diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 99f0407f..b0de0d8c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -84,12 +84,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 */; }; @@ -231,12 +233,14 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; @@ -459,6 +463,7 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, ); @@ -473,6 +478,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, @@ -700,6 +706,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 */, ); @@ -714,6 +721,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 */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 69f4d1b8..0396f6e9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4564,6 +4564,1332 @@ } } }, + "browser.profile.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル: %@" + } + } + } + }, + "browser.profile.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + } + } + }, + "browser.profile.menu.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル" + } + } + } + }, + "browser.profile.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいプロファイル..." + } + } + } + }, + "browser.profile.new.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a separate browser profile for cookies, history, and local storage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie、履歴、ローカルストレージを分けるためのブラウザープロファイルを作成します。" + } + } + } + }, + "browser.profile.new.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル名" + } + } + } + }, + "browser.profile.new.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいブラウザープロファイル" + } + } + } + }, + "browser.profile.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Current Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のプロファイル名を変更..." + } + } + } + }, + "browser.profile.rename.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a new name for this browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このブラウザープロファイルの新しい名前を入力します。" + } + } + } + }, + "browser.profile.rename.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル名を変更" + } + } + } + }, + "browser.import.additionalData.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.additionalData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional data (bookmarks, settings, extensions)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加データ(ブックマーク、設定、拡張機能)" + } + } + } + }, + "browser.import.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, + "browser.import.complete.browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ: %@" + } + } + } + }, + "browser.import.complete.createdProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created cmux profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成した cmux プロファイル: %@" + } + } + } + }, + "browser.import.complete.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Destination profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存先プロファイル: %@" + } + } + } + }, + "browser.import.complete.domainFilter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Domain filter: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインフィルタ: %@" + } + } + } + }, + "browser.import.complete.profileMapping": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ -> %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ -> %2$@" + } + } + } + }, + "browser.import.complete.profileMappings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile mappings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル対応:" + } + } + } + }, + "browser.import.complete.importedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookie: %ld" + } + } + } + }, + "browser.import.complete.importedHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported history entries: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートした履歴件数: %ld" + } + } + } + }, + "browser.import.complete.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象: %@" + } + } + } + }, + "browser.import.complete.skippedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップしたCookie: %ld" + } + } + } + }, + "browser.import.complete.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル: %@" + } + } + } + }, + "browser.import.complete.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser data import complete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータのインポートが完了しました" + } + } + } + }, + "browser.import.complete.warnings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warnings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告:" + } + } + } + }, + "browser.import.cookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies (site sign-ins)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie(サイトのログイン状態)" + } + } + } + }, + "browser.import.destination.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux destination" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux の保存先" + } + } + } + }, + "browser.import.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import into" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート先" + } + } + } + }, + "browser.import.destinationProfile.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create \"%@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" を作成" + } + } + } + }, + "browser.import.destinationProfile.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies and history go into the selected cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookieと履歴は、選択したcmuxブラウザープロファイルに保存されます。" + } + } + } + }, + "browser.import.destinationProfile.mergeHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All selected source profiles will be merged into the chosen cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した元プロファイルはすべて、選んだ cmux ブラウザープロファイルにまとめて取り込まれます。" + } + } + } + }, + "browser.import.destinationProfile.separateHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing cmux profiles are created when import starts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不足している cmux プロファイルは、インポート開始時に作成されます。" + } + } + } + }, + "browser.import.destinationMode.merge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Merge all into one cmux profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてを1つの cmux プロファイルにまとめる" + } + } + } + }, + "browser.import.destinationMode.separate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep profiles separate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを分けたまま取り込む" + } + } + } + }, + "browser.import.detected.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@。" + } + } + } + }, + "browser.import.detected.more.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +1 more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか1件。" + } + } + } + }, + "browser.import.detected.more.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +%ld more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか%ld件。" + } + } + } + }, + "browser.import.detected.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No supported browsers detected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応しているブラウザーが見つかりませんでした。" + } + } + } + }, + "browser.import.domain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limit to" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象ドメイン" + } + } + } + }, + "browser.import.domain.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional domains only (e.g. github.com, openai.com)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のドメインのみ(例: github.com, openai.com)" + } + } + } + }, + "browser.import.error.destinationCreateFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not create the destination profile \"%@\"." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は保存先プロファイル「%@」を作成できませんでした。" + } + } + } + }, + "browser.import.error.destinationMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected cmux browser profile no longer exists. Pick a destination profile again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した cmux ブラウザープロファイルが見つかりません。保存先プロファイルを選び直してください。" + } + } + } + }, + "browser.import.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import could not start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを開始できませんでした" + } + } + } + }, + "browser.import.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History (visited pages)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴(訪問したページ)" + } + } + } + }, + "browser.import.next": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次へ" + } + } + } + }, + "browser.import.noBrowsers.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not find browser profiles to import from on this Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacでインポート元にできるブラウザープロファイルが見つかりませんでした。" + } + } + } + }, + "browser.import.noBrowsers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No importable browsers found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートできるブラウザーが見つかりません" + } + } + } + }, + "browser.import.progress.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing %@ from %@…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%2$@ から %1$@ をインポート中…" + } + } + } + }, + "browser.import.progress.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This can take a few seconds for large profiles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルが大きい場合は数秒かかることがあります。" + } + } + } + }, + "browser.import.progress.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート中" + } + } + } + }, + "browser.import.scope.cookiesAndHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies + history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie + 履歴" + } + } + } + }, + "browser.import.scope.cookiesOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookieのみ" + } + } + } + }, + "browser.import.scope.everything": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Everything" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + } + } + }, + "browser.import.scope.historyOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のみ" + } + } + } + }, + "browser.import.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート元" + } + } + } + }, + "browser.import.sourceProfile.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル%ld" + } + } + } + }, + "browser.import.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル" + } + } + } + }, + "browser.import.sourceProfiles.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイルを1つ以上選択してください。3 / 3 で、分けたまま取り込むか、1つの cmux プロファイルにまとめるかを選べます。" + } + } + } + }, + "browser.import.sourceProfiles.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No source profiles detected for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の元プロファイルが見つかりません。" + } + } + } + }, + "browser.import.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート開始" + } + } + } + }, + "browser.import.step.dataTypes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 3 of 3: Choose what to import from %@ and where to put it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "3 / 3: %@ から何をインポートし、どこに保存するかを選択します。" + } + } + } + }, + "browser.import.step.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 1 of 3: Choose the browser to import from." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 / 3: インポート元のブラウザーを選択します。" + } + } + } + }, + "browser.import.step.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 2 of 3: Choose source profiles from %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2 / 3: %@ の元プロファイルを選択します。" + } + } + } + }, + "browser.import.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.validation.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select Cookies, History, or both before starting import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを始める前に、Cookie、履歴、またはその両方を選択してください。" + } + } + } + }, + "browser.import.validation.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose at least one source profile to import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートする元プロファイルを少なくとも1つ選択してください。" + } + } + } + }, + "browser.import.warning.additionalDataUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。Cookieと履歴のみを取り込みました。" + } + } + } + }, + "browser.import.warning.browserCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.browserHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.cookieImportUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ cookie import is not implemented yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.warning.encryptedCookiesSkipped": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted cookies that require Keychain decryption." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainでの復号が必要な暗号化Cookieを%ld件スキップしました。" + } + } + } + }, + "browser.import.warning.keychainDecryptFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychain から %3$@ を開けなかったため、暗号化された %2$@ のCookieを%1$ld件スキップしました。" + } + } + } + }, + "browser.import.warning.firefoxCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.firefoxHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.noHistoryDatabase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No history database found for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴データベースが見つかりませんでした。" + } + } + } + }, + "browser.import.warning.safariCookiesUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Safari のCookieは Cookies.binarycookies に保存されており、このインポーターではまだ対応していません。" + } + } + } + }, + "browser.theme.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Theme: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーテーマ: %@" + } + } + } + }, "browser.addressBarSuggestions": { "extractionState": "manual", "localizations": { @@ -22972,6 +24298,23 @@ } } }, + "common.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成" + } + } + } + }, "common.close": { "extractionState": "manual", "localizations": { @@ -36521,6 +37864,23 @@ } } }, + "menu.view.importFromBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む…" + } + } + } + }, "menu.view.forward": { "extractionState": "manual", "localizations": { @@ -49269,6 +50629,40 @@ } } }, + "settings.browser.emptyImport.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose What to Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "取り込む項目を選ぶ…" + } + } + } + }, + "settings.browser.emptyImport.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデータを取り込む" + } + } + } + }, "settings.browser.history": { "extractionState": "manual", "localizations": { @@ -49382,6 +50776,57 @@ } } }, + "settings.browser.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む" + } + } + } + }, + "settings.browser.import.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択…" + } + } + } + }, + "settings.browser.import.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + } + } + }, "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 674b9d5c..a77cf00a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2386,7 +2386,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() } @@ -8861,7 +8861,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) " + diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 73eda80a..7af68022 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,26 @@ import Combine import WebKit import AppKit import Bonsplit +import SQLite3 +import CryptoKit +#if canImport(CommonCrypto) +import CommonCrypto +#endif +#if canImport(Security) +import Security +#endif + +fileprivate func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + for url in urls { + let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path + if seen.insert(canonical).inserted { + result.append(url) + } + } + return result +} enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { @@ -178,6 +198,209 @@ enum BrowserThemeSettings { } } +struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { + let id: UUID + var displayName: String + let createdAt: Date + let isBuiltInDefault: Bool + + var slug: String { + if isBuiltInDefault { + return "default" + } + + let normalized = displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return normalized.isEmpty ? id.uuidString.lowercased() : normalized + } +} + +@MainActor +final class BrowserProfileStore: ObservableObject { + static let shared = BrowserProfileStore() + + private static let profilesDefaultsKey = "browserProfiles.v1" + private static let lastUsedProfileDefaultsKey = "browserProfiles.lastUsed" + private static let builtInDefaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + + @Published private(set) var profiles: [BrowserProfileDefinition] = [] + @Published private(set) var lastUsedProfileID: UUID = builtInDefaultProfileID + + private let defaults: UserDefaults + private var dataStores: [UUID: WKWebsiteDataStore] = [:] + private var historyStores: [UUID: BrowserHistoryStore] = [:] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + var builtInDefaultProfileID: UUID { + Self.builtInDefaultProfileID + } + + var effectiveLastUsedProfileID: UUID { + profileDefinition(id: lastUsedProfileID) != nil ? lastUsedProfileID : Self.builtInDefaultProfileID + } + + func profileDefinition(id: UUID) -> BrowserProfileDefinition? { + profiles.first(where: { $0.id == id }) + } + + func displayName(for id: UUID) -> String { + profileDefinition(id: id)?.displayName + ?? String(localized: "browser.profile.default", defaultValue: "Default") + } + + func createProfile(named rawName: String) -> BrowserProfileDefinition? { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let profile = BrowserProfileDefinition( + id: UUID(), + displayName: name, + createdAt: Date(), + isBuiltInDefault: false + ) + profiles.append(profile) + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + noteUsed(profile.id) + return profile + } + + func renameProfile(id: UUID, to rawName: String) -> Bool { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }), + !profiles[index].isBuiltInDefault else { + return false + } + profiles[index].displayName = name + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + return true + } + + func canRenameProfile(id: UUID) -> Bool { + guard let profile = profileDefinition(id: id) else { return false } + return !profile.isBuiltInDefault + } + + func noteUsed(_ id: UUID) { + guard profileDefinition(id: id) != nil else { return } + if lastUsedProfileID != id { + lastUsedProfileID = id + defaults.set(id.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + func websiteDataStore(for profileID: UUID) -> WKWebsiteDataStore { + if profileID == Self.builtInDefaultProfileID { + return .default() + } + if let existing = dataStores[profileID] { + return existing + } + let store = WKWebsiteDataStore(forIdentifier: profileID) + dataStores[profileID] = store + return store + } + + func historyStore(for profileID: UUID) -> BrowserHistoryStore { + if profileID == Self.builtInDefaultProfileID { + return .shared + } + if let existing = historyStores[profileID] { + return existing + } + let store = BrowserHistoryStore(fileURL: historyFileURL(for: profileID)) + historyStores[profileID] = store + return store + } + + func historyFileURL(for profileID: UUID) -> URL? { + if profileID == Self.builtInDefaultProfileID { + return BrowserHistoryStore.defaultHistoryFileURLForCurrentBundle() + } + + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let bundleId = Bundle.main.bundleIdentifier ?? "cmux" + let namespace = BrowserHistoryStore.normalizedBrowserHistoryNamespaceForBundleIdentifier(bundleId) + let profilesDir = appSupport + .appendingPathComponent(namespace, isDirectory: true) + .appendingPathComponent("browser_profiles", isDirectory: true) + .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) + return profilesDir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + func flushPendingSaves() { + BrowserHistoryStore.shared.flushPendingSaves() + for store in historyStores.values { + store.flushPendingSaves() + } + } + + private func load() { + let builtInDefaultProfile = BrowserProfileDefinition( + id: Self.builtInDefaultProfileID, + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + createdAt: Date(timeIntervalSince1970: 0), + isBuiltInDefault: true + ) + + if let data = defaults.data(forKey: Self.profilesDefaultsKey), + let decoded = try? JSONDecoder().decode([BrowserProfileDefinition].self, from: data), + !decoded.isEmpty { + var resolvedProfiles = decoded.filter { $0.id != Self.builtInDefaultProfileID } + resolvedProfiles.append(builtInDefaultProfile) + profiles = sortedProfiles(resolvedProfiles) + } else { + profiles = [builtInDefaultProfile] + persist() + } + + if let rawLastUsed = defaults.string(forKey: Self.lastUsedProfileDefaultsKey), + let parsed = UUID(uuidString: rawLastUsed), + profileDefinition(id: parsed) != nil { + lastUsedProfileID = parsed + } else { + lastUsedProfileID = Self.builtInDefaultProfileID + defaults.set(lastUsedProfileID.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + private func persist() { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(profiles) else { return } + defaults.set(data, forKey: Self.profilesDefaultsKey) + } + + private func sortedProfiles(_ profiles: [BrowserProfileDefinition]) -> [BrowserProfileDefinition] { + profiles.sorted { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} + enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true @@ -800,6 +1023,100 @@ final class BrowserHistoryStore: ObservableObject { return Array(ranked.prefix(limit)) } + @discardableResult + func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { + loadIfNeeded() + guard !importedEntries.isEmpty else { return 0 } + + var mergedCount = 0 + for imported in importedEntries { + guard let parsedURL = URL(string: imported.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } + + if let host = parsedURL.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { continue } + } + + let urlString = parsedURL.absoluteString + guard urlString != "about:blank" else { continue } + let normalizedKey = normalizedHistoryKey(url: parsedURL) + + let importedTitle = imported.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let importedLastVisited = imported.lastVisited + let importedVisitCount = max(1, imported.visitCount) + let importedTypedCount = max(0, imported.typedCount) + let importedLastTypedAt = imported.lastTypedAt + + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + guard let normalizedKey else { return false } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + var didMutate = false + if importedLastVisited > entries[idx].lastVisited { + entries[idx].lastVisited = importedLastVisited + didMutate = true + } + if importedVisitCount > entries[idx].visitCount { + entries[idx].visitCount = importedVisitCount + didMutate = true + } + if importedTypedCount > entries[idx].typedCount { + entries[idx].typedCount = importedTypedCount + didMutate = true + } + if let importedLastTypedAt { + if let existingLastTypedAt = entries[idx].lastTypedAt { + if importedLastTypedAt > existingLastTypedAt { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } else { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } + + let existingTitle = entries[idx].title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let incomingTitle = importedTitle ?? "" + if !incomingTitle.isEmpty, + (existingTitle.isEmpty || importedLastVisited >= entries[idx].lastVisited) { + if entries[idx].title != incomingTitle { + entries[idx].title = incomingTitle + didMutate = true + } + } + + if didMutate { + mergedCount += 1 + } + } else { + entries.append(Entry( + id: UUID(), + url: urlString, + title: importedTitle, + lastVisited: importedLastVisited, + visitCount: importedVisitCount, + typedCount: importedTypedCount, + lastTypedAt: importedLastTypedAt + )) + mergedCount += 1 + } + } + + guard mergedCount > 0 else { return 0 } + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + scheduleSave() + return mergedCount + } + func clearHistory() { loadIfNeeded() saveTask?.cancel() @@ -1078,6 +1395,14 @@ final class BrowserHistoryStore: ObservableObject { let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: [.atomic]) } + + nonisolated static func defaultHistoryFileURLForCurrentBundle() -> URL? { + defaultHistoryFileURL() + } + + nonisolated static func normalizedBrowserHistoryNamespaceForBundleIdentifier(_ bundleIdentifier: String) -> String { + normalizedBrowserHistoryNamespace(bundleIdentifier: bundleIdentifier) + } } actor BrowserSearchSuggestionService { @@ -1410,6 +1735,9 @@ final class BrowserPanel: Panel, ObservableObject { /// The workspace ID this panel belongs to private(set) var workspaceId: UUID + @Published private(set) var profileID: UUID + @Published private(set) var historyStore: BrowserHistoryStore + /// The underlying web view private(set) var webView: WKWebView @@ -1807,6 +2135,14 @@ final class BrowserPanel: Panel, ObservableObject { return String(localized: "browser.newTab", defaultValue: "New tab") } + var profileDisplayName: String { + BrowserProfileStore.shared.displayName(for: profileID) + } + + var usesBuiltInDefaultProfile: Bool { + profileID == BrowserProfileStore.shared.builtInDefaultProfileID + } + private static let portalHostAreaThreshold: CGFloat = 4 private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 @@ -1965,13 +2301,11 @@ final class BrowserPanel: Panel, ObservableObject { false } - private static func makeWebView() -> CmuxWebView { + private static func makeWebView(profileID: UUID) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] - // Ensure browser cookies/storage persist across navigations and launches. - // This reduces repeated consent/bot-challenge flows on sites like Google. - config.websiteDataStore = .default() + config.websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: profileID) // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -2031,20 +2365,34 @@ final class BrowserPanel: Panel, ObservableObject { return instanceID == webViewInstanceID } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + init( + workspaceId: UUID, + profileID: UUID? = nil, + initialURL: URL? = nil, + bypassInsecureHTTPHostOnce: String? = nil + ) { self.id = UUID() self.workspaceId = workspaceId + let requestedProfileID = profileID ?? BrowserProfileStore.shared.effectiveLastUsedProfileID + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + self.profileID = resolvedProfileID + self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.browserThemeMode = BrowserThemeSettings.mode() - let webView = Self.makeWebView() + let webView = Self.makeWebView(profileID: resolvedProfileID) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.didFinish = { webView in - BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) + Task { @MainActor [weak self] in + self?.historyStore.recordVisit(url: webView.url, title: webView.title) + } Task { @MainActor [weak self] in guard let self, self.isCurrentWebView(webView) else { return } self.refreshFavicon(from: webView) @@ -2150,6 +2498,84 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId = newWorkspaceId } + @discardableResult + func switchToProfile(_ requestedProfileID: UUID) -> Bool { + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + guard resolvedProfileID != profileID else { + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + return false + } + + let previousWebView = webView + let wasRenderable = shouldRenderWebView + let restoreURL = previousWebView.url ?? currentURL + let restoreURLString = restoreURL?.absoluteString + let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString + let history = sessionNavigationHistorySnapshot() + let historyCurrentURL = preferredURLStringForOmnibar() + let desiredZoom = max(minPageZoom, min(maxPageZoom, previousWebView.pageZoom)) + let restoreDeveloperTools = preferredDeveloperToolsVisible || isDeveloperToolsVisible() + + invalidateSearchFocusRequests(reason: "profileSwitch") + searchState = nil + + _ = hideDeveloperTools() + cancelDeveloperToolsRestoreRetry() + + webViewObservers.removeAll() + webViewCancellables.removeAll() + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 + BrowserWindowPortalRegistry.detach(webView: previousWebView) + previousWebView.stopLoading() + previousWebView.navigationDelegate = nil + previousWebView.uiDelegate = nil + if let previousCmuxWebView = previousWebView as? CmuxWebView { + previousCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + profileID = resolvedProfileID + historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + + let replacement = Self.makeWebView(profileID: resolvedProfileID) + replacement.pageZoom = desiredZoom + webViewInstanceID = UUID() + webView = replacement + currentURL = restoreURL + shouldRenderWebView = wasRenderable + + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + + if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { + restoreSessionNavigationHistory( + backHistoryURLStrings: history.backHistoryURLStrings, + forwardHistoryURLStrings: history.forwardHistoryURLStrings, + currentURLString: historyCurrentURL + ) + } + + if shouldRestoreURL, let restoreURL { + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + } else { + refreshNavigationAvailability() + } + + if restoreDeveloperTools { + requestDeveloperToolsRefreshAfterNextAttach(reason: "profile_switch") + } + + return true + } + func triggerFlash() { guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 @@ -2298,7 +2724,7 @@ final class BrowserPanel: Panel, ObservableObject { terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(profileID: profileID) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2653,7 +3079,7 @@ final class BrowserPanel: Panel, ObservableObject { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) + historyStore.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url browserLoadRequest(request, in: webView) @@ -2879,7 +3305,7 @@ extension BrowserPanel { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView() + let replacement = Self.makeWebView(profileID: profileID) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false @@ -3013,6 +3439,7 @@ extension BrowserPanel { inPane: paneId, url: url, focus: true, + preferredProfileID: profileID, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) #if DEBUG @@ -5177,3 +5604,3685 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } } } + +// MARK: - Browser Data Import + +enum BrowserImportScope: String, CaseIterable, Identifiable { + case cookiesOnly + case historyOnly + case cookiesAndHistory + case everything + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cookiesOnly: + return String(localized: "browser.import.scope.cookiesOnly", defaultValue: "Cookies only") + case .historyOnly: + return String(localized: "browser.import.scope.historyOnly", defaultValue: "History only") + case .cookiesAndHistory: + return String(localized: "browser.import.scope.cookiesAndHistory", defaultValue: "Cookies + history") + case .everything: + return String(localized: "browser.import.scope.everything", defaultValue: "Everything") + } + } + + var includesCookies: Bool { + switch self { + case .cookiesOnly, .cookiesAndHistory, .everything: + return true + case .historyOnly: + return false + } + } + + var includesHistory: Bool { + switch self { + case .cookiesOnly: + return false + case .historyOnly, .cookiesAndHistory, .everything: + return true + } + } + + static func fromSelection( + includeCookies: Bool, + includeHistory: Bool, + includeAdditionalData: Bool + ) -> BrowserImportScope? { + if includeAdditionalData { + return .everything + } + guard includeCookies || includeHistory else { return nil } + if includeCookies && includeHistory { + return .cookiesAndHistory + } + if includeCookies { + return .cookiesOnly + } + return .historyOnly + } +} + +enum BrowserImportEngineFamily: String, Hashable { + case chromium + case firefox + case webkit +} + +struct InstalledBrowserProfile: Identifiable, Hashable { + let displayName: String + let rootURL: URL + let isDefault: Bool + + var id: String { + rootURL.standardizedFileURL.resolvingSymlinksInPath().path + } +} + +struct BrowserImportBrowserDescriptor: Hashable { + let id: String + let displayName: String + let family: BrowserImportEngineFamily + let tier: Int + let bundleIdentifiers: [String] + let appNames: [String] + let dataRootRelativePaths: [String] + let dataArtifactRelativePaths: [String] + let supportsDataOnlyDetection: Bool +} + +struct InstalledBrowserCandidate: Identifiable, Hashable { + let descriptor: BrowserImportBrowserDescriptor + let resolvedFamily: BrowserImportEngineFamily + let homeDirectoryURL: URL + let appURL: URL? + let dataRootURL: URL? + let profiles: [InstalledBrowserProfile] + let detectionSignals: [String] + let detectionScore: Int + + var id: String { descriptor.id } + var displayName: String { descriptor.displayName } + var family: BrowserImportEngineFamily { resolvedFamily } + var profileURLs: [URL] { profiles.map(\.rootURL) } +} + +enum InstalledBrowserDetector { + typealias BundleLookup = (String) -> URL? + + static let allBrowserDescriptors: [BrowserImportBrowserDescriptor] = [ + BrowserImportBrowserDescriptor( + id: "safari", + displayName: "Safari", + family: .webkit, + tier: 1, + bundleIdentifiers: ["com.apple.Safari"], + appNames: ["Safari.app"], + dataRootRelativePaths: ["Library/Safari"], + dataArtifactRelativePaths: [ + "Library/Safari/History.db", + "Library/Cookies/Cookies.binarycookies", + ], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "google-chrome", + displayName: "Google Chrome", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + dataRootRelativePaths: ["Library/Application Support/Google/Chrome"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "firefox", + displayName: "Firefox", + family: .firefox, + tier: 1, + bundleIdentifiers: ["org.mozilla.firefox"], + appNames: ["Firefox.app"], + dataRootRelativePaths: ["Library/Application Support/Firefox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "arc", + displayName: "Arc", + family: .chromium, + tier: 1, + bundleIdentifiers: ["company.thebrowser.Browser", "company.thebrowser.arc"], + appNames: ["Arc.app"], + dataRootRelativePaths: ["Library/Application Support/Arc"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "brave", + displayName: "Brave", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.brave.Browser"], + appNames: ["Brave Browser.app"], + dataRootRelativePaths: ["Library/Application Support/BraveSoftware/Brave-Browser"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "microsoft-edge", + displayName: "Microsoft Edge", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.microsoft.edgemac", "com.microsoft.Edge"], + appNames: ["Microsoft Edge.app"], + dataRootRelativePaths: ["Library/Application Support/Microsoft Edge"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "zen", + displayName: "Zen Browser", + family: .firefox, + tier: 2, + bundleIdentifiers: ["app.zen-browser.zen", "app.zen-browser.Zen"], + appNames: ["Zen Browser.app", "Zen.app"], + dataRootRelativePaths: ["Library/Application Support/Zen", "Library/Application Support/zen"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "vivaldi", + displayName: "Vivaldi", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.vivaldi.Vivaldi"], + appNames: ["Vivaldi.app"], + dataRootRelativePaths: ["Library/Application Support/Vivaldi"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera", + displayName: "Opera", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.Opera"], + appNames: ["Opera.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.Opera", + "Library/Application Support/Opera", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera-gx", + displayName: "Opera GX", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.OperaGX"], + appNames: ["Opera GX.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.OperaGX", + "Library/Application Support/Opera GX Stable", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "orion", + displayName: "Orion", + family: .webkit, + tier: 2, + bundleIdentifiers: ["com.kagi.kagimacOS", "com.kagi.kagimacos", "com.kagi.orion"], + appNames: ["Orion.app"], + dataRootRelativePaths: ["Library/Application Support/Orion"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "dia", + displayName: "Dia", + family: .chromium, + tier: 2, + bundleIdentifiers: ["company.thebrowser.Dia", "company.thebrowser.dia"], + appNames: ["Dia.app"], + dataRootRelativePaths: ["Library/Application Support/Dia"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "perplexity-comet", + displayName: "Perplexity Comet", + family: .chromium, + tier: 3, + bundleIdentifiers: ["ai.perplexity.comet"], + appNames: ["Perplexity Comet.app", "Comet.app"], + dataRootRelativePaths: ["Library/Application Support/Comet"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "floorp", + displayName: "Floorp", + family: .firefox, + tier: 3, + bundleIdentifiers: ["one.ablaze.floorp"], + appNames: ["Floorp.app"], + dataRootRelativePaths: ["Library/Application Support/Floorp"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "waterfox", + displayName: "Waterfox", + family: .firefox, + tier: 3, + bundleIdentifiers: ["net.waterfox.waterfox"], + appNames: ["Waterfox.app"], + dataRootRelativePaths: ["Library/Application Support/Waterfox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sigmaos", + displayName: "SigmaOS", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.feralcat.sigmaos"], + appNames: ["SigmaOS.app"], + dataRootRelativePaths: ["Library/Application Support/SigmaOS"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sidekick", + displayName: "Sidekick", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.meetsidekick.Sidekick", "com.pushplaylabs.sidekick"], + appNames: ["Sidekick.app"], + dataRootRelativePaths: ["Library/Application Support/Sidekick"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "helium", + displayName: "Helium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], + appNames: ["Helium.app"], + dataRootRelativePaths: [ + "Library/Application Support/net.imput.helium", + "Library/Application Support/Helium", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "atlas", + displayName: "Atlas", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.atlas.browser"], + appNames: ["Atlas.app"], + dataRootRelativePaths: ["Library/Application Support/Atlas"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ladybird", + displayName: "Ladybird", + family: .webkit, + tier: 3, + bundleIdentifiers: ["org.ladybird.Browser", "org.serenityos.ladybird"], + appNames: ["Ladybird.app"], + dataRootRelativePaths: ["Library/Application Support/Ladybird"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "chromium", + displayName: "Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ungoogled-chromium", + displayName: "Ungoogled Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.ungoogled"], + appNames: ["Ungoogled Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ), + ] + + static func detectInstalledBrowsers( + homeDirectoryURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), + bundleLookup: BundleLookup? = nil, + applicationSearchDirectories: [URL]? = nil, + fileManager: FileManager = .default + ) -> [InstalledBrowserCandidate] { + let lookup = bundleLookup ?? { bundleIdentifier in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + let appSearchDirectories = applicationSearchDirectories ?? defaultApplicationSearchDirectories(homeDirectoryURL: homeDirectoryURL) + + let candidates = allBrowserDescriptors.compactMap { descriptor -> InstalledBrowserCandidate? in + let appDetection = detectApplication( + descriptor: descriptor, + appSearchDirectories: appSearchDirectories, + bundleLookup: lookup, + fileManager: fileManager + ) + + let dataDetection = detectData( + descriptor: descriptor, + homeDirectoryURL: homeDirectoryURL, + appBundleIdentifier: appDetection.bundleIdentifier, + fileManager: fileManager + ) + + if appDetection.url == nil, + !descriptor.supportsDataOnlyDetection { + return nil + } + + let hasData = dataDetection.dataRootURL != nil || !dataDetection.profiles.isEmpty || !dataDetection.artifactHits.isEmpty + guard appDetection.url != nil || hasData else { + return nil + } + + var score = 0 + if appDetection.url != nil { + score += 80 + } + if dataDetection.dataRootURL != nil { + score += 24 + } + score += min(24, dataDetection.profiles.count * 6) + score += min(16, dataDetection.artifactHits.count * 4) + + var signals: [String] = [] + signals.append(contentsOf: appDetection.signals) + if let root = dataDetection.dataRootURL { + signals.append("data:\(root.lastPathComponent)") + } + if !dataDetection.profiles.isEmpty { + signals.append("profiles:\(dataDetection.profiles.count)") + } + if !dataDetection.artifactHits.isEmpty { + signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) + } + + return InstalledBrowserCandidate( + descriptor: descriptor, + resolvedFamily: dataDetection.family, + homeDirectoryURL: homeDirectoryURL, + appURL: appDetection.url, + dataRootURL: dataDetection.dataRootURL, + profiles: dataDetection.profiles, + detectionSignals: signals, + detectionScore: score + ) + } + + return candidates.sorted { lhs, rhs in + if lhs.detectionScore != rhs.detectionScore { + return lhs.detectionScore > rhs.detectionScore + } + if lhs.descriptor.tier != rhs.descriptor.tier { + return lhs.descriptor.tier < rhs.descriptor.tier + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + static func summaryText(for browsers: [InstalledBrowserCandidate], limit: Int = 4) -> String { + guard !browsers.isEmpty else { + return String( + localized: "browser.import.detected.none", + defaultValue: "No supported browsers detected." + ) + } + let names = browsers.map(\.displayName) + if names.count <= limit { + return String( + format: String( + localized: "browser.import.detected.all", + defaultValue: "Detected: %@." + ), + names.joined(separator: ", ") + ) + } + let shown = names.prefix(limit).joined(separator: ", ") + let remaining = names.count - limit + if remaining == 1 { + return String( + format: String( + localized: "browser.import.detected.more.one", + defaultValue: "Detected: %@, +1 more." + ), + shown + ) + } + return String( + format: String( + localized: "browser.import.detected.more.other", + defaultValue: "Detected: %@, +%ld more." + ), + shown, + remaining + ) + } + + private static func detectApplication( + descriptor: BrowserImportBrowserDescriptor, + appSearchDirectories: [URL], + bundleLookup: BundleLookup, + fileManager: FileManager + ) -> (url: URL?, signals: [String], bundleIdentifier: String?) { + for knownBundleIdentifier in descriptor.bundleIdentifiers { + if let appURL = bundleLookup(knownBundleIdentifier) { + return (appURL, ["bundle:\(knownBundleIdentifier)"], bundleIdentifier(for: appURL) ?? knownBundleIdentifier) + } + } + + for appName in descriptor.appNames { + for directory in appSearchDirectories { + let appURL = directory.appendingPathComponent(appName, isDirectory: true) + if fileManager.fileExists(atPath: appURL.path) { + return (appURL, ["app:\(appName)"], bundleIdentifier(for: appURL)) + } + } + } + + return (nil, [], nil) + } + + private static func detectData( + descriptor: BrowserImportBrowserDescriptor, + homeDirectoryURL: URL, + appBundleIdentifier: String?, + fileManager: FileManager + ) -> (dataRootURL: URL?, family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile], artifactHits: [String]) { + var bestRootURL: URL? + var bestFamily = descriptor.family + var bestProfiles: [InstalledBrowserProfile] = [] + var bestArtifacts: [String] = [] + let candidateRootPaths = candidateDataRootRelativePaths( + descriptor: descriptor, + appBundleIdentifier: appBundleIdentifier + ) + + for relativePath in candidateRootPaths { + let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) + guard fileManager.fileExists(atPath: rootURL.path) else { continue } + + let detectedProfiles = detectProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + ) + + let score = scoreProfileDetection( + family: detectedProfiles.family, + profiles: detectedProfiles.profiles, + preferredFamily: descriptor.family + ) + 8 + let currentScore = scoreProfileDetection( + family: bestFamily, + profiles: bestProfiles, + preferredFamily: descriptor.family + ) + (bestRootURL == nil ? 0 : 8) + if score > currentScore { + bestRootURL = rootURL + bestFamily = detectedProfiles.family + bestProfiles = detectedProfiles.profiles + } + } + + var artifactHits: [String] = [] + for relativePath in descriptor.dataArtifactRelativePaths { + let artifactURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: false) + if fileManager.fileExists(atPath: artifactURL.path) { + artifactHits.append(artifactURL.lastPathComponent) + } + } + + if !artifactHits.isEmpty { + bestArtifacts = artifactHits + if bestRootURL == nil, + let rootPath = candidateRootPaths.first { + let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) + if fileManager.fileExists(atPath: rootURL.path) { + bestRootURL = rootURL + } + } + } + + if bestProfiles.isEmpty, let bestRootURL { + bestProfiles = [ + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: bestRootURL, + isDefault: true + ) + ] + } + + return ( + dataRootURL: bestRootURL, + family: bestFamily, + profiles: sortProfiles(dedupedProfiles(bestProfiles)), + artifactHits: bestArtifacts + ) + } + + private static func detectProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> (family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile]) { + let candidates: [(BrowserImportEngineFamily, [InstalledBrowserProfile])] = [ + (.chromium, chromiumProfiles(rootURL: rootURL, fileManager: fileManager)), + (.firefox, firefoxProfiles(rootURL: rootURL, fileManager: fileManager)), + (.webkit, webKitProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + )), + ] + + return candidates.max { lhs, rhs in + let lhsScore = scoreProfileDetection( + family: lhs.0, + profiles: lhs.1, + preferredFamily: descriptor.family + ) + let rhsScore = scoreProfileDetection( + family: rhs.0, + profiles: rhs.1, + preferredFamily: descriptor.family + ) + if lhsScore != rhsScore { + return lhsScore < rhsScore + } + return lhs.0.rawValue > rhs.0.rawValue + } ?? (descriptor.family, []) + } + + private static func bundleIdentifier(for appURL: URL) -> String? { + Bundle(url: appURL)?.bundleIdentifier + } + + private static func candidateDataRootRelativePaths( + descriptor: BrowserImportBrowserDescriptor, + appBundleIdentifier: String? + ) -> [String] { + var result: [String] = [] + var seen = Set() + + func append(_ relativePath: String) { + if seen.insert(relativePath).inserted { + result.append(relativePath) + } + } + + for relativePath in descriptor.dataRootRelativePaths { + append(relativePath) + } + + let bundleIdentifiers = [appBundleIdentifier].compactMap { $0 } + descriptor.bundleIdentifiers + for bundleIdentifier in bundleIdentifiers { + append("Library/Application Support/\(bundleIdentifier)") + append("Library/Containers/\(bundleIdentifier)/Data/Library/Application Support/\(bundleIdentifier)") + } + + return result + } + + private static func scoreProfileDetection( + family: BrowserImportEngineFamily, + profiles: [InstalledBrowserProfile], + preferredFamily: BrowserImportEngineFamily + ) -> Int { + var score = profiles.count * 10 + if family == preferredFamily { + score += 3 + } + if profiles.contains(where: \.isDefault) { + score += 1 + } + return score + } + + private static func chromiumProfiles( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + let nameMap = chromiumProfileNameMap(rootURL: rootURL) + var profiles: [InstalledBrowserProfile] = [] + if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: rootURL.lastPathComponent, + nameMap: nameMap, + isDefault: true + ), + rootURL: rootURL, + isDefault: true + ) + ) + } + + let children = (try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + let name = child.lastPathComponent + let isLikelyProfile = + name == "Default" || + name.hasPrefix("Profile ") || + name.hasPrefix("Guest Profile") || + name.hasPrefix("Person ") || + nameMap[name] != nil + if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: name, + nameMap: nameMap, + isDefault: name == "Default" + ), + rootURL: child, + isDefault: name == "Default" + ) + ) + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func firefoxProfiles( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + var profiles = firefoxProfilesFromINI(rootURL: rootURL, fileManager: fileManager) + + let likelyProfileRoots = [ + rootURL.appendingPathComponent("Profiles", isDirectory: true), + rootURL, + ] + + for directory in likelyProfileRoots where fileManager.fileExists(atPath: directory.path) { + let children = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + if looksLikeFirefoxProfile(rootURL: child, fileManager: fileManager) { + let directoryName = child.lastPathComponent + profiles.append( + InstalledBrowserProfile( + displayName: directoryName, + rootURL: child, + isDefault: directoryName.localizedCaseInsensitiveContains("default") + ) + ) + } + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func firefoxProfilesFromINI( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + let iniURL = rootURL.appendingPathComponent("profiles.ini", isDirectory: false) + guard let contents = try? String(contentsOf: iniURL, encoding: .utf8) else { + return [] + } + + let sections = parseINISections(contents: contents) + var profiles: [InstalledBrowserProfile] = [] + for section in sections { + guard let pathValue = section["Path"], !pathValue.isEmpty else { continue } + let isRelative = section["IsRelative"] != "0" + let profileURL: URL + if isRelative { + profileURL = rootURL.appendingPathComponent(pathValue, isDirectory: true) + } else { + profileURL = URL(fileURLWithPath: pathValue, isDirectory: true) + } + if looksLikeFirefoxProfile(rootURL: profileURL, fileManager: fileManager) { + let displayName = section["Name"]?.trimmingCharacters(in: .whitespacesAndNewlines) + profiles.append( + InstalledBrowserProfile( + displayName: (displayName?.isEmpty == false ? displayName! : profileURL.lastPathComponent), + rootURL: profileURL, + isDefault: section["Default"] == "1" + ) + ) + } + } + return profiles + } + + private static func parseINISections(contents: String) -> [[String: String]] { + var sections: [[String: String]] = [] + var current: [String: String] = [:] + + func flushCurrent() { + if !current.isEmpty { + sections.append(current) + current.removeAll() + } + } + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix(";") || trimmed.hasPrefix("#") { + continue + } + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + flushCurrent() + continue + } + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. Bool { + let historyURL = rootURL.appendingPathComponent("History", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("Cookies", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func looksLikeFirefoxProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let historyURL = rootURL.appendingPathComponent("places.sqlite", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func webKitProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + var profiles: [InstalledBrowserProfile] = [] + if looksLikeWebKitProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: rootURL, + isDefault: true + ) + ) + } + + var profileRoots = [rootURL.appendingPathComponent("Profiles", isDirectory: true)] + if descriptor.id == "safari" { + profileRoots.append( + homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Containers", isDirectory: true) + .appendingPathComponent("com.apple.Safari", isDirectory: true) + .appendingPathComponent("Data", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("Profiles", isDirectory: true) + ) + } + + var profileIndex = 1 + for profileRoot in dedupedCanonicalURLs(profileRoots) where fileManager.fileExists(atPath: profileRoot.path) { + let children = (try? fileManager.contentsOfDirectory( + at: profileRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + guard looksLikeWebKitProfile(rootURL: child, fileManager: fileManager) else { continue } + profiles.append( + InstalledBrowserProfile( + displayName: webKitProfileDisplayName( + directoryName: child.lastPathComponent, + fallbackIndex: profileIndex + ), + rootURL: child, + isDefault: false + ) + ) + profileIndex += 1 + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func chromiumProfileNameMap(rootURL: URL) -> [String: String] { + let localStateURL = rootURL.appendingPathComponent("Local State", isDirectory: false) + guard let data = try? Data(contentsOf: localStateURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profileSection = jsonObject["profile"] as? [String: Any], + let infoCache = profileSection["info_cache"] as? [String: Any] else { + return [:] + } + + var result: [String: String] = [:] + for (directoryName, rawProfileInfo) in infoCache { + guard let profileInfo = rawProfileInfo as? [String: Any], + let name = profileInfo["name"] as? String else { + continue + } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + result[directoryName] = trimmedName + } + } + return result + } + + private static func chromiumProfileDisplayName( + directoryName: String, + nameMap: [String: String], + isDefault: Bool + ) -> String { + if let mappedName = nameMap[directoryName], !mappedName.isEmpty { + return mappedName + } + if isDefault { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + return directoryName + } + + private static func looksLikeWebKitProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let candidatePaths = [ + "History.db", + "Cookies.binarycookies", + "Cookies.sqlite", + "WebsiteData", + "LocalStorage", + ] + + for candidatePath in candidatePaths { + let url = rootURL.appendingPathComponent(candidatePath, isDirectory: candidatePath != "History.db" && candidatePath != "Cookies.binarycookies" && candidatePath != "Cookies.sqlite") + if fileManager.fileExists(atPath: url.path) { + return true + } + } + return false + } + + private static func webKitProfileDisplayName(directoryName: String, fallbackIndex: Int) -> String { + if directoryName.caseInsensitiveCompare("Default") == .orderedSame { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + if UUID(uuidString: directoryName) != nil { + return String( + format: String( + localized: "browser.import.sourceProfile.fallback", + defaultValue: "Profile %ld" + ), + fallbackIndex + ) + } + return directoryName + } + + private static func defaultApplicationSearchDirectories(homeDirectoryURL: URL) -> [URL] { + [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true), + URL(fileURLWithPath: "/Applications/Setapp", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications/Setapp", isDirectory: true), + ] + } + + private static func dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + var seen = Set() + var result: [InstalledBrowserProfile] = [] + for profile in profiles { + if seen.insert(profile.id).inserted { + result.append(profile) + } + } + return result + } + + private static func sortProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + profiles.sorted { lhs, rhs in + if lhs.isDefault != rhs.isDefault { + return lhs.isDefault && !rhs.isDefault + } + let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return lhs.id < rhs.id + } + } +} + +struct BrowserImportOutcomeEntry: Sendable { + let sourceProfileNames: [String] + let destinationProfileName: String + let importedCookies: Int + let skippedCookies: Int + let importedHistoryEntries: Int + let warnings: [String] +} + +struct BrowserImportOutcome: Sendable { + let browserName: String + let scope: BrowserImportScope + let domainFilters: [String] + let createdDestinationProfileNames: [String] + let entries: [BrowserImportOutcomeEntry] + let warnings: [String] + + var totalImportedCookies: Int { + entries.reduce(0) { $0 + $1.importedCookies } + } + + var totalSkippedCookies: Int { + entries.reduce(0) { $0 + $1.skippedCookies } + } + + var totalImportedHistoryEntries: Int { + entries.reduce(0) { $0 + $1.importedHistoryEntries } + } +} + +struct RealizedBrowserImportExecutionEntry: Sendable { + let sourceProfiles: [InstalledBrowserProfile] + let destinationProfileID: UUID + let destinationProfileName: String +} + +struct RealizedBrowserImportExecutionPlan: Sendable { + let mode: BrowserImportDestinationMode + let entries: [RealizedBrowserImportExecutionEntry] + let createdProfiles: [BrowserProfileDefinition] +} + +enum BrowserImportPlanRealizationError: LocalizedError { + case missingDestinationProfile(UUID) + case profileCreationFailed(String) + + var errorDescription: String? { + switch self { + case .missingDestinationProfile: + return String( + localized: "browser.import.error.destinationMissing", + defaultValue: "The selected cmux browser profile no longer exists. Pick a destination profile again." + ) + case .profileCreationFailed(let name): + return String( + format: String( + localized: "browser.import.error.destinationCreateFailed", + defaultValue: "cmux could not create the destination profile \"%@\"." + ), + name + ) + } + } +} + +enum BrowserImportOutcomeFormatter { + static func lines(for outcome: BrowserImportOutcome) -> [String] { + var lines: [String] = [] + lines.append( + String( + format: String( + localized: "browser.import.complete.browser", + defaultValue: "Browser: %@" + ), + outcome.browserName + ) + ) + + if outcome.entries.count == 1, let entry = outcome.entries.first { + if !entry.sourceProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.sourceProfiles", + defaultValue: "Source profiles: %@" + ), + entry.sourceProfileNames.joined(separator: ", ") + ) + ) + } + lines.append( + String( + format: String( + localized: "browser.import.complete.destinationProfile", + defaultValue: "Destination profile: %@" + ), + entry.destinationProfileName + ) + ) + } else if !outcome.entries.isEmpty { + lines.append( + String( + localized: "browser.import.complete.profileMappings", + defaultValue: "Profile mappings:" + ) + ) + for entry in outcome.entries { + let sourceNames = entry.sourceProfileNames.joined(separator: ", ") + lines.append( + String( + format: String( + localized: "browser.import.complete.profileMapping", + defaultValue: "%@ -> %@" + ), + sourceNames, + entry.destinationProfileName + ) + ) + } + } + + lines.append( + String( + format: String( + localized: "browser.import.complete.scope", + defaultValue: "Scope: %@" + ), + outcome.scope.displayName + ) + ) + lines.append( + String( + format: String( + localized: "browser.import.complete.importedCookies", + defaultValue: "Imported cookies: %ld" + ), + outcome.totalImportedCookies + ) + ) + if outcome.totalSkippedCookies > 0 { + lines.append( + String( + format: String( + localized: "browser.import.complete.skippedCookies", + defaultValue: "Skipped cookies: %ld" + ), + outcome.totalSkippedCookies + ) + ) + } + if outcome.scope.includesHistory { + lines.append( + String( + format: String( + localized: "browser.import.complete.importedHistory", + defaultValue: "Imported history entries: %ld" + ), + outcome.totalImportedHistoryEntries + ) + ) + } + if !outcome.domainFilters.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.domainFilter", + defaultValue: "Domain filter: %@" + ), + outcome.domainFilters.joined(separator: ", ") + ) + ) + } + if !outcome.createdDestinationProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.createdProfiles", + defaultValue: "Created cmux profiles: %@" + ), + outcome.createdDestinationProfileNames.joined(separator: ", ") + ) + ) + } + if !outcome.warnings.isEmpty { + lines.append("") + lines.append( + String( + localized: "browser.import.complete.warnings", + defaultValue: "Warnings:" + ) + ) + for warning in outcome.warnings { + lines.append("- \(warning)") + } + } + + return lines + } +} + +enum BrowserImportDestinationMode: Equatable, Sendable { + case singleDestination + case separateProfiles + case mergeIntoOne +} + +enum BrowserImportDestinationRequest: Equatable, Sendable { + case existing(UUID) + case createNamed(String) +} + +struct BrowserImportExecutionEntry: Equatable, Sendable { + var sourceProfiles: [InstalledBrowserProfile] + var destination: BrowserImportDestinationRequest +} + +struct BrowserImportExecutionPlan: Equatable, Sendable { + var mode: BrowserImportDestinationMode + var entries: [BrowserImportExecutionEntry] +} + +struct BrowserImportStep3Presentation: Equatable { + let showsModeSelector: Bool + let showsSeparateRows: Bool + let showsSingleDestinationPicker: Bool + + init(plan: BrowserImportExecutionPlan) { + showsModeSelector = plan.entries.count > 1 || plan.entries.contains { $0.sourceProfiles.count > 1 } + showsSeparateRows = plan.mode == .separateProfiles + showsSingleDestinationPicker = plan.mode != .separateProfiles + } +} + +enum BrowserImportPlanResolver { + @MainActor + static func defaultPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition], + preferredSingleDestinationProfileID: UUID + ) -> BrowserImportExecutionPlan { + let resolvedSourceProfiles = selectedSourceProfiles.isEmpty ? [] : selectedSourceProfiles + + guard resolvedSourceProfiles.count > 1 else { + let destinationRequest: BrowserImportDestinationRequest + if let sourceProfile = resolvedSourceProfiles.first, + let matchingProfile = matchingDestinationProfile( + for: sourceProfile.displayName, + destinationProfiles: destinationProfiles + ) { + destinationRequest = .existing(matchingProfile.id) + } else { + destinationRequest = .existing(preferredSingleDestinationProfileID) + } + + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: resolvedSourceProfiles.map { + BrowserImportExecutionEntry( + sourceProfiles: [$0], + destination: destinationRequest + ) + } + ) + } + + return separateProfilesPlan( + selectedSourceProfiles: resolvedSourceProfiles, + destinationProfiles: destinationProfiles + ) + } + + static func separateProfilesPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserImportExecutionPlan { + var reservedNames = Set(destinationProfiles.map { normalizedProfileName($0.displayName) }) + + return BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: selectedSourceProfiles.map { profile in + if let matchingProfile = matchingDestinationProfile( + for: profile.displayName, + destinationProfiles: destinationProfiles + ) { + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .existing(matchingProfile.id) + ) + } + + let createName = nextCreateName( + baseName: profile.displayName, + takenNames: reservedNames + ) + reservedNames.insert(normalizedProfileName(createName)) + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .createNamed(createName) + ) + } + ) + } + + private static func matchingDestinationProfile( + for sourceProfileName: String, + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserProfileDefinition? { + let normalizedSourceName = normalizedProfileName(sourceProfileName) + guard !normalizedSourceName.isEmpty else { return nil } + return destinationProfiles.first { + normalizedProfileName($0.displayName) == normalizedSourceName + } + } + + private static func nextCreateName( + baseName: String, + takenNames: Set + ) -> String { + let trimmedBaseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBaseName = trimmedBaseName.isEmpty ? "Profile" : trimmedBaseName + if !takenNames.contains(normalizedProfileName(resolvedBaseName)) { + return resolvedBaseName + } + + var suffix = 2 + while true { + let candidate = "\(resolvedBaseName) (\(suffix))" + if !takenNames.contains(normalizedProfileName(candidate)) { + return candidate + } + suffix += 1 + } + } + + private static func normalizedProfileName(_ rawName: String) -> String { + rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + @MainActor + static func realize( + plan: BrowserImportExecutionPlan, + profileStore: BrowserProfileStore = .shared + ) throws -> RealizedBrowserImportExecutionPlan { + var realizedEntries: [RealizedBrowserImportExecutionEntry] = [] + var createdProfiles: [BrowserProfileDefinition] = [] + + for entry in plan.entries { + let destinationProfile: BrowserProfileDefinition + switch entry.destination { + case .existing(let id): + guard let existingProfile = profileStore.profileDefinition(id: id) else { + throw BrowserImportPlanRealizationError.missingDestinationProfile(id) + } + destinationProfile = existingProfile + case .createNamed(let name): + if let existingProfile = matchingDestinationProfile( + for: name, + destinationProfiles: profileStore.profiles + ) { + destinationProfile = existingProfile + } else if let createdProfile = profileStore.createProfile(named: name) { + createdProfiles.append(createdProfile) + destinationProfile = createdProfile + } else { + throw BrowserImportPlanRealizationError.profileCreationFailed(name) + } + } + + realizedEntries.append( + RealizedBrowserImportExecutionEntry( + sourceProfiles: entry.sourceProfiles, + destinationProfileID: destinationProfile.id, + destinationProfileName: destinationProfile.displayName + ) + ) + } + + return RealizedBrowserImportExecutionPlan( + mode: plan.mode, + entries: realizedEntries, + createdProfiles: createdProfiles + ) + } +} + +#if canImport(CommonCrypto) && canImport(Security) +private struct ChromiumCookieKeychainItem: Hashable { + let service: String + let account: String +} + +private final class ChromiumCookieDecryptor { + private enum KeychainLookupResult { + case success(Data) + case failure(OSStatus) + } + + enum FailureReason { + case keychain(OSStatus) + case itemNotFound + case unreadableSecret + case decrypt + case unsupportedFormat + } + + private let browser: InstalledBrowserCandidate + private var cachedKeychainItem: ChromiumCookieKeychainItem? + private var cachedPasswordData: Data? + private var attemptedLookup = false + private(set) var lastFailureReason: FailureReason? + + init(browser: InstalledBrowserCandidate) { + self.browser = browser + } + + var resolvedKeychainItemName: String? { + cachedKeychainItem?.service + } + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { + guard let versionPrefix = chromiumVersionPrefix(in: encryptedValue) else { + lastFailureReason = .unsupportedFormat + return nil + } + + guard let passwordData = passwordData() else { + return nil + } + + let ciphertext = encryptedValue.dropFirst(versionPrefix.count) + guard let key = deriveKey(from: passwordData), + let plaintext = decrypt(ciphertext: Data(ciphertext), key: key), + let cookieValue = decodePlaintext(plaintext, host: host) else { + lastFailureReason = .decrypt + return nil + } + + lastFailureReason = nil + return cookieValue + } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0, let failure = lastFailureReason else { return nil } + switch failure { + case .keychain, .itemNotFound, .unreadableSecret: + let itemName = resolvedKeychainItemName ?? suggestedKeychainItems().first?.service ?? "\(browserName) Storage Key" + return String( + format: String( + localized: "browser.import.warning.keychainDecryptFailed", + defaultValue: "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + ), + skippedCount, + browserName, + itemName + ) + case .decrypt, .unsupportedFormat: + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } + } + + private func passwordData() -> Data? { + if let cachedPasswordData { + return cachedPasswordData + } + guard !attemptedLookup else { + return nil + } + attemptedLookup = true + + for item in suggestedKeychainItems() { + switch readPasswordData(item: item) { + case .success(let passwordData): + guard !passwordData.isEmpty else { + cachedKeychainItem = item + lastFailureReason = .unreadableSecret + return nil + } + cachedKeychainItem = item + cachedPasswordData = passwordData + lastFailureReason = nil + return passwordData + case .failure(let status): + if status == errSecItemNotFound { + continue + } + cachedKeychainItem = item + lastFailureReason = .keychain(status) + return nil + } + } + + lastFailureReason = .itemNotFound + return nil + } + + private func suggestedKeychainItems() -> [ChromiumCookieKeychainItem] { + var result: [ChromiumCookieKeychainItem] = [] + var seen = Set() + + func append(service: String, account: String) { + let trimmedService = service.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedService.isEmpty, !trimmedAccount.isEmpty else { return } + let item = ChromiumCookieKeychainItem(service: trimmedService, account: trimmedAccount) + if seen.insert(item).inserted { + result.append(item) + } + } + + for baseName in keychainBaseNames() { + append(service: "\(baseName) Storage Key", account: baseName) + append(service: "\(baseName) Safe Storage", account: baseName) + } + + for baseName in keychainBaseNames() { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: baseName, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitAll, + ] + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { continue } + let attributesList = rawResult as? [[String: Any]] ?? [] + for attributes in attributesList { + guard let service = attributes[kSecAttrService as String] as? String else { continue } + guard service.contains("Storage Key") || service.contains("Safe Storage") else { continue } + append(service: service, account: baseName) + } + } + + return result + } + + private func keychainBaseNames() -> [String] { + var result: [String] = [] + var seen = Set() + + func append(_ rawName: String?) { + guard let rawName else { return } + let trimmedName = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + if seen.insert(trimmedName).inserted { + result.append(trimmedName) + } + } + + append(browser.displayName) + append(browser.appURL?.deletingPathExtension().lastPathComponent) + append(browser.descriptor.appNames.first?.replacingOccurrences(of: ".app", with: "")) + + if let appURL = browser.appURL, + let bundle = Bundle(url: appURL) { + append(bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + append(bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + } + + for name in Array(result) { + if name.hasPrefix("Google ") { + append(String(name.dropFirst("Google ".count))) + } + if name.hasSuffix(" Browser") { + append(String(name.dropLast(" Browser".count))) + } + } + + switch browser.descriptor.id { + case "google-chrome": + append("Chrome") + case "chromium": + append("Chromium") + case "brave": + append("Brave") + case "helium": + append("Helium") + default: + break + } + + return result + } + + private func readPasswordData(item: ChromiumCookieKeychainItem) -> KeychainLookupResult { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: item.service, + kSecAttrAccount: item.account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { + return .failure(status) + } + guard let passwordData = rawResult as? Data else { + return .failure(errSecDecode) + } + return .success(passwordData) + } + + private func chromiumVersionPrefix(in encryptedValue: Data) -> Data? { + for prefix in [Data("v10".utf8), Data("v11".utf8)] where encryptedValue.starts(with: prefix) { + return prefix + } + return nil + } + + private func deriveKey(from passwordData: Data) -> Data? { + let salt = Data("saltysalt".utf8) + var derivedKey = Data(count: kCCKeySizeAES128) + + let status = derivedKey.withUnsafeMutableBytes { derivedBytes in + passwordData.withUnsafeBytes { passwordBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self), + passwordData.count, + saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + derivedBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + kCCKeySizeAES128 + ) + } + } + } + + guard status == kCCSuccess else { return nil } + return derivedKey + } + + private func decrypt(ciphertext: Data, key: Data) -> Data? { + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var plaintext = Data(count: ciphertext.count + kCCBlockSizeAES128) + var plaintextLength = 0 + let plaintextCapacity = plaintext.count + + let status = plaintext.withUnsafeMutableBytes { plaintextBytes in + ciphertext.withUnsafeBytes { ciphertextBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + ciphertextBytes.baseAddress, + ciphertext.count, + plaintextBytes.baseAddress, + plaintextCapacity, + &plaintextLength + ) + } + } + } + } + + guard status == kCCSuccess else { return nil } + plaintext.removeSubrange(plaintextLength...) + return plaintext + } + + private func decodePlaintext(_ plaintext: Data, host: String) -> String? { + if let value = String(data: plaintext, encoding: .utf8) { + return value + } + + let hostDigest = Data(SHA256.hash(data: Data(host.utf8))) + if plaintext.starts(with: hostDigest) { + return String(data: plaintext.dropFirst(hostDigest.count), encoding: .utf8) + } + + return nil + } +} +#else +private final class ChromiumCookieDecryptor { + init(browser: InstalledBrowserCandidate) {} + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { nil } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0 else { return nil } + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } +} +#endif + +enum BrowserDataImporter { + private struct CookieImportResult { + var importedCount: Int = 0 + var skippedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryImportResult { + var importedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryRow { + let url: String + let title: String? + let visitCount: Int + let lastVisited: Date + } + + static func parseDomainFilters(_ raw: String) -> [String] { + var result: [String] = [] + var seen = Set() + let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")) + for token in raw.components(separatedBy: separators) { + var value = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value.hasPrefix("*.") { + value.removeFirst(2) + } + while value.hasPrefix(".") { + value.removeFirst() + } + guard !value.isEmpty else { continue } + guard seen.insert(value).inserted else { continue } + result.append(value) + } + return result + } + + static func importData( + from browser: InstalledBrowserCandidate, + plan: RealizedBrowserImportExecutionPlan, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcome { + var outcomeEntries: [BrowserImportOutcomeEntry] = [] + var warnings: [String] = [] + var seenWarnings = Set() + + for entry in plan.entries { + let outcomeEntry = await importEntry( + from: browser, + sourceProfiles: entry.sourceProfiles, + destinationProfileID: entry.destinationProfileID, + destinationProfileName: entry.destinationProfileName, + scope: scope, + domainFilters: domainFilters + ) + outcomeEntries.append(outcomeEntry) + for warning in outcomeEntry.warnings where seenWarnings.insert(warning).inserted { + warnings.append(warning) + } + } + + if scope == .everything { + let unavailableWarning = String( + localized: "browser.import.warning.additionalDataUnavailable", + defaultValue: "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + ) + if seenWarnings.insert(unavailableWarning).inserted { + warnings.append(unavailableWarning) + } + } + + return BrowserImportOutcome( + browserName: browser.displayName, + scope: scope, + domainFilters: domainFilters, + createdDestinationProfileNames: plan.createdProfiles.map(\.displayName), + entries: outcomeEntries, + warnings: warnings + ) + } + + private static func importEntry( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + destinationProfileName: String, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcomeEntry { + let resolvedSourceProfiles = sourceProfiles.isEmpty ? browser.profiles : sourceProfiles + var cookieResult = CookieImportResult() + if scope.includesCookies { + cookieResult = await importCookies( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var historyResult = HistoryImportResult() + if scope.includesHistory { + historyResult = await importHistory( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var warnings = cookieResult.warnings + warnings.append(contentsOf: historyResult.warnings) + return BrowserImportOutcomeEntry( + sourceProfileNames: resolvedSourceProfiles.map(\.displayName), + destinationProfileName: destinationProfileName, + importedCookies: cookieResult.importedCount, + skippedCookies: cookieResult.skippedCount, + importedHistoryEntries: historyResult.importedCount, + warnings: warnings + ) + } + + private static func importCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + switch browser.family { + case .firefox: + return await importFirefoxCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .chromium: + return await importChromiumCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .webkit: + if browser.descriptor.id == "safari" { + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + String( + localized: "browser.import.warning.safariCookiesUnsupported", + defaultValue: "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + ) + ] + ) + } + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + String( + format: String( + localized: "browser.import.warning.cookieImportUnsupported", + defaultValue: "%@ cookie import is not implemented yet." + ), + browser.displayName + ) + ] + ) + } + } + + private static func importHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + switch browser.family { + case .firefox: + return await importFirefoxHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .chromium: + return await importChromiumHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .webkit: + return await importWebKitHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + } + + private static func importFirefoxCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host, name, value, path, expiry, isSecure FROM moz_cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiry = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: value, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if expiry > 0 { + properties[.expires] = Date(timeIntervalSince1970: TimeInterval(expiry)) + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxCookiesReadFailed", + defaultValue: "Failed reading Firefox cookies at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) + return CookieImportResult(importedCount: importedCount, skippedCount: max(0, dedupedCookies.count - importedCount), warnings: warnings) + } + + private static func importChromiumCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + var skippedEncryptedCookies = 0 + let decryptor = ChromiumCookieDecryptor(browser: browser) + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("Cookies", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host_key, name, value, path, expires_utc, is_secure, encrypted_value FROM cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiresUTC = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + let encryptedValue = sqliteColumnData(statement, index: 6) + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + var usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if usableValue.isEmpty && !encryptedValue.isEmpty { + if let decryptedValue = decryptor.decryptCookieValue( + encryptedValue: encryptedValue, + host: host + ) { + usableValue = decryptedValue + } else { + skippedEncryptedCookies += 1 + return + } + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: usableValue, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if let expiresDate = chromiumDate(fromWebKitMicroseconds: expiresUTC) { + properties[.expires] = expiresDate + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserCookiesReadFailed", + defaultValue: "Failed reading %@ cookies at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) + if let warning = decryptor.warningMessage( + browserName: browser.displayName, + skippedCount: skippedEncryptedCookies + ) { + warnings.append(warning) + } + let skippedCount = max(0, dedupedCookies.count - importedCount) + skippedEncryptedCookies + return CookieImportResult(importedCount: importedCount, skippedCount: skippedCount, warnings: warnings) + } + + private static func importFirefoxHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("places.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_date + FROM moz_places + WHERE url LIKE 'http%' + ORDER BY last_visit_date DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? .distantPast + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxHistoryReadFailed", + defaultValue: "Failed reading Firefox history at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importChromiumHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_time + FROM urls + WHERE url LIKE 'http%' + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? .distantPast + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importWebKitHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + var candidateDatabaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History.db", isDirectory: false) + } + if browser.descriptor.id == "safari" { + candidateDatabaseURLs.append( + browser.homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("History.db", isDirectory: false) + ) + } + let uniqueURLs = dedupedCanonicalURLs(candidateDatabaseURLs).filter { fileManager.fileExists(atPath: $0.path) } + + if uniqueURLs.isEmpty { + return HistoryImportResult( + importedCount: 0, + warnings: [ + String( + format: String( + localized: "browser.import.warning.noHistoryDatabase", + defaultValue: "No history database found for %@." + ), + browser.displayName + ) + ] + ) + } + + for databaseURL in uniqueURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT history_items.url, + history_items.title, + COUNT(history_visits.id) AS visit_count, + MAX(history_visits.visit_time) AS last_visit_time + FROM history_items + JOIN history_visits + ON history_items.id = history_visits.history_item + GROUP BY history_items.url + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitReferenceSeconds = sqliteColumnDouble(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = Date(timeIntervalSinceReferenceDate: lastVisitReferenceSeconds) + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func mergeHistoryRows(_ rows: [HistoryRow], destinationProfileID: UUID) async -> Int { + guard !rows.isEmpty else { return 0 } + return await MainActor.run { + let entries = rows.compactMap { row -> BrowserHistoryStore.Entry? in + guard let parsedURL = URL(string: row.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + let trimmedTitle = row.title?.trimmingCharacters(in: .whitespacesAndNewlines) + return BrowserHistoryStore.Entry( + id: UUID(), + url: parsedURL.absoluteString, + title: trimmedTitle, + lastVisited: row.lastVisited, + visitCount: max(1, row.visitCount) + ) + } + let historyStore = BrowserProfileStore.shared.historyStore(for: destinationProfileID) + return historyStore.mergeImportedEntries(entries) + } + } + + private static func setCookiesInStore(_ cookies: [HTTPCookie], destinationProfileID: UUID) async -> Int { + guard !cookies.isEmpty else { return 0 } + let store = await MainActor.run { + BrowserProfileStore.shared.websiteDataStore(for: destinationProfileID).httpCookieStore + } + var importedCount = 0 + for cookie in cookies { + await setCookie(cookie, in: store) + importedCount += 1 + } + return importedCount + } + + @MainActor + private static func setCookie(_ cookie: HTTPCookie, in store: WKHTTPCookieStore) async { + await withCheckedContinuation { continuation in + store.setCookie(cookie) { + continuation.resume() + } + } + } + + private static func dedupeCookies(_ cookies: [HTTPCookie]) -> [HTTPCookie] { + var dedupedByKey: [String: HTTPCookie] = [:] + for cookie in cookies { + let key = "\(cookie.name.lowercased())|\(cookie.domain.lowercased())|\(cookie.path)" + if let existing = dedupedByKey[key] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + dedupedByKey[key] = cookie + } + } else { + dedupedByKey[key] = cookie + } + } + return Array(dedupedByKey.values) + } + + private static func domainMatches(host: String, filters: [String]) -> Bool { + if filters.isEmpty { return true } + var normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while normalizedHost.hasPrefix(".") { + normalizedHost.removeFirst() + } + guard !normalizedHost.isEmpty else { return false } + for filter in filters { + if normalizedHost == filter { return true } + if normalizedHost.hasSuffix(".\(filter)") { return true } + } + return false + } + + private static func chromiumDate(fromWebKitMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let unixSeconds = (Double(rawValue) / 1_000_000.0) - 11_644_473_600.0 + guard unixSeconds.isFinite else { return nil } + return Date(timeIntervalSince1970: unixSeconds) + } + + private static func firefoxDate(fromUnixMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let seconds = Double(rawValue) / 1_000_000.0 + guard seconds.isFinite else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func querySQLiteRows( + sourceDatabaseURL: URL, + sql: String, + rowHandler: (OpaquePointer) throws -> Void + ) throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-browser-import-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + let snapshotURL = tempRoot.appendingPathComponent(sourceDatabaseURL.lastPathComponent, isDirectory: false) + try fileManager.copyItem(at: sourceDatabaseURL, to: snapshotURL) + + let walSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-wal") + let walSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-wal") + if fileManager.fileExists(atPath: walSourceURL.path) { + try? fileManager.copyItem(at: walSourceURL, to: walSnapshotURL) + } + let shmSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-shm") + let shmSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-shm") + if fileManager.fileExists(atPath: shmSourceURL.path) { + try? fileManager.copyItem(at: shmSourceURL, to: shmSnapshotURL) + } + + var database: OpaquePointer? + let openCode = sqlite3_open_v2(snapshotURL.path, &database, SQLITE_OPEN_READONLY, nil) + guard openCode == SQLITE_OK, let database else { + let message = sqliteMessage(from: database) ?? "unknown SQLite open failure" + sqlite3_close(database) + throw NSError(domain: "BrowserDataImporter", code: Int(openCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_close(database) } + + var statement: OpaquePointer? + let prepareCode = sqlite3_prepare_v2(database, sql, -1, &statement, nil) + guard prepareCode == SQLITE_OK, let statement else { + let message = sqliteMessage(from: database) ?? "unknown SQLite prepare failure" + sqlite3_finalize(statement) + throw NSError(domain: "BrowserDataImporter", code: Int(prepareCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_finalize(statement) } + + while true { + let stepCode = sqlite3_step(statement) + if stepCode == SQLITE_ROW { + try rowHandler(statement) + continue + } + if stepCode == SQLITE_DONE { + break + } + let message = sqliteMessage(from: database) ?? "unknown SQLite step failure" + throw NSError(domain: "BrowserDataImporter", code: Int(stepCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + + private static func sqliteMessage(from database: OpaquePointer?) -> String? { + guard let database, let cString = sqlite3_errmsg(database) else { return nil } + return String(cString: cString) + } + + private static func sqliteColumnText(_ statement: OpaquePointer, index: Int32) -> String? { + guard let cValue = sqlite3_column_text(statement, index) else { return nil } + return String(cString: cValue) + } + + private static func sqliteColumnInt64(_ statement: OpaquePointer, index: Int32) -> Int64 { + sqlite3_column_int64(statement, index) + } + + private static func sqliteColumnDouble(_ statement: OpaquePointer, index: Int32) -> Double { + sqlite3_column_double(statement, index) + } + + private static func sqliteColumnBytes(_ statement: OpaquePointer, index: Int32) -> Int { + Int(sqlite3_column_bytes(statement, index)) + } + + private static func sqliteColumnData(_ statement: OpaquePointer, index: Int32) -> Data { + let length = Int(sqlite3_column_bytes(statement, index)) + guard length > 0, let pointer = sqlite3_column_blob(statement, index) else { + return Data() + } + return Data(bytes: pointer, count: length) + } +} + +#if DEBUG +enum BrowserImportUITestFixtureLoader { + private struct BrowserFixture: Decodable { + let browserName: String + let profiles: [String] + } + + static func browsers(from environment: [String: String]) -> [InstalledBrowserCandidate]? { + guard let rawFixture = environment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"], + let data = rawFixture.data(using: .utf8), + let fixture = try? JSONDecoder().decode(BrowserFixture.self, from: data) else { + return nil + } + + let resolvedProfiles = fixture.profiles.enumerated().map { index, name in + InstalledBrowserProfile( + displayName: name, + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-browser-import") + .appendingPathComponent( + fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + ) + .appendingPathComponent("\(index)-\(name)") + .standardizedFileURL, + isDefault: index == 0 + ) + } + + let descriptor = InstalledBrowserDetector.allBrowserDescriptors.first(where: { + $0.displayName == fixture.browserName + }) ?? BrowserImportBrowserDescriptor( + id: fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")), + displayName: fixture.browserName, + family: .chromium, + tier: 0, + bundleIdentifiers: [], + appNames: [], + dataRootRelativePaths: [], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ) + + return [ + InstalledBrowserCandidate( + descriptor: descriptor, + resolvedFamily: descriptor.family, + homeDirectoryURL: FileManager.default.homeDirectoryForCurrentUser, + appURL: nil, + dataRootURL: nil, + profiles: resolvedProfiles, + detectionSignals: ["ui-test-fixture"], + detectionScore: Int.max + ) + ] + } + + static func destinationProfiles(from environment: [String: String]) -> [BrowserProfileDefinition]? { + guard let rawDestinations = environment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"], + let data = rawDestinations.data(using: .utf8), + let names = try? JSONDecoder().decode([String].self, from: data), + !names.isEmpty else { + return nil + } + + return names.enumerated().map { index, rawName in + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + if name.localizedCaseInsensitiveCompare("Default") == .orderedSame { + return BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + } + return BrowserProfileDefinition( + id: UUID(), + displayName: name.isEmpty ? "Profile \(index + 1)" : name, + createdAt: .distantPast, + isBuiltInDefault: false + ) + } + } +} +#endif + +@MainActor +final class BrowserDataImportCoordinator { + static let shared = BrowserDataImportCoordinator() + + private var importInProgress = false + + private init() {} + + func presentImportDialog(defaultDestinationProfileID: UUID? = nil) { + presentImportDialog(prefilledBrowsers: nil, defaultDestinationProfileID: defaultDestinationProfileID) + } + + private struct ImportSelection { + let browser: InstalledBrowserCandidate + let executionPlan: BrowserImportExecutionPlan + let scope: BrowserImportScope + let domainFilters: [String] + } + + private func presentImportDialog( + prefilledBrowsers: [InstalledBrowserCandidate]?, + defaultDestinationProfileID: UUID? + ) { + guard !importInProgress else { return } +#if DEBUG + let environment = ProcessInfo.processInfo.environment + let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) + let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) + let browsers = prefilledBrowsers ?? fixtureBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#else + let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil + let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#endif + guard !browsers.isEmpty else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.noBrowsers.title", + defaultValue: "No importable browsers found" + ) + alert.informativeText = String( + localized: "browser.import.noBrowsers.message", + defaultValue: "cmux could not find browser profiles to import from on this Mac." + ) + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + + guard let selection = promptForSelection( + browsers: browsers, + destinationProfiles: fixtureDestinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) else { return } + +#if DEBUG + if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { + return + } +#endif + let realizedPlan: RealizedBrowserImportExecutionPlan + do { + realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) + } catch { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.error.title", + defaultValue: "Import could not start" + ) + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + importInProgress = true + + let progressWindow = showProgressWindow( + title: String( + localized: "browser.import.progress.title", + defaultValue: "Importing Browser Data" + ), + message: String( + format: String( + localized: "browser.import.progress.message", + defaultValue: "Importing %@ from %@…" + ), + selection.scope.displayName.lowercased(), + selection.browser.displayName + ) + ) + + Task.detached(priority: .userInitiated) { + let outcome = await BrowserDataImporter.importData( + from: selection.browser, + plan: realizedPlan, + scope: selection.scope, + domainFilters: selection.domainFilters + ) + + await MainActor.run { + self.hideProgressWindow(progressWindow) + self.presentOutcome(outcome) + self.importInProgress = false + } + } + } + + private func promptForSelection( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) -> ImportSelection? { + guard !browsers.isEmpty else { return nil } + let wizard = ImportWizardWindowController( + browsers: browsers, + destinationProfiles: destinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) + return wizard.runModal() + } + +#if DEBUG + private struct CapturedImportSelection: Encodable { + struct Entry: Encodable { + let sourceProfiles: [String] + let destinationKind: String + let destinationName: String + } + + let browserName: String + let mode: String + let scope: String + let domainFilters: [String] + let entries: [Entry] + } + + private func captureSelectionIfRequested( + _ selection: ImportSelection, + destinationProfiles: [BrowserProfileDefinition]? + ) -> Bool { + let environment = ProcessInfo.processInfo.environment + guard environment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } + guard let path = environment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { + return true + } + + let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let payload = CapturedImportSelection( + browserName: selection.browser.displayName, + mode: captureModeName(selection.executionPlan.mode), + scope: selection.scope.rawValue, + domainFilters: selection.domainFilters, + entries: selection.executionPlan.entries.map { entry in + let destinationKind: String + let destinationName: String + switch entry.destination { + case .existing(let id): + destinationKind = "existing" + destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + destinationKind = "create" + destinationName = name + } + return CapturedImportSelection.Entry( + sourceProfiles: entry.sourceProfiles.map(\.displayName), + destinationKind: destinationKind, + destinationName: destinationName + ) + } + ) + + guard let data = try? JSONEncoder().encode(payload) else { return true } + let url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try? data.write(to: url) + return true + } + + private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { + switch mode { + case .singleDestination: + return "singleDestination" + case .separateProfiles: + return "separateProfiles" + case .mergeIntoOne: + return "mergeIntoOne" + } + } +#endif + + @MainActor + private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { + private final class FlippedDocumentView: NSView { + override var isFlipped: Bool { true } + } + + private enum Step { + case source + case sourceProfiles + case dataTypes + } + + private let browsers: [InstalledBrowserCandidate] + private let destinationProfiles: [BrowserProfileDefinition] + private let initialDestinationProfileID: UUID + + private var step: Step = .source + private var didFinishModal = false + private(set) var selection: ImportSelection? + private var selectedSourceProfileIDsByBrowserID: [String: Set] = [:] + private var sourceProfileCheckboxes: [NSButton] = [] + private var destinationMode: BrowserImportDestinationMode = .singleDestination + private var separateExecutionEntries: [BrowserImportExecutionEntry] = [] + private var separateDestinationOptionsByEntryIndex: [Int: [BrowserImportDestinationRequest]] = [:] + private var mergeDestinationProfileID: UUID + + private let panel: NSPanel + + private let stepLabel = NSTextField(labelWithString: "") + private let sourcePopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let sourceContainer = NSStackView() + private let sourceProfilesContainer = NSStackView() + private let sourceProfilesList = NSStackView() + private let sourceProfilesDocumentView = FlippedDocumentView(frame: .zero) + private let sourceProfilesEmptyLabel = NSTextField(wrappingLabelWithString: "") + private let sourceProfilesHelpLabel = NSTextField(labelWithString: "") + private let sourceProfilesScrollView = NSScrollView() + private let dataTypesContainer = NSStackView() + private let validationLabel = NSTextField(labelWithString: "") + private let destinationModeContainer = NSStackView() + private let separateProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let mergeProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let separateDestinationRows = NSStackView() + private let mergeDestinationRow = NSStackView() + private let mergeDestinationPopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let destinationHelpLabel = NSTextField(wrappingLabelWithString: "") + + private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let additionalDataCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let domainField = NSTextField(frame: .zero) + + private let backButton = NSButton(title: "", target: nil, action: nil) + private let cancelButton = NSButton(title: "", target: nil, action: nil) + private let primaryButton = NSButton(title: "", target: nil, action: nil) + + init( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) { + let resolvedDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let fallbackDestinationProfileID = resolvedDestinationProfiles.first?.id + ?? BrowserProfileStore.shared.effectiveLastUsedProfileID + self.browsers = browsers + self.destinationProfiles = resolvedDestinationProfiles + self.initialDestinationProfileID = defaultDestinationProfileID + .flatMap { candidateID in resolvedDestinationProfiles.first(where: { $0.id == candidateID })?.id } + ?? fallbackDestinationProfileID + self.mergeDestinationProfileID = self.initialDestinationProfileID + self.panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + super.init() + setupUI() + configureInitialState() + } + + func runModal() -> ImportSelection? { + panel.center() + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + let response = NSApp.runModal(for: panel) + if panel.isVisible { + panel.orderOut(nil) + } + + guard response == .OK else { return nil } + return selection + } + + func windowWillClose(_ notification: Notification) { + finishModal(with: .cancel) + } + + @objc + private func handleBack() { + switch step { + case .source: + return + case .sourceProfiles: + step = .source + case .dataTypes: + step = .sourceProfiles + } + validationLabel.isHidden = true + updateStepUI() + } + + @objc + private func handleCancel() { + finishModal(with: .cancel) + } + + @objc + private func handlePrimary() { + switch step { + case .source: + step = .sourceProfiles + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + case .sourceProfiles: + let selectedSourceProfiles = selectedSourceProfiles() + guard !selectedSourceProfiles.isEmpty else { + validationLabel.stringValue = String( + localized: "browser.import.validation.sourceProfiles", + defaultValue: "Choose at least one source profile to import." + ) + validationLabel.isHidden = false + return + } + + resetStep3State() + step = .dataTypes + validationLabel.isHidden = true + updateStepUI() + case .dataTypes: + let includeCookies = cookiesCheckbox.state == .on + let includeHistory = historyCheckbox.state == .on + let includeAdditionalData = additionalDataCheckbox.state == .on + guard let scope = BrowserImportScope.fromSelection( + includeCookies: includeCookies, + includeHistory: includeHistory, + includeAdditionalData: includeAdditionalData + ) else { + validationLabel.stringValue = String( + localized: "browser.import.validation.scope", + defaultValue: "Select Cookies, History, or both before starting import." + ) + validationLabel.isHidden = false + return + } + + let selectedBrowser = selectedBrowser() + let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + selection = ImportSelection( + browser: selectedBrowser, + executionPlan: currentExecutionPlan(), + scope: scope, + domainFilters: domainFilters + ) + finishModal(with: .OK) + } + } + + @objc + private func handleSourceChanged() { + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + } + + @objc + private func handleSourceProfileToggled(_ sender: NSButton) { + guard let profileID = sender.identifier?.rawValue else { return } + let browserID = selectedBrowser().id + var selectedIDs = storedSelectedSourceProfileIDs(for: selectedBrowser()) + if sender.state == .on { + selectedIDs.insert(profileID) + } else { + selectedIDs.remove(profileID) + } + selectedSourceProfileIDsByBrowserID[browserID] = selectedIDs + validationLabel.isHidden = true + } + + @objc + private func handleDestinationModeChanged(_ sender: NSButton) { + let selectedSourceProfiles = selectedSourceProfiles() + guard selectedSourceProfiles.count > 1 else { return } + destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne + rebuildStep3DestinationUI() + } + + @objc + private func handleMergeDestinationChanged(_ sender: NSPopUpButton) { + let selectedIndex = max(0, min(sender.indexOfSelectedItem, destinationProfiles.count - 1)) + guard destinationProfiles.indices.contains(selectedIndex) else { return } + mergeDestinationProfileID = destinationProfiles[selectedIndex].id + validationLabel.isHidden = true + } + + @objc + private func handleSeparateDestinationChanged(_ sender: NSPopUpButton) { + let entryIndex = sender.tag + guard separateExecutionEntries.indices.contains(entryIndex), + let options = separateDestinationOptionsByEntryIndex[entryIndex], + options.indices.contains(sender.indexOfSelectedItem) else { + return + } + separateExecutionEntries[entryIndex].destination = options[sender.indexOfSelectedItem] + validationLabel.isHidden = true + } + + private func setupUI() { + panel.title = String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) + panel.isReleasedWhenClosed = false + panel.delegate = self + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 620, height: 420)) + contentView.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = contentView + + let titleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) + ) + titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold) + + stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium) + stepLabel.textColor = .secondaryLabelColor + + setupSourceContainer() + setupSourceProfilesContainer() + setupDataTypesContainer() + + validationLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + validationLabel.textColor = .systemRed + validationLabel.isHidden = true + validationLabel.lineBreakMode = .byWordWrapping + validationLabel.maximumNumberOfLines = 3 + + backButton.target = self + backButton.action = #selector(handleBack) + backButton.bezelStyle = .rounded + backButton.title = String(localized: "browser.import.back", defaultValue: "Back") + + cancelButton.target = self + cancelButton.action = #selector(handleCancel) + cancelButton.bezelStyle = .rounded + cancelButton.title = String(localized: "common.cancel", defaultValue: "Cancel") + cancelButton.keyEquivalent = "\u{1b}" + + primaryButton.target = self + primaryButton.action = #selector(handlePrimary) + primaryButton.bezelStyle = .rounded + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + primaryButton.keyEquivalent = "\r" + + let buttonSpacer = NSView(frame: .zero) + + let buttonRow = NSStackView(views: [buttonSpacer, backButton, cancelButton, primaryButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + buttonRow.alignment = .centerY + buttonRow.translatesAutoresizingMaskIntoConstraints = false + buttonSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + buttonSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let contentStack = NSStackView(views: [ + titleLabel, + stepLabel, + sourceContainer, + sourceProfilesContainer, + dataTypesContainer, + validationLabel, + ]) + contentStack.orientation = .vertical + contentStack.spacing = 10 + contentStack.alignment = .leading + contentStack.translatesAutoresizingMaskIntoConstraints = false + + guard let panelContent = panel.contentView else { return } + panelContent.addSubview(contentStack) + panelContent.addSubview(buttonRow) + + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 18), + contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + + buttonRow.topAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 14), + buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -16), + ]) + } + + private func setupSourceContainer() { + for browser in browsers { + sourcePopup.addItem(withTitle: browser.displayName) + } + sourcePopup.selectItem(at: 0) + sourcePopup.target = self + sourcePopup.action = #selector(handleSourceChanged) + + let sourceLabel = NSTextField( + labelWithString: String(localized: "browser.import.source", defaultValue: "Source") + ) + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 80 + + let sourceRow = NSStackView(views: [sourceLabel, sourcePopup]) + sourceRow.orientation = .horizontal + sourceRow.spacing = 8 + sourceRow.alignment = .centerY + + let detectedLabel = NSTextField( + wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers) + ) + detectedLabel.font = NSFont.systemFont(ofSize: 12) + detectedLabel.textColor = .secondaryLabelColor + detectedLabel.maximumNumberOfLines = 2 + detectedLabel.preferredMaxLayoutWidth = 500 + + sourceContainer.orientation = .vertical + sourceContainer.spacing = 10 + sourceContainer.alignment = .leading + sourceContainer.addArrangedSubview(sourceRow) + sourceContainer.addArrangedSubview(detectedLabel) + } + + private func setupSourceProfilesContainer() { + let sourceProfilesTitle = NSTextField( + labelWithString: String( + localized: "browser.import.sourceProfiles", + defaultValue: "Source Profiles" + ) + ) + sourceProfilesTitle.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + sourceProfilesList.orientation = .vertical + sourceProfilesList.spacing = 6 + sourceProfilesList.alignment = .leading + sourceProfilesList.translatesAutoresizingMaskIntoConstraints = false + + sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 13) + sourceProfilesEmptyLabel.textColor = .secondaryLabelColor + sourceProfilesEmptyLabel.maximumNumberOfLines = 0 + sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 520 + + sourceProfilesDocumentView.frame = NSRect(x: 0, y: 0, width: 1, height: 1) + sourceProfilesDocumentView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesDocumentView.addSubview(sourceProfilesList) + NSLayoutConstraint.activate([ + sourceProfilesList.topAnchor.constraint(equalTo: sourceProfilesDocumentView.topAnchor), + sourceProfilesList.leadingAnchor.constraint(equalTo: sourceProfilesDocumentView.leadingAnchor), + sourceProfilesList.trailingAnchor.constraint(equalTo: sourceProfilesDocumentView.trailingAnchor), + sourceProfilesList.bottomAnchor.constraint(equalTo: sourceProfilesDocumentView.bottomAnchor), + sourceProfilesList.widthAnchor.constraint(equalTo: sourceProfilesDocumentView.widthAnchor), + ]) + + sourceProfilesScrollView.drawsBackground = false + sourceProfilesScrollView.borderType = .bezelBorder + sourceProfilesScrollView.hasVerticalScroller = true + sourceProfilesScrollView.documentView = sourceProfilesDocumentView + sourceProfilesScrollView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true + sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true + + sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 12) + sourceProfilesHelpLabel.textColor = .secondaryLabelColor + sourceProfilesHelpLabel.maximumNumberOfLines = 2 + sourceProfilesHelpLabel.lineBreakMode = .byWordWrapping + sourceProfilesHelpLabel.stringValue = String( + localized: "browser.import.sourceProfiles.help", + defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + ) + + sourceProfilesContainer.orientation = .vertical + sourceProfilesContainer.spacing = 10 + sourceProfilesContainer.alignment = .leading + sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) + sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) + sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel) + sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical) + sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } + + private func setupDataTypesContainer() { + cookiesCheckbox.state = .on + historyCheckbox.state = .on + additionalDataCheckbox.state = .off + cookiesCheckbox.title = String( + localized: "browser.import.cookies", + defaultValue: "Cookies (site sign-ins)" + ) + historyCheckbox.title = String( + localized: "browser.import.history", + defaultValue: "History (visited pages)" + ) + additionalDataCheckbox.title = String( + localized: "browser.import.additionalData", + defaultValue: "Additional data (bookmarks, settings, extensions)" + ) + cookiesCheckbox.setAccessibilityIdentifier("BrowserImportCookiesCheckbox") + historyCheckbox.setAccessibilityIdentifier("BrowserImportHistoryCheckbox") + additionalDataCheckbox.setAccessibilityIdentifier("BrowserImportAdditionalDataCheckbox") + separateProfilesRadio.title = String( + localized: "browser.import.destinationMode.separate", + defaultValue: "Keep profiles separate" + ) + mergeProfilesRadio.title = String( + localized: "browser.import.destinationMode.merge", + defaultValue: "Merge all into one cmux profile" + ) + separateProfilesRadio.target = self + separateProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) + mergeProfilesRadio.target = self + mergeProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) + + destinationModeContainer.orientation = .vertical + destinationModeContainer.spacing = 6 + destinationModeContainer.alignment = .leading + destinationModeContainer.addArrangedSubview(separateProfilesRadio) + destinationModeContainer.addArrangedSubview(mergeProfilesRadio) + + mergeDestinationPopup.target = self + mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:)) + + separateDestinationRows.orientation = .vertical + separateDestinationRows.spacing = 8 + separateDestinationRows.alignment = .leading + + mergeDestinationRow.orientation = .horizontal + mergeDestinationRow.spacing = 8 + mergeDestinationRow.alignment = .centerY + + destinationHelpLabel.font = NSFont.systemFont(ofSize: 12) + destinationHelpLabel.textColor = .secondaryLabelColor + destinationHelpLabel.maximumNumberOfLines = 3 + destinationHelpLabel.preferredMaxLayoutWidth = 540 + + domainField.placeholderString = String( + localized: "browser.import.domain.placeholder", + defaultValue: "Optional domains only (e.g. github.com, openai.com)" + ) + domainField.stringValue = "" + + let destinationTitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destination.cmux", + defaultValue: "cmux destination" + ) + ) + destinationTitleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + let domainLabel = NSTextField( + labelWithString: String(localized: "browser.import.domain", defaultValue: "Limit to") + ) + domainLabel.alignment = .right + domainLabel.frame.size.width = 80 + + let domainRow = NSStackView(views: [domainLabel, domainField]) + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + + let noteLabel = NSTextField( + wrappingLabelWithString: String( + localized: "browser.import.additionalData.note", + defaultValue: "Bookmarks, settings, and extensions import are not available yet." + ) + ) + noteLabel.font = NSFont.systemFont(ofSize: 12) + noteLabel.textColor = .secondaryLabelColor + noteLabel.maximumNumberOfLines = 2 + noteLabel.preferredMaxLayoutWidth = 540 + + dataTypesContainer.orientation = .vertical + dataTypesContainer.spacing = 8 + dataTypesContainer.alignment = .leading + dataTypesContainer.addArrangedSubview(destinationTitleLabel) + dataTypesContainer.addArrangedSubview(destinationModeContainer) + dataTypesContainer.addArrangedSubview(separateDestinationRows) + dataTypesContainer.addArrangedSubview(mergeDestinationRow) + dataTypesContainer.addArrangedSubview(destinationHelpLabel) + dataTypesContainer.addArrangedSubview(cookiesCheckbox) + dataTypesContainer.addArrangedSubview(historyCheckbox) + dataTypesContainer.addArrangedSubview(additionalDataCheckbox) + dataTypesContainer.addArrangedSubview(domainRow) + dataTypesContainer.addArrangedSubview(noteLabel) + } + + private func configureInitialState() { + step = .source + refreshSourceProfilesList() + updateStepUI() + } + + private func updateStepUI() { + switch step { + case .source: + stepLabel.stringValue = String( + localized: "browser.import.step.source", + defaultValue: "Step 1 of 3: Choose the browser to import from." + ) + sourceContainer.isHidden = false + sourceProfilesContainer.isHidden = true + dataTypesContainer.isHidden = true + backButton.isHidden = true + primaryButton.isEnabled = true + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .sourceProfiles: + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.sourceProfiles", + defaultValue: "Step 2 of 3: Choose source profiles from %@." + ), + selectedBrowser().displayName + ) + sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = false + dataTypesContainer.isHidden = true + backButton.isHidden = false + primaryButton.isEnabled = !selectedBrowser().profiles.isEmpty + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .dataTypes: + rebuildStep3DestinationUI() + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.dataTypes", + defaultValue: "Step 3 of 3: Choose what to import from %@ and where to put it." + ), + selectedBrowser().displayName + ) + sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = true + dataTypesContainer.isHidden = false + backButton.isHidden = false + primaryButton.isEnabled = true + primaryButton.title = String( + localized: "browser.import.start", + defaultValue: "Start Import" + ) + } + } + + private func selectedBrowser() -> InstalledBrowserCandidate { + let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) + return browsers[selectedIndex] + } + + private func refreshSourceProfilesList() { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + + sourceProfileCheckboxes.removeAll() + for arrangedSubview in sourceProfilesList.arrangedSubviews { + sourceProfilesList.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + if browser.profiles.isEmpty { + sourceProfilesEmptyLabel.stringValue = String( + format: String( + localized: "browser.import.sourceProfiles.empty", + defaultValue: "No source profiles detected for %@." + ), + browser.displayName + ) + sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel) + return + } + + for profile in browser.profiles { + let checkbox = NSButton( + checkboxWithTitle: profile.displayName, + target: self, + action: #selector(handleSourceProfileToggled(_:)) + ) + checkbox.identifier = NSUserInterfaceItemIdentifier(profile.id) + checkbox.state = selectedIDs.contains(profile.id) ? .on : .off + checkbox.lineBreakMode = .byTruncatingTail + sourceProfilesList.addArrangedSubview(checkbox) + sourceProfileCheckboxes.append(checkbox) + } + } + + private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { + if let existing = selectedSourceProfileIDsByBrowserID[browser.id] { + return existing + } + let defaultSelection = defaultSelectedSourceProfileIDs(for: browser) + selectedSourceProfileIDsByBrowserID[browser.id] = defaultSelection + return defaultSelection + } + + private func defaultSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { + if let defaultProfile = browser.profiles.first(where: \.isDefault) { + return [defaultProfile.id] + } + if let firstProfile = browser.profiles.first { + return [firstProfile.id] + } + return [] + } + + private func selectedSourceProfiles() -> [InstalledBrowserProfile] { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + return browser.profiles.filter { selectedIDs.contains($0.id) } + } + + private func resetStep3State() { + let selectedProfiles = selectedSourceProfiles() + let defaultPlan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles, + preferredSingleDestinationProfileID: initialDestinationProfileID + ) + destinationMode = defaultPlan.mode + separateExecutionEntries = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles + ).entries + if let initialDestination = defaultPlan.entries.first.flatMap(destinationProfileID(for:)) { + mergeDestinationProfileID = initialDestination + } else { + mergeDestinationProfileID = initialDestinationProfileID + } + rebuildStep3DestinationUI() + } + + private func currentExecutionPlan() -> BrowserImportExecutionPlan { + let selectedProfiles = selectedSourceProfiles() + guard !selectedProfiles.isEmpty else { + return BrowserImportExecutionPlan(mode: .singleDestination, entries: []) + } + + guard selectedProfiles.count > 1 else { + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + + switch destinationMode { + case .separateProfiles: + let entriesBySourceID = Dictionary( + uniqueKeysWithValues: separateExecutionEntries.compactMap { entry in + entry.sourceProfiles.first.map { ($0.id, entry.destination) } + } + ) + let entries = selectedProfiles.map { profile in + BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: entriesBySourceID[profile.id] ?? defaultSeparateDestinationRequest(for: profile) + ) + } + return BrowserImportExecutionPlan(mode: .separateProfiles, entries: entries) + case .singleDestination, .mergeIntoOne: + return BrowserImportExecutionPlan( + mode: .mergeIntoOne, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + } + + private func rebuildStep3DestinationUI() { + let plan = currentExecutionPlan() + let presentation = BrowserImportStep3Presentation(plan: plan) + destinationModeContainer.isHidden = !presentation.showsModeSelector + separateDestinationRows.isHidden = !presentation.showsSeparateRows + mergeDestinationRow.isHidden = !presentation.showsSingleDestinationPicker + + if presentation.showsModeSelector { + separateProfilesRadio.state = destinationMode == .separateProfiles ? .on : .off + mergeProfilesRadio.state = destinationMode == .mergeIntoOne ? .on : .off + } else { + separateProfilesRadio.state = .off + mergeProfilesRadio.state = .off + } + + rebuildSeparateDestinationRows(with: plan) + rebuildMergeDestinationRow() + + if presentation.showsSeparateRows { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.separateHelp", + defaultValue: "Missing cmux profiles are created when import starts." + ) + } else if plan.entries.count > 1 { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.mergeHelp", + defaultValue: "All selected source profiles will be merged into the chosen cmux browser profile." + ) + } else { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.help", + defaultValue: "Imported cookies and history go into the selected cmux browser profile." + ) + } + } + + private func rebuildSeparateDestinationRows(with plan: BrowserImportExecutionPlan) { + separateDestinationOptionsByEntryIndex.removeAll() + for arrangedSubview in separateDestinationRows.arrangedSubviews { + separateDestinationRows.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + guard plan.mode == .separateProfiles else { return } + + for (index, entry) in plan.entries.enumerated() { + guard let sourceProfile = entry.sourceProfiles.first else { continue } + let sourceLabel = NSTextField(labelWithString: sourceProfile.displayName) + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 140 + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.target = self + popup.action = #selector(handleSeparateDestinationChanged(_:)) + popup.tag = index + popup.setAccessibilityIdentifier( + "BrowserImportDestinationPopup-\(accessibilitySlug(for: sourceProfile, index: index))" + ) + + let options = destinationOptions(for: entry, sourceProfile: sourceProfile) + separateDestinationOptionsByEntryIndex[index] = options + for option in options { + popup.addItem(withTitle: title(for: option)) + } + if let selectedIndex = options.firstIndex(of: entry.destination) { + popup.selectItem(at: selectedIndex) + } else { + popup.selectItem(at: 0) + } + + let row = NSStackView(views: [sourceLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + separateDestinationRows.addArrangedSubview(row) + } + } + + private func rebuildMergeDestinationRow() { + for arrangedSubview in mergeDestinationRow.arrangedSubviews { + mergeDestinationRow.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + mergeDestinationPopup.removeAllItems() + for profile in destinationProfiles { + mergeDestinationPopup.addItem(withTitle: profile.displayName) + } + if let selectedIndex = destinationProfiles.firstIndex(where: { $0.id == resolvedMergeDestinationProfileID() }) { + mergeDestinationPopup.selectItem(at: selectedIndex) + } else { + mergeDestinationPopup.selectItem(at: 0) + if let firstProfile = destinationProfiles.first { + mergeDestinationProfileID = firstProfile.id + } + } + mergeDestinationPopup.setAccessibilityIdentifier("BrowserImportDestinationPopup-merge") + + let destinationLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destinationProfile", + defaultValue: "Import into" + ) + ) + destinationLabel.alignment = .right + destinationLabel.frame.size.width = 140 + + mergeDestinationRow.addArrangedSubview(destinationLabel) + mergeDestinationRow.addArrangedSubview(mergeDestinationPopup) + } + + private func destinationOptions( + for entry: BrowserImportExecutionEntry, + sourceProfile: InstalledBrowserProfile + ) -> [BrowserImportDestinationRequest] { + var options = destinationProfiles.map { BrowserImportDestinationRequest.existing($0.id) } + let createName: String + switch entry.destination { + case .createNamed(let name): + createName = name + case .existing: + createName = sourceProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + } + if !createName.isEmpty, + !destinationProfiles.contains(where: { + $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(createName) == .orderedSame + }) { + options.append(.createNamed(createName)) + } + return options + } + + private func title(for request: BrowserImportDestinationRequest) -> String { + switch request { + case .existing(let id): + return destinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + return String( + format: String( + localized: "browser.import.destinationProfile.create", + defaultValue: "Create \"%@\"" + ), + name + ) + } + } + + private func destinationProfileID(for entry: BrowserImportExecutionEntry) -> UUID? { + guard case .existing(let id) = entry.destination else { return nil } + return id + } + + private func resolvedMergeDestinationProfileID() -> UUID { + if destinationProfiles.contains(where: { $0.id == mergeDestinationProfileID }) { + return mergeDestinationProfileID + } + return initialDestinationProfileID + } + + private func defaultSeparateDestinationRequest( + for profile: InstalledBrowserProfile + ) -> BrowserImportDestinationRequest { + BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: [profile], + destinationProfiles: destinationProfiles + ).entries.first?.destination ?? .createNamed(profile.displayName) + } + + private func accessibilitySlug(for profile: InstalledBrowserProfile, index: Int) -> String { + let base = profile.displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return base.isEmpty ? "profile-\(index)" : base + } + + private func finishModal(with response: NSApplication.ModalResponse) { + guard !didFinishModal else { return } + didFinishModal = true + + if NSApp.modalWindow == panel { + NSApp.stopModal(withCode: response) + } + panel.orderOut(nil) + } + } + + private func showProgressWindow(title: String, message: String) -> NSWindow { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 122), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = title + window.isReleasedWhenClosed = false + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 122)) + + let spinner = NSProgressIndicator(frame: NSRect(x: 20, y: 50, width: 20, height: 20)) + spinner.style = .spinning + spinner.controlSize = .regular + spinner.startAnimation(nil) + content.addSubview(spinner) + + let titleLabel = NSTextField(labelWithString: message) + titleLabel.frame = NSRect(x: 52, y: 56, width: 340, height: 20) + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + content.addSubview(titleLabel) + + let subtitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.progress.subtitle", + defaultValue: "This can take a few seconds for large profiles." + ) + ) + subtitleLabel.frame = NSRect(x: 52, y: 34, width: 340, height: 16) + subtitleLabel.font = NSFont.systemFont(ofSize: 11) + subtitleLabel.textColor = .secondaryLabelColor + content.addSubview(subtitleLabel) + + window.contentView = content + + if let keyWindow = NSApp.keyWindow { + keyWindow.beginSheet(window, completionHandler: nil) + } else { + window.center() + window.makeKeyAndOrderFront(nil) + } + + return window + } + + private func hideProgressWindow(_ window: NSWindow) { + if let parent = window.sheetParent { + parent.endSheet(window) + } else { + window.orderOut(nil) + } + } + + private func presentOutcome(_ outcome: BrowserImportOutcome) { + let lines = BrowserImportOutcomeFormatter.lines(for: outcome) + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String( + localized: "browser.import.complete.title", + defaultValue: "Browser data import complete" + ) + alert.informativeText = lines.joined(separator: "\n") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + } +} diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d5433771..596820de 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -235,6 +235,7 @@ private struct BrowserChromeStyle { /// 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 @@ -255,6 +256,9 @@ 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 emptyStateImportBrowserRefreshTask: Task? + @State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0 @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @@ -266,6 +270,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 browserChromeStyle = BrowserChromeStyle.resolve( for: .light, @@ -461,7 +466,8 @@ struct BrowserPanelView: View { // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") - BrowserHistoryStore.shared.loadIfNeeded() + refreshEmptyStateImportBrowsers() + panel.historyStore.loadIfNeeded() #if DEBUG logBrowserFocusState(event: "view.onAppear") #endif @@ -480,6 +486,9 @@ struct BrowserPanelView: View { !isWebViewBlank() { setAddressBarFocused(false, reason: "panel.currentURL.loaded") } + if isWebViewBlank() { + refreshEmptyStateImportBrowsers() + } } .onChange(of: browserThemeModeRaw) { _ in let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) @@ -498,6 +507,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( @@ -570,7 +585,7 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) refreshInlineCompletion() } - .onReceive(BrowserHistoryStore.shared.$entries) { _ in + .onReceive(panel.historyStore.$entries) { _ in guard addressBarFocused else { return } refreshSuggestions() } @@ -598,10 +613,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - if !panel.isShowingNewTabPage { - browserThemeModeButton - developerToolsButton - } + browserProfileButton + browserThemeModeButton + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) @@ -706,6 +720,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() @@ -722,10 +764,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 @@ -910,6 +1018,11 @@ struct BrowserPanelView: View { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } + .overlay { + if shouldShowEmptyStateImportOverlay { + emptyBrowserStateOverlay + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -1175,6 +1288,51 @@ struct BrowserPanelView: View { #endif } + private var emptyBrowserStateOverlay: some View { + VStack { + Spacer(minLength: 22) + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(12) + .frame(maxWidth: 360, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.45), + lineWidth: 1 + ) + ) + .shadow(color: Color.black.opacity(0.08), radius: 8, y: 3) + + Spacer() + } + .padding(.horizontal, 18) + } + + private var shouldShowEmptyStateImportOverlay: Bool { + !panel.shouldRenderWebView && isWebViewBlank() + } + /// 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 } @@ -1226,6 +1384,31 @@ struct BrowserPanelView: View { #endif } + private func refreshEmptyStateImportBrowsers() { + emptyStateImportBrowserRefreshTask?.cancel() + emptyStateImportBrowserRefreshGeneration &+= 1 + let generation = emptyStateImportBrowserRefreshGeneration + + guard shouldShowEmptyStateImportOverlay else { + emptyStateImportBrowsers = [] + emptyStateImportBrowserRefreshTask = nil + return + } + + emptyStateImportBrowserRefreshTask = Task { + let browsers = await Task.detached(priority: .utility) { + InstalledBrowserDetector.detectInstalledBrowsers() + }.value + guard !Task.isCancelled else { return } + await MainActor.run { + guard emptyStateImportBrowserRefreshGeneration == generation, + shouldShowEmptyStateImportOverlay else { return } + emptyStateImportBrowsers = browsers + emptyStateImportBrowserRefreshTask = nil + } + } + } + private func openDevTools() { #if DEBUG dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") @@ -1329,10 +1512,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, @@ -1368,9 +1614,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 @@ -1434,7 +1680,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), diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 5419cd92..b0303d53 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -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 diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 40c07397..10cda9d6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2995,6 +2995,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 } @@ -3003,14 +3004,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 @@ -3025,6 +3036,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 } @@ -3038,7 +3050,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 @@ -3064,6 +3077,7 @@ class TabManager: ObservableObject { from: splitSourcePanelId, orientation: .horizontal, url: url, + preferredProfileID: preferredProfileID, focus: true ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) @@ -3076,7 +3090,8 @@ class TabManager: ObservableObject { inPane: paneId, url: url, focus: true, - insertAtEnd: insertAtEnd + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID ) else { return nil } @@ -3086,12 +3101,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 ) } @@ -3187,7 +3207,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) @@ -3204,7 +3229,8 @@ class TabManager: ObservableObject { from: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, - url: snapshot.url + url: snapshot.url, + preferredProfileID: snapshot.profileID )?.id { return browserPanelId } @@ -3212,7 +3238,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. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b0d65f39..ae6831e8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -328,6 +328,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(), @@ -513,7 +514,8 @@ extension Workspace { guard let browserPanel = newBrowserSurface( inPane: paneId, url: initialURL, - focus: false + focus: false, + preferredProfileID: snapshot.browser?.profileID ) else { return nil } @@ -898,6 +900,7 @@ enum SidebarBranchOrdering { struct ClosedBrowserPanelRestoreSnapshot { let workspaceId: UUID let url: URL? + let profileID: UUID? let originalPaneId: UUID let originalTabIndex: Int let fallbackSplitOrientation: SplitOrientation? @@ -915,6 +918,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 @@ -1363,6 +1367,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) { @@ -2266,6 +2299,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 @@ -2282,9 +2316,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( @@ -2340,17 +2382,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, @@ -2823,6 +2868,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, @@ -4002,14 +4048,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) { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2768c037..2b9fb5ec 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -587,6 +587,10 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } + Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { activeTabManager.selectNextTab() } @@ -3150,6 +3154,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? @@ -3254,6 +3259,10 @@ struct SettingsView: View { } } + private var browserImportSubtitle: String { + InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -4349,6 +4358,25 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { + HStack(spacing: 8) { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + SettingsCardDivider() + SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true @@ -4491,6 +4519,7 @@ struct SettingsView: View { browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + refreshDetectedImportBrowsers() reloadWorkspaceTabColorSettings() refreshNotificationCustomSoundStatus() } @@ -4646,6 +4675,7 @@ struct SettingsView: View { socketPasswordDraft = "" socketPasswordStatusMessage = nil socketPasswordStatusIsError = false + refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() WorkspaceTabColorSettings.reset() reloadWorkspaceTabColorSettings() @@ -4691,6 +4721,10 @@ struct SettingsView: View { private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } + + private func refreshDetectedImportBrowsers() { + detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift new file mode 100644 index 00000000..1f6c662c --- /dev/null +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -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 + ) + } +} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 7e74a25b..cab7d0f7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1043,6 +1043,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase { ClosedBrowserPanelRestoreSnapshot( workspaceId: UUID(), url: URL(string: "https://example.com/\(index)"), + profileID: nil, originalPaneId: UUID(), originalTabIndex: index, fallbackSplitOrientation: .horizontal, @@ -1844,3 +1845,232 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { return output.trimmingCharacters(in: .whitespacesAndNewlines) } } + +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" })) + } + + 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)") + } + + 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) + } +} + +final class BrowserImportScopeTests: XCTestCase { + func testFromSelectionCookiesOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesOnly) + } + + func testFromSelectionHistoryOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .historyOnly) + } + + func testFromSelectionCookiesAndHistory() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesAndHistory) + } + + func testFromSelectionEverything() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: true + ) + XCTAssertEqual(scope, .everything) + } + + func testFromSelectionRejectsEmptySelection() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertNil(scope) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index af9ccf2d..2b72a440 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -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) } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift new file mode 100644 index 00000000..eca6d360 --- /dev/null +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -0,0 +1,159 @@ +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") + } + + func testAdditionalDataSelectionCapturesEverythingScope() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"] + XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0)) + cookiesCheckbox.click() + + let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"] + XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0)) + historyCheckbox.click() + + let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"] + XCTAssertTrue( + additionalDataCheckbox.waitForExistence(timeout: 5.0), + "Expected Step 3 to expose the additional data checkbox" + ) + additionalDataCheckbox.click() + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["scope"] as? String, "everything") + } + + 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 + } +}