From ca9c680da707fb31bf81d8f16b060580c3ffa469 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:12:50 -0800 Subject: [PATCH] Ensure tab focus owns key input --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/GhosttyTerminalView.swift | 35 ++++++ Sources/TabManager.swift | 1 + Sources/Update/UpdateTestURLProtocol.swift | 117 +++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 Sources/Update/UpdateTestURLProtocol.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 897dceb8..02ee4bb0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ A5001206 /* UpdateBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001216 /* UpdateBadge.swift */; }; A500120A /* UpdateTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001220 /* UpdateTiming.swift */; }; A500120B /* UpdateTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001221 /* UpdateTestSupport.swift */; }; + A500120E /* UpdateTestURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001224 /* UpdateTestURLProtocol.swift */; }; A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; }; A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; }; @@ -89,6 +90,7 @@ A5001216 /* UpdateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateBadge.swift; sourceTree = ""; }; A5001220 /* UpdateTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTiming.swift; sourceTree = ""; }; A5001221 /* UpdateTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTestSupport.swift; sourceTree = ""; }; + A5001224 /* UpdateTestURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTestURLProtocol.swift; sourceTree = ""; }; A5001217 /* UpdatePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePopoverView.swift; sourceTree = ""; }; A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = ""; }; @@ -176,6 +178,7 @@ A5001216 /* UpdateBadge.swift */, A5001220 /* UpdateTiming.swift */, A5001221 /* UpdateTestSupport.swift */, + A5001224 /* UpdateTestURLProtocol.swift */, A5001223 /* UpdateLogStore.swift */, A5001217 /* UpdatePopoverView.swift */, A5001218 /* UpdateTitlebarAccessory.swift */, @@ -310,6 +313,7 @@ A5001206 /* UpdateBadge.swift in Sources */, A500120A /* UpdateTiming.swift in Sources */, A500120B /* UpdateTestSupport.swift in Sources */, + A500120E /* UpdateTestURLProtocol.swift in Sources */, A500120D /* UpdateLogStore.swift in Sources */, A5001207 /* UpdatePopoverView.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b994eae5..1d193c2b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1771,6 +1771,41 @@ final class GhosttySurfaceScrollView: NSView { } } + func ensureFocus(for tabId: UUID, surfaceId: UUID, attempt: Int = 0) { + let maxAttempts = 6 + guard attempt < maxAttempts else { return } + guard let tabManager = AppDelegate.shared?.tabManager, + tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } + + guard let window else { + scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) + return + } + + guard window.isKeyWindow else { + scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) + return + } + + if window.firstResponder === surfaceView { + return + } + + window.makeFirstResponder(surfaceView) + + if window.firstResponder !== surfaceView { + scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt) + } + } + + private func scheduleFocusRetry(for tabId: UUID, surfaceId: UUID, attempt: Int) { + let delay = 0.05 * pow(2.0, Double(attempt)) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.ensureFocus(for: tabId, surfaceId: surfaceId, attempt: attempt + 1) + } + } + private func updateFocusForWindow() { let shouldFocus = isActive && (window?.isKeyWindow ?? false) surfaceView.desiredFocus = shouldFocus diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 8f9d4466..e1fd238b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -395,6 +395,7 @@ class TabManager: ObservableObject { tabs.first(where: { $0.id == id })?.focusedSurface } surface.hostedView.moveFocus(from: previousSurface?.hostedView) + surface.hostedView.ensureFocus(for: selectedTabId, surfaceId: surface.id) } private func updateTabTitle(tabId: UUID, title: String) { diff --git a/Sources/Update/UpdateTestURLProtocol.swift b/Sources/Update/UpdateTestURLProtocol.swift new file mode 100644 index 00000000..6c6fe575 --- /dev/null +++ b/Sources/Update/UpdateTestURLProtocol.swift @@ -0,0 +1,117 @@ +#if DEBUG +import Foundation + +final class UpdateTestURLProtocol: URLProtocol { + static let host = "cmuxterm.test" + static let appcastPath = "/appcast.xml" + static let updatePath = "/cmuxterm-test.zip" + + private static var isRegistered = false + private static let registrationLock = NSLock() + + static func registerIfNeeded() { + registrationLock.lock() + defer { registrationLock.unlock() } + guard !isRegistered else { return } + URLProtocol.registerClass(UpdateTestURLProtocol.self) + isRegistered = true + } + + override class func canInit(with request: URLRequest) -> Bool { + guard let url = request.url else { return false } + guard url.host == host else { return false } + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let method = request.httpMethod?.uppercased() ?? "GET" + let (statusCode, data, contentType) = payload(for: url) + let headers = [ + "Content-Type": contentType, + "Content-Length": "\(data.count)" + ] + + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + if method != "HEAD" { + client?.urlProtocol(self, didLoad: data) + } + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private func payload(for url: URL) -> (Int, Data, String) { + switch url.path { + case Self.appcastPath: + let data = Self.appcastData() + return (200, data, "application/xml") + case Self.updatePath: + let data = Self.updateArchiveData() + return (200, data, "application/octet-stream") + default: + let data = Data("Not Found".utf8) + return (404, data, "text/plain") + } + } + + private static func appcastData() -> Data { + let env = ProcessInfo.processInfo.environment + let mode = env["CMUX_UI_TEST_FEED_MODE"] ?? "available" + let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9" + let updateURL = "https://\(host)\(updatePath)" + let updateLength = updateArchiveData().count + + let item: String + if mode == "none" { + item = "" + } else { + item = """ + + cmuxterm \(version) + \(version) + \(version) + + + """ + } + + let xml = """ + + + + cmuxterm Test Updates + https://\(host) + Test updates feed + en + \(item) + + + """ + + return Data(xml.utf8) + } + + private static func updateArchiveData() -> Data { + Data("cmuxterm test update".utf8) + } +} +#endif