From 8db5ccbb58c4e68de38480e1efb2e18254514a52 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 28 Jan 2026 04:20:52 -0800 Subject: [PATCH] Move no-update auto-dismiss to controller --- GhosttyTabsUITests/UpdatePillUITests.swift | 15 ++++-- Sources/Update/UpdateController.swift | 59 ++++++++++++++++++++++ Sources/Update/UpdatePill.swift | 47 ----------------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/GhosttyTabsUITests/UpdatePillUITests.swift b/GhosttyTabsUITests/UpdatePillUITests.swift index 11313e2c..ebcafb9b 100644 --- a/GhosttyTabsUITests/UpdatePillUITests.swift +++ b/GhosttyTabsUITests/UpdatePillUITests.swift @@ -90,10 +90,17 @@ final class UpdatePillUITests: XCTestCase { return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } - private func assertVisibleSize(_ element: XCUIElement) { - let size = element.frame.size - XCTAssertGreaterThan(size.width, 20) - XCTAssertGreaterThan(size.height, 10) + private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) { + let deadline = Date().addingTimeInterval(timeout) + var size = element.frame.size + while Date() < deadline { + size = element.frame.size + if size.width > 20 && size.height > 10 { + return + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTFail("Expected UpdatePill to have visible size, got \(size)") } private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) { diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 5e360cd1..e8f19c6a 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -1,12 +1,15 @@ import Sparkle import Cocoa import Combine +import SwiftUI /// Controller for managing Sparkle updates in cmuxterm. class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? + private var noUpdateDismissCancellable: AnyCancellable? + private var noUpdateDismissWorkItem: DispatchWorkItem? var viewModel: UpdateViewModel { userDriver.viewModel @@ -26,10 +29,13 @@ class UpdateController { userDriver: userDriver, delegate: userDriver ) + installNoUpdateDismissObserver() } deinit { installCancellable?.cancel() + noUpdateDismissCancellable?.cancel() + noUpdateDismissWorkItem?.cancel() } /// Start the updater. If startup fails, the error is shown via the custom UI. @@ -105,4 +111,57 @@ class UpdateController { } return true } + + private func installNoUpdateDismissObserver() { + noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) + .receive(on: DispatchQueue.main) + .sink { [weak self] state, overrideState in + self?.scheduleNoUpdateDismiss(for: state, overrideState: overrideState) + } + } + + private func scheduleNoUpdateDismiss(for state: UpdateState, overrideState: UpdateState?) { + noUpdateDismissWorkItem?.cancel() + noUpdateDismissWorkItem = nil + + guard overrideState == nil else { return } + guard case .notFound(let notFound) = state else { return } + + recordUITestTimestamp(key: "noUpdateShownAt") + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.viewModel.overrideState == nil, + case .notFound = self.viewModel.state else { return } + + withAnimation(.easeInOut(duration: 0.25)) { + self.recordUITestTimestamp(key: "noUpdateHiddenAt") + self.viewModel.state = .idle + } + notFound.acknowledgement() + } + noUpdateDismissWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + UpdateTiming.noUpdateDisplayDuration, + execute: workItem + ) + } + + private func recordUITestTimestamp(key: String) { +#if DEBUG + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_MODE"] == "1" else { return } + guard let path = env["CMUX_UI_TEST_TIMING_PATH"] else { return } + + let url = URL(fileURLWithPath: path) + var payload: [String: Double] = [:] + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] { + payload = object + } + payload[key] = Date().timeIntervalSince1970 + if let data = try? JSONSerialization.data(withJSONObject: payload) { + try? data.write(to: url) + } +#endif + } } diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index 6795e103..4854e4bc 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -6,7 +6,6 @@ import SwiftUI struct UpdatePill: View { @ObservedObject var model: UpdateViewModel @State private var showPopover = false - @State private var resetTask: Task? private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) @@ -22,12 +21,6 @@ struct UpdatePill: View { UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) - .onAppear { - scheduleNoUpdateDismiss(for: model.effectiveState) - } - .onChange(of: model.effectiveState) { newState in - scheduleNoUpdateDismiss(for: newState) - } } } @@ -71,44 +64,4 @@ struct UpdatePill: View { let size = (model.maxWidthText as NSString).size(withAttributes: attributes) return size.width } - - private func recordUITestTimestamp(key: String) { -#if DEBUG - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_MODE"] == "1" else { return } - guard let path = env["CMUX_UI_TEST_TIMING_PATH"] else { return } - - let url = URL(fileURLWithPath: path) - var payload: [String: Double] = [:] - if let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] { - payload = object - } - payload[key] = Date().timeIntervalSince1970 - if let data = try? JSONSerialization.data(withJSONObject: payload) { - try? data.write(to: url) - } -#endif - } - - private func scheduleNoUpdateDismiss(for state: UpdateState) { - resetTask?.cancel() - if case .notFound(let notFound) = state, model.overrideState == nil { - recordUITestTimestamp(key: "noUpdateShownAt") - resetTask = Task { [weak model] in - let delay = UInt64(UpdateTiming.noUpdateDisplayDuration * 1_000_000_000) - try? await Task.sleep(nanoseconds: delay) - guard !Task.isCancelled, case .notFound? = model?.state else { return } - await MainActor.run { - withAnimation(.easeInOut(duration: 0.25)) { - recordUITestTimestamp(key: "noUpdateHiddenAt") - model?.state = .idle - } - } - notFound.acknowledgement() - } - } else { - resetTask = nil - } - } }