diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0396f6e9..68b84487 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4797,7 +4797,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザ: %@" + "value": "ブラウザー: %@" } } } @@ -37876,7 +37876,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む…" + "value": "ブラウザーから取り込む…" } } } @@ -50658,7 +50658,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザデータを取り込む" + "value": "ブラウザーデータを取り込む" } } } @@ -50788,7 +50788,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む" + "value": "ブラウザーから取り込む" } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7af68022..c7a17f4d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2354,11 +2354,41 @@ final class BrowserPanel: Panel, ObservableObject { webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + configureNavigationDelegateCallbacks() webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) } + private func configureNavigationDelegateCallbacks() { + guard let navigationDelegate else { return } + let boundWebViewInstanceID = webViewInstanceID + let boundHistoryStore = historyStore + + navigationDelegate.didFinish = { [weak self] webView in + Task { @MainActor [weak self] in + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + boundHistoryStore.recordVisit(url: webView.url, title: webView.title) + self.refreshFavicon(from: webView) + self.applyBrowserThemeModeIfNeeded() + // Keep find-in-page open through load completion and refresh matches for the new DOM. + self.restoreFindStateAfterNavigation(replaySearch: true) + } + } + navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in + Task { @MainActor in + guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return } + // Clear stale title/favicon from the previous page so the tab + // shows the failed URL instead of the old page's branding. + self.pageTitle = failedURL.isEmpty ? "" : failedURL + self.faviconPNGData = nil + self.lastFaviconURLString = nil + // Keep find-in-page open and clear stale counters on failed loads. + self.restoreFindStateAfterNavigation(replaySearch: false) + } + } + } + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { guard candidate === webView else { return false } guard let instanceID else { return true } @@ -2389,30 +2419,6 @@ final class BrowserPanel: Panel, ObservableObject { // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() - navDelegate.didFinish = { webView in - 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) - self.applyBrowserThemeModeIfNeeded() - // Keep find-in-page open through load completion and refresh matches for the new DOM. - self.restoreFindStateAfterNavigation(replaySearch: true) - } - } - navDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in - Task { @MainActor in - guard let self, self.isCurrentWebView(failedWebView) else { return } - // Clear stale title/favicon from the previous page so the tab - // shows the failed URL instead of the old page's branding. - self.pageTitle = failedURL.isEmpty ? "" : failedURL - self.faviconPNGData = nil - self.lastFaviconURLString = nil - // Keep find-in-page open and clear stale counters on failed loads. - self.restoreFindStateAfterNavigation(replaySearch: false) - } - } navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 596820de..f0b16dc1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1518,8 +1518,9 @@ struct BrowserPanelView: View { private func applyBrowserProfileSelection(_ profileID: UUID) { isBrowserProfileMenuPresented = false + let didApply = panel.profileID == profileID || panel.switchToProfile(profileID) + guard didApply else { return } owningWorkspace?.setPreferredBrowserProfileID(profileID) - _ = panel.switchToProfile(profileID) } private func presentCreateBrowserProfilePrompt() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ae6831e8..25a7173d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2326,7 +2326,6 @@ final class Workspace: Identifiable, ObservableObject { ) 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( @@ -2350,6 +2349,7 @@ final class Workspace: Identifiable, ObservableObject { panelTitles.removeValue(forKey: browserPanel.id) return nil } + setPreferredBrowserProfileID(browserPanel.profileID) // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView @@ -2386,16 +2386,19 @@ final class Workspace: Identifiable, ObservableObject { bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) let browserPanel = BrowserPanel( workspaceId: id, - profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID), + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: sourcePanelId + ), initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle - setPreferredBrowserProfileID(browserPanel.profileID) guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, @@ -2412,6 +2415,7 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = browserPanel.id + setPreferredBrowserProfileID(browserPanel.profileID) // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. if insertAtEnd { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 75f32d3b..67f8cadf 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6271,6 +6271,12 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { } } + private final class RejectingSplitPaneDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + false + } + } + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( BrowserProfileStore.shared.createProfile( @@ -6354,6 +6360,38 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { "Expected a failed browser creation to leave the workspace preferred profile unchanged" ) } + + func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeProfile(named: "Preferred") + let unexpectedProfile = try makeProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingSplitPaneDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSplit( + from: browser.id, + orientation: .horizontal, + preferredProfileID: unexpectedProfile.id, + focus: false + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser split to leave the workspace preferred profile unchanged" + ) + } } @MainActor @@ -6434,7 +6472,10 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { alternateStore.clearHistory() } - let panel = BrowserPanel(workspaceId: UUID()) + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) let staleWebView = panel.webView let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) @@ -6443,18 +6484,23 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { baseURL: staleURL ) - XCTAssertTrue(panel.switchToProfile(alternateProfile.id)) + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() staleDelegate.webView?(staleWebView, didFinish: nil) drainMainQueue() XCTAssertTrue( defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store" + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" ) XCTAssertTrue( alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store" + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" ) } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index cab7d0f7..9ac2a8f7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2004,16 +2004,20 @@ final class BrowserInstallDetectorTests: XCTestCase { return } - XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"]) + XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"])) XCTAssertEqual( - safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(), + safari.profiles + .map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().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/Safari", isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), + home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), home.appendingPathComponent( "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel", isDirectory: true - ).path(percentEncoded: false), + ).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), ].sorted() ) } @@ -2024,7 +2028,9 @@ final class BrowserInstallDetectorTests: XCTestCase { 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) + guard FileManager.default.createFile(atPath: url.path, contents: contents) else { + throw CocoaError(.fileWriteUnknown) + } } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2b72a440..6f5c7b1d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -150,7 +150,7 @@ final class SessionPersistenceTests: XCTestCase { } func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { - let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64") + let profileID = try XCTUnwrap(UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")) let source = SessionBrowserPanelSnapshot( urlString: "https://example.com/current", profileID: profileID, diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index eca6d360..cc28d425 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -138,12 +138,7 @@ final class BrowserImportProfilesUITests: XCTestCase { } 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 + return nil } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {