From e3781f23932d1ac54cde3cbdf7646cf828b95857 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Thu, 17 Sep 2026 13:49:12 -0500 Subject: [PATCH 1/2] splash: fixed life, and a loading indicator under it The splash waited for the later of its own animation and the active app's first navigation, capped at 8s, so an ordinary catalog fetch or page load read as a stuck splash. It was also waiting on the wrong signal: didFinish fires when the document loads, not when the app has drawn anything, so the wait could end on a blank page anyway. The splash now lives exactly as long as its animation and then goes. The content area says "loading" for itself: an opaque overlay with a spinner while that tab has a navigation in flight, and while the catalog has not resolved an app to mount yet. Webviews also get the shell's background as their under-page color, so a tab opening for the first time no longer flashes white. Measured on this machine, warm: catalog fetch 0.59s, first tab to didFinish 0.17s. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/Window/MattstackWindowView.swift | 22 ++++++- rt-tray/Sources/Window/SplashView.swift | 5 +- rt-tray/Sources/Window/WebViewStore.swift | 4 ++ rt-tray/Sources/Window/WindowModel.swift | 57 ++++++++----------- 4 files changed, 50 insertions(+), 38 deletions(-) diff --git a/rt-tray/Sources/Window/MattstackWindowView.swift b/rt-tray/Sources/Window/MattstackWindowView.swift index 1476caa28..adea3ab02 100644 --- a/rt-tray/Sources/Window/MattstackWindowView.swift +++ b/rt-tray/Sources/Window/MattstackWindowView.swift @@ -240,9 +240,14 @@ private struct ContentArea: View { WindowWebView(model: model, app: app) if model.loadFailures[app.name] == true { FailureOverlay(model: model, app: app) + } else if model.loadingApps.contains(app.name) { + LoadingOverlay() } } else { - Color(NSColor.windowBackgroundColor) + // Before the catalog resolves there is no app to mount, and + // the splash may already have gone, so this is what the + // window shows in the meantime. + LoadingOverlay() } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -275,6 +280,21 @@ private struct WindowWebView: NSViewRepresentable { } } +/// Opaque, not a floating spinner over a half-drawn page: until the page has +/// something to show, the shell's own background is the better thing to look +/// at, and it is the same color the webview shows through. +private struct LoadingOverlay: View { + var body: some View { + ZStack { + barFill + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + .colorScheme(.dark) + } + } +} + private struct FailureOverlay: View { @ObservedObject var model: WindowModel let app: DiscoveryApp diff --git a/rt-tray/Sources/Window/SplashView.swift b/rt-tray/Sources/Window/SplashView.swift index 38affea38..57c8b61c6 100644 --- a/rt-tray/Sources/Window/SplashView.swift +++ b/rt-tray/Sources/Window/SplashView.swift @@ -24,9 +24,8 @@ enum SplashTuning { static let dismissFadeDuration: Double = 0.25 - // The minimum-display gate WindowModel waits on before it will consider - // dismissing the splash (the other half of the "later of" rule is the - // active app's first navigation finishing, still uncapped here at 8s). + // How long the splash is on screen, full stop: WindowModel dismisses on + // this alone and waits on nothing else. // animationSettleDuration is a best-visual-estimate of when the drop-in // finishes, not something derived from the spring math -- if a future // eye-check says the animation actually settles earlier or later, this diff --git a/rt-tray/Sources/Window/WebViewStore.swift b/rt-tray/Sources/Window/WebViewStore.swift index fba6166bf..b1685abb8 100644 --- a/rt-tray/Sources/Window/WebViewStore.swift +++ b/rt-tray/Sources/Window/WebViewStore.swift @@ -35,6 +35,10 @@ final class WebViewStore { config.websiteDataStore = .default() let view = WKWebView(frame: .zero, configuration: config) view.allowsBackForwardNavigationGestures = true + // What shows through before a page has painted. Left at its default + // it is white, which flashes against the shell's dark chrome every + // time a tab is opened for the first time. + view.underPageBackgroundColor = ShellChrome.bar.nsColor if let url = URL(string: app.url) { view.load(URLRequest(url: url)) } views[app.name] = view return view diff --git a/rt-tray/Sources/Window/WindowModel.swift b/rt-tray/Sources/Window/WindowModel.swift index ad3219edb..89276f06c 100644 --- a/rt-tray/Sources/Window/WindowModel.swift +++ b/rt-tray/Sources/Window/WindowModel.swift @@ -29,11 +29,7 @@ final class WindowNavigationDelegate: NSObject, WKNavigationDelegate, WKUIDelega func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { model?.loadFailures[appName] = true - // A dead app's failed load still counts as its "first navigation - // finishing" for the splash gate, or a dead app would hold the - // splash for the full 8s hard cap instead of dismissing at the - // normal minimum-visible time with the error overlay ready beneath. - model?.reportFirstNavigationFinish(appName: appName) + model?.loadingApps.remove(appName) } /// Clears the overlay as soon as a new attempt starts, not just on @@ -41,11 +37,12 @@ final class WindowNavigationDelegate: NSObject, WKNavigationDelegate, WKUIDelega /// until the retry completes. func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { model?.loadFailures[appName] = false + model?.loadingApps.insert(appName) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { model?.loadFailures[appName] = false - model?.reportFirstNavigationFinish(appName: appName) + model?.loadingApps.remove(appName) } /// A same-window link to a different mattstack app (e.g. deck's own app @@ -109,6 +106,9 @@ final class WindowModel: ObservableObject { @Published private(set) var catalogFresh = false @Published var activeApp: String = "" @Published var loadFailures: [String: Bool] = [:] + /// Apps whose webview has a navigation in flight, so the content area can + /// say "loading" instead of showing a blank page. + @Published var loadingApps: Set = [] @Published private(set) var icons: [String: NSImage] = [:] @Published private(set) var splashVisible = false @Published private(set) var splashOpacity: Double = 1 @@ -125,8 +125,6 @@ final class WindowModel: ObservableObject { /// the process level to say what it means -- re-shows of the window /// never replay the splash. private static var hasShownSplash = false - private var splashMinDelayElapsed = false - private var splashNavigationFinished = false private var splashDismissed = false init(store: WebViewStore? = nil) { @@ -184,42 +182,28 @@ final class WindowModel: ObservableObject { } /// No-op on every call after the first per process: `show()` calls this - /// unconditionally on every window show, including re-shows. The - /// minimum-display gate is `SplashTuning.minimumVisibleDuration` - /// (animation settle + a post-settle hold), not a bare literal here, so - /// it stays in lockstep with the animation's own tunables. + /// unconditionally on every window show, including re-shows. + /// + /// The splash lives for exactly as long as its own animation takes + /// (`SplashTuning.minimumVisibleDuration`, kept there so it stays in + /// lockstep with the animation's tunables) and then goes, whatever the + /// network is doing. It used to also wait on the active app's first + /// navigation, which made an unremarkable catalog fetch or page load read + /// as a stuck splash -- and waited on the wrong thing anyway, since a + /// finished navigation is not a drawn page. Content that is not ready yet + /// says so itself, in the content area. func presentSplashIfNeeded() { guard !Self.hasShownSplash else { return } Self.hasShownSplash = true splashVisible = true - let minimumVisibleNanoseconds = UInt64(SplashTuning.minimumVisibleDuration * 1_000_000_000) + let visibleNanoseconds = UInt64(SplashTuning.minimumVisibleDuration * 1_000_000_000) Task { [weak self] in - try? await Task.sleep(nanoseconds: minimumVisibleNanoseconds) - self?.splashMinDelayElapsed = true - self?.dismissSplashIfReady() - } - Task { [weak self] in - try? await Task.sleep(nanoseconds: 8_000_000_000) + try? await Task.sleep(nanoseconds: visibleNanoseconds) self?.dismissSplash() } } - /// The gate is "the active app's first navigation finishing", checked - /// live against `activeApp` rather than a name captured at splash-show - /// time, since the active app is often still unresolved (catalog not - /// loaded yet) at that moment. - func reportFirstNavigationFinish(appName: String) { - guard splashVisible, appName == activeApp else { return } - splashNavigationFinished = true - dismissSplashIfReady() - } - - private func dismissSplashIfReady() { - guard splashMinDelayElapsed, splashNavigationFinished else { return } - dismissSplash() - } - /// Deterministic fade, not a conditional-removal `.transition`: a plain /// `if splashVisible` conditional pops the instant the flag flips /// (that removal isn't guaranteed to pick up an ambient `.animation`), @@ -279,6 +263,11 @@ final class WindowModel: ObservableObject { navigationDelegates[appName] = delegate view.navigationDelegate = delegate view.uiDelegate = delegate + // The store starts a webview's first load when it builds it, which is + // a moment before this delegate exists. Seeding from the webview's own + // state, rather than assuming, keeps the indicator honest for a view + // that somehow arrives already idle. + if view.isLoading { loadingApps.insert(appName) } } private func fetchIcon(url urlString: String?, into name: String) { From 6b7a71d823877adc1d8fe148a938f38731953eb0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Thu, 17 Sep 2026 14:46:36 -0500 Subject: [PATCH 2/2] window: retry a failed tab icon, and log what came back instead Icons were fetched once per app at launch, so a single bad moment cost every tab its icon for the life of the window. It happened twice today: all five apps came back with the same undecodable 153264-byte body, and the tabs wore single letters until relaunch. A failed icon is now retried at 1s, 3s and 9s, which outlasts a service still coming up as the window opens. On failure the log carries the status, content type, final url and first line of the body, so the next occurrence names whatever served it -- the byte count alone said only that it was not an image. Co-Authored-By: Claude Opus 5 (1M context) --- rt-tray/Sources/Window/WindowModel.swift | 60 +++++++++++++++++++----- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/rt-tray/Sources/Window/WindowModel.swift b/rt-tray/Sources/Window/WindowModel.swift index 89276f06c..320d541bf 100644 --- a/rt-tray/Sources/Window/WindowModel.swift +++ b/rt-tray/Sources/Window/WindowModel.swift @@ -270,21 +270,59 @@ final class WindowModel: ObservableObject { if view.isLoading { loadingApps.insert(appName) } } + /// Icons are fetched once per app at launch, which used to mean a single + /// bad moment cost the tab its icon for the life of the window: every app + /// came back with the same undecodable 150KB body one startup, and the + /// tabs wore letters until the next relaunch. So a failure is retried, and + /// what came back is logged well enough to name the culprit next time -- + /// a byte count alone said only that it was not an image. private func fetchIcon(url urlString: String?, into name: String) { guard icons[name] == nil, let urlString, let url = URL(string: urlString) else { return } Task { [weak self] in - let data: Data - do { - data = try await URLSession.shared.data(from: url).0 - } catch { - TrayLog.warn("window icon fetch failed", ["app": name, "url": urlString, "error": String(describing: error)]) - return + for attempt in 1...Self.iconFetchAttempts { + if let image = await Self.loadIcon(url: url, app: name, attempt: attempt) { + self?.icons[name] = image + return + } + guard attempt < Self.iconFetchAttempts else { return } + try? await Task.sleep(nanoseconds: UInt64(Self.iconRetryDelay(attempt) * 1_000_000_000)) } - guard let image = NSImage(data: data) else { - TrayLog.warn("window icon decode failed", ["app": name, "url": urlString, "bytes": data.count]) - return - } - self?.icons[name] = image } } + + private static let iconFetchAttempts = 4 + + /// 1s, 3s, 9s: long enough in total (13s) to outlast a service that is + /// still coming up when the window opens, short enough that a tab does + /// not wear a letter for a noticeable part of a session. + private static func iconRetryDelay(_ attempt: Int) -> Double { + pow(3, Double(attempt - 1)) + } + + private static func loadIcon(url: URL, app: String, attempt: Int) async -> NSImage? { + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(from: url) + } catch { + TrayLog.warn("window icon fetch failed", [ + "app": app, "url": url.absoluteString, "attempt": attempt, + "error": String(describing: error), + ]) + return nil + } + if let image = NSImage(data: data) { return image } + let http = response as? HTTPURLResponse + TrayLog.warn("window icon decode failed", [ + "app": app, "url": url.absoluteString, "attempt": attempt, + "bytes": data.count, + "status": http?.statusCode ?? -1, + "contentType": http?.value(forHTTPHeaderField: "Content-Type") ?? "(none)", + "finalUrl": http?.url?.absoluteString ?? "(none)", + // The first line of a served error page usually names its author. + "head": String(decoding: data.prefix(120), as: UTF8.self) + .replacingOccurrences(of: "\n", with: " "), + ]) + return nil + } }