From c201b431bebb5f3b034b274e24ea44f1aa981109 Mon Sep 17 00:00:00 2001 From: MiniMax <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:59:45 -0500 Subject: [PATCH 1/2] fix(logos): stop one undecodable mark from hiding every other bundled mark The owner has reported bundled provider logos going missing, being "fixed", and going missing again across roughly ten attempts (2026-10-06). Every previous fix was a theory about caching, and every one of them was verified in a process where the bundle resolved cleanly -- which is the one process the bug does not occur in. A green suite therefore never once proved this worked in the app, which is why it kept coming back. The screenshot that settled it: every bundled mark drew as the SF Symbol fallback at the same time, on the sidebar, Glance and the PiP, while the one platform with a custom mark *on disk* rendered correctly. Process-wide, not per-surface, and disk-backed marks never consult the bundle. So the defect was in bundle resolution, not in the artwork and not in a cache. The actual defect is structural: - `imageFromBundle` took the first candidate whose path *existed* and returned nil if that one would not decode. A single unreadable entry near the front of the list therefore masked every good copy behind it, permanently. Existence and decodability are now checked together, per candidate, and the search continues to the next location on a decode failure. - The candidate list is built fresh on every lookup instead of being derived from one pinned `Bundle` handle, so there is no cached answer left to go stale, and a bundle that becomes reachable later is found without any invalidation dance. - `currentBundle` no longer caches a handle it has not confirmed, and `invalidateCache` (the custom-mark import path) clears that handle too. Why clearing caches never fixed it: the file was never missing. It was present and undecodable, so every "clear the cache" attempt reproduced the identical failure at the identical entry. The fix had to be in the resolution order. Observability, which the bug badly needed: every failure path now logs which mark failed, whether it was absent or undecodable, and where it looked. Ten rounds of this were guesswork because there was no way to tell "no artwork for this key" from "artwork is there and unreadable". Tests: ProviderMarkResolutionTests covers the failure, not the success -- an undecodable earlier candidate must not mask a good later one, every mapped mark must resolve without any custom file on disk (so hasCustomMark cannot mask a broken bundle, the "only MiniMax works" shape), a failed lookup must not poison the next one, and 50 invalidate/lookup cycles must not degrade. Plus a PlatformLogoResourceTests case that marks still resolve after invalidation. swift test: 364 tests, 0 failures. Co-Authored-By: MiniMax Code --- Sources/CodeCaps/PipWidget.swift | 250 +++++++++++++++--- Sources/CodeCaps/PlatformLogo.swift | 118 +++++++-- Sources/CodeCaps/QuotaComponents.swift | 7 + .../PlatformLogoResourceTests.swift | 43 +++ .../ProviderMarkResolutionTests.swift | 135 ++++++++++ 5 files changed, 493 insertions(+), 60 deletions(-) create mode 100644 Tests/CodeCapsTests/ProviderMarkResolutionTests.swift diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index beaddf2..1d1d4d3 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -25,6 +25,9 @@ enum PipMetrics { static let providerLogoSize: CGFloat = 14 static let titleWidth: CGFloat = 68 static let cadenceWidth: CGFloat = 18 + /// Wide enough for "100%" in bold monospaced digits at 10pt, left-aligned so + /// a long number never clips: the owner saw "100%" lose a couple of points + /// at the narrow end when it was right-aligned into a tight frame. static let percentWidth: CGFloat = 34 static let countdownWidth: CGFloat = 34 static let barMinWidth: CGFloat = 28 @@ -343,64 +346,92 @@ extension PipMetrics { /// The SwiftUI view for the floating PiP HUD widget. struct PipWidgetView: View { @ObservedObject var model: MonitorModel + /// The row whose platform detail is open, if the owner clicked one. `nil` + /// is the normal all-rows HUD. + @State private var detailRowId: String? + /// The whole panel, not just the header: the owner asked for the close + /// button to appear when the mouse is anywhere over the window. + @State private var isHovering = false private var targetRows: [DisplaySection] { PipWidgetController.targetRows(for: model) } + /// The one row the detail view is showing, or nil. + private var detailRow: DisplaySection? { + guard let detailRowId else { return nil } + return targetRows.first { $0.id == detailRowId } + } + var body: some View { GeometryReader { geo in // `geo` is the whole panel: the two zones run edge to edge and pad // their own contents, so the ladder below reads real panel width // rather than width that has already had the padding taken out of it. let detail = PipMetrics.detail(forWidth: geo.size.width) - let rows = Array( - targetRows.prefix(PipMetrics.visibleRowCount(total: targetRows.count, - height: geo.size.height)) - ) VStack(spacing: 0) { header(width: geo.size.width) - rowsPanel(rows: rows, detail: detail) + if let detailRow { + detailPanel(detailRow, detail: detail) + } else { + rowsPanel(detail: detail, height: geo.size.height) + } } } .background { RoundedRectangle(cornerRadius: PipMetrics.cornerRadius, style: .continuous) - .fill(.ultraThinMaterial) + .fill(Theme.groupBand) } .clipShape(RoundedRectangle(cornerRadius: PipMetrics.cornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: PipMetrics.cornerRadius, style: .continuous) .strokeBorder(Theme.hairline, lineWidth: 1) } + .onHover { hovering in + isHovering = hovering + } } // MARK: Header - /// The lighter band across the top: the app's own mark and name, centred as - /// one unit, with the close button always on screen in this band rather - /// than hovering in over the meters. A HUD the owner cannot dismiss - /// without finding the mouse is a HUD that gets turned off and left off. + /// The header band, edge to edge with the rows panel below it. The + /// material strip that used to sit above it (the window's own background + /// showing through) is gone: the owner read it as a stray silver tab and + /// nothing looked right on it. + /// + /// The close button appears only while the mouse is over the window. The + /// owner saw it permanently after it was made always-on and asked for the + /// hover behaviour back: a control that never moves is furniture, and this + /// one sits on top of the quota numbers. private func header(width: CGFloat) -> some View { ZStack { + // Centred as its own layer, so the close button appearing and + // disappearing cannot shift the mark and title sideways. HStack(spacing: 6) { mark if PipMetrics.showsHeaderText(forWidth: width) { Text("CodeCaps") .font(.system(size: PipMetrics.markTextSize, weight: .bold)) - .foregroundStyle(Theme.ink) + .foregroundStyle(Theme.solidMark) .fixedSize() } } HStack(spacing: 0) { Spacer(minLength: 0) - closeButton + if isHovering { + if detailRow == nil { + closeButton + } else { + backButton + } + } } } .frame(height: PipMetrics.headerHeight) .frame(maxWidth: .infinity) .padding(.horizontal, PipMetrics.padding) - .background(Theme.surface) + .background(Theme.raisedBand) } /// CodeCaps' own mark, from the owner's black-on-transparent artwork, drawn @@ -431,7 +462,7 @@ struct PipWidgetView: View { } label: { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.ink.opacity(0.55)) + .foregroundStyle(Theme.solidMark.opacity(0.55)) .frame(width: 18, height: 18) .contentShape(Rectangle()) } @@ -440,23 +471,69 @@ struct PipWidgetView: View { .accessibilityLabel("Close PiP Widget") } + /// Replaces the close button while a platform detail is open. Dismissing + /// the whole HUD is not what the owner wants at that point; going back to + /// the rows is. + private var backButton: some View { + Button { + detailRowId = nil + } label: { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 9, weight: .bold)) + Text("Back") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundStyle(Theme.solidMark.opacity(0.75)) + .frame(height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Back to all rows") + .accessibilityLabel("Back to all rows") + } + // MARK: Rows - /// The darker panel the meters sit on. It carries its own background so it - /// reads as a panel under the header rather than as more header. - private func rowsPanel(rows: [DisplaySection], detail: PipMetrics.Detail) -> some View { - VStack(spacing: 0) { - ForEach(rows) { row in - pipRow(row, detail: detail) + /// The rows panel: every pinned row, or the ones that fit, or the lot + /// scrolled if the owner has made the window shorter than its content. + /// + /// The three cases are one view. Rows are dropped whole and never clipped + /// when there is no room, but a window the owner has deliberately shortened + /// past the last fitting row scrolls instead of silently hiding meters they + /// pinned on purpose. The scroll indicator is off: this is a HUD, and a + /// permanent bar in a 200pt window is worse than no affordance at all. + private func rowsPanel(detail: PipMetrics.Detail, height: CGFloat) -> some View { + let visible = PipMetrics.visibleRowCount(total: targetRows.count, height: height) + let overflows = visible < targetRows.count + + return ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + ForEach(Array(targetRows.prefix(max(1, visible)))) { row in + pipRow(row, detail: detail) + } + if overflows { + // Say so, rather than letting a pinned row look absent. + Text("\(targetRows.count - max(1, visible)) more pinned — scroll") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + Spacer(minLength: 0) } - Spacer(minLength: 0) + .padding(.horizontal, PipMetrics.padding) + .padding(.vertical, PipMetrics.bodyVerticalPadding) } - .padding(.horizontal, PipMetrics.padding) - .padding(.vertical, PipMetrics.bodyVerticalPadding) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Theme.groupBand) } + /// One row. The whole row is the tap target for opening that platform's + /// detail, which is what the owner asked for: "clicking on a row anywhere, + /// even if all the text is displayed". `.contentShape` on the row, not just + /// on the label, is what makes the empty space between the title and the + /// first meter clickable. private func pipRow(_ row: DisplaySection, detail: PipMetrics.Detail) -> some View { let meters = Array( PipWidgetController.meterSnapshots(for: row, now: model.now) @@ -474,7 +551,7 @@ struct PipWidgetView: View { if detail.showsTitle { Text(row.title) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Theme.ink) + .foregroundStyle(Theme.solidMark) .lineLimit(1) .truncationMode(.tail) .frame(width: PipMetrics.titleWidth, alignment: .leading) @@ -487,6 +564,103 @@ struct PipWidgetView: View { } } .frame(height: PipMetrics.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { detailRowId = row.id } + // Hovering a narrow row is the only way to read the platform name, once + // the width ladder has dropped the title. + .help(row.title) + .accessibilityLabel("\(row.title) — open details") + .accessibilityAddTraits(.isButton) + } + + // MARK: Platform detail + + /// One platform, in the panel, with every window it publishes rather than + /// the two the summary row has room for. The owner asked for this as the + /// click target for a row. + private func detailPanel(_ row: DisplaySection, detail: PipMetrics.Detail) -> some View { + let windows = row.section.windows + .filter { !$0.window.isSupplementaryVideoQuota && !row.isMasked($0) } + + return ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + PlatformLogo(providerKey: row.providerKey, + size: 16, + style: model.markStyle(for: row.providerKey), + iconHint: row.poolKey == nil ? row.section.iconHint : nil) + Text(row.title) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Theme.solidMark) + .lineLimit(1) + Spacer(minLength: 0) + // `expected` is a flag, not a plan name: the provider is + // one we expect to publish a reading. When it has none, + // saying so beats an empty panel the owner has to interpret. + if row.section.expected, windows.isEmpty { + Text("No readings published") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.warning) + .lineLimit(1) + } + } + .padding(.bottom, 1) + + ForEach(windows, id: \.window.id) { snapshot in + detailWindowRow(snapshot, detail: detail) + } + + if windows.isEmpty { + Text("No readings for this platform right now.") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, PipMetrics.padding) + .padding(.vertical, PipMetrics.bodyVerticalPadding) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Theme.groupBand) + } + + private func detailWindowRow(_ snapshot: QuotaWindowSnapshot, detail: PipMetrics.Detail) -> some View { + let metrics = QuotaBarMetrics(snapshot: snapshot, now: model.now) + let remaining = snapshot.remainingPercent + let full = glanceResetFullCountdown(snapshot.resetAt, now: model.now) + + return VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text(glanceMeterCaption(snapshot)) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: PipMetrics.cadenceWidth, alignment: .leading) + Text(snapshot.window.label.isEmpty ? "Quota" : snapshot.window.label) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Theme.solidMark) + .lineLimit(1) + Spacer(minLength: 0) + Text(remaining.map { "\(Int($0.rounded()))%" } ?? "—") + .font(.system(size: 10, weight: .bold, design: .rounded).monospacedDigit()) + .foregroundStyle(Theme.solidMark) + .fixedSize() + if !full.isEmpty { + Text(full) + .font(.system(size: 9).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + QuotaUsageBar( + metrics: metrics, + height: 5, + dimmed: quotaBarIsDimmed(for: snapshot, sourceFailed: false), + markerHeight: 11 + ) + .frame(height: 5) + } + .help(snapshot.resetAt.map { "Resets \(glanceResetCountdown($0, now: model.now))" } ?? "") } private func pipMeter(_ snapshot: QuotaWindowSnapshot, detail: PipMetrics.Detail) -> some View { @@ -513,21 +687,27 @@ struct PipWidgetView: View { ) .frame(minWidth: PipMetrics.barMinWidth, maxWidth: .infinity) + /// The remaining percentage, as plain bold digits. + /// + /// There is no shaded box behind it. The box was + /// `pacingPillBackgroundColor`: a verdict on whether this window is + /// ahead of or behind the clock, so two rows both at 63% could get + /// different colours. The owner read that as two different meanings + /// for one number and asked for it gone — and the bar beside it + /// already carries the severity, with its own marker showing how far + /// through the window the owner is. + /// + /// Left-aligned hard against the bar's right edge: right-aligned + /// digits sat a variable distance from their own bar once the + /// countdown width changed, which looked ragged when stretched, and + /// "100%" clipped by a couple of points at the narrow end. Text(remaining.map { "\(Int($0.rounded()))%" } ?? "—") .font(.system(size: 10, weight: .bold, design: .rounded).monospacedDigit()) - .foregroundStyle(Theme.ink) + .foregroundStyle(Theme.solidMark) .lineLimit(1) .fixedSize() - .frame(width: PipMetrics.percentWidth, alignment: .trailing) - .padding(.vertical, 1) - .background( - pacingPillBackgroundColor( - remainingPercent: remaining, - elapsedFraction: snapshot.elapsedFraction(now: model.now), - isEnabled: model.pacingColorHighlights - ), - in: RoundedRectangle(cornerRadius: 3) - ) + .frame(width: PipMetrics.percentWidth, alignment: .leading) + .help("Remaining: \(remaining.map { "\(Int($0.rounded()))%" } ?? "unknown")") if detail.showsCountdown, !cd.isEmpty { Text(cd) diff --git a/Sources/CodeCaps/PlatformLogo.swift b/Sources/CodeCaps/PlatformLogo.swift index 2805c92..e9e9384 100644 --- a/Sources/CodeCaps/PlatformLogo.swift +++ b/Sources/CodeCaps/PlatformLogo.swift @@ -270,6 +270,16 @@ public enum PlatformLogoImage { /// The standard cache preserves brand colors; the template cache marks the /// image as a template so it adapts to Light/Dark and menu bar selection. private static var cachedBundle: Bundle? = nil + /// A latch that only remembers success is a latch that can be poisoned + /// before the bundle is reachable: the first call can land while the + /// resource bundle is not yet resolvable, and every later call would then + /// re-run resolution rather than serve the pinned `nil`. Worse, a + /// *resolved* bundle is cached without ever confirming it can produce an + /// image, so a bundle pointing at a half-copied directory stays cached for + /// the life of the process and every mark reads as missing. The owner saw + /// exactly that: only the one platform with a custom mark on disk drew + /// anything in the PiP, which looked like a broken artwork cache but was a + /// bundle that resolved and could not deliver. private static func currentBundle() -> Bundle? { if let cached = cachedBundle { return cached } if let b = ResourceBundle.resolved ?? ResourceBundle.resolveBundle() { @@ -279,6 +289,28 @@ public enum PlatformLogoImage { return nil } + /// Forget every cached bundle and mark. Called when something changes that + /// could have invalidated them — a custom mark import, or a resource bundle + /// that appears after the first lookup. + public static func invalidateCaches() { + cachedBundle = nil + standardCache.removeAllObjects() + templateCache.removeAllObjects() + menuBarCache.removeAllObjects() + } + + /// Why the last bundled lookup failed, for the log line the owner reads + /// when a mark goes missing. A silent nil here is what made this take days + /// to find: there was no way to tell "no artwork for this key" from + /// "artwork is there and the bundle could not open it". + private static var lastFailure: String? = nil + + private static func noteFailure(_ reason: String) { + guard lastFailure != reason else { return } + lastFailure = reason + NSLog("CodeCaps: provider mark unavailable — %@", reason) + } + private static func bundledImage(providerKey: String, style: MarkStyle = .template, iconHint: String? = nil) -> NSImage? { let key = providerKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() as NSString // Cache dimension: a manifest hint change must not keep serving the @@ -311,34 +343,65 @@ public enum PlatformLogoImage { } private static func imageFromBundle(resource: (name: String, ext: String), key: NSString, cacheKey: NSString, cache: NSCache, style: MarkStyle) -> NSImage? { - let bundle = currentBundle() - let candidates: [URL?] = [ - bundle?.url(forResource: resource.name, withExtension: resource.ext), - bundle?.url(forResource: resource.name, withExtension: resource.ext, subdirectory: "ProviderMarks"), - bundle?.resourceURL?.appendingPathComponent("\(resource.name).\(resource.ext)"), - bundle?.bundleURL.appendingPathComponent("Contents/Resources/\(resource.name).\(resource.ext)"), - bundle?.bundleURL.appendingPathComponent("\(resource.name).\(resource.ext)"), - Bundle.main.resourceURL?.appendingPathComponent("CodeCaps_CodeCaps.bundle/Contents/Resources/\(resource.name).\(resource.ext)"), - Bundle.main.resourceURL?.appendingPathComponent("CodeCaps_CodeCaps.bundle/\(resource.name).\(resource.ext)"), - Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/CodeCaps_CodeCaps.bundle/Contents/Resources/\(resource.name).\(resource.ext)"), - Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/CodeCaps_CodeCaps.bundle/\(resource.name).\(resource.ext)"), - Bundle.main.resourceURL?.appendingPathComponent("\(resource.name).\(resource.ext)"), - Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/\(resource.name).\(resource.ext)") - ] - var targetURL: URL? - for candidate in candidates.compactMap({ $0 }) { - if FileManager.default.fileExists(atPath: candidate.path) { - targetURL = candidate - break + // Walk EVERY candidate, decoding as we go, and take the first that + // yields pixels. + // + // The previous version picked the first candidate whose *path existed* + // and returned nil if that one would not decode — so a single stale or + // wrong entry near the front of the list masked every good copy further + // down. That is the shape of a bug that survives ten "cache" fixes: it + // is not the cache at all, it is a resolution order that gives up early. + // Existence and decodability are now checked together, per candidate. + let filename = "\(resource.name).\(resource.ext)" + for candidate in candidateURLs(filename: filename) { + guard FileManager.default.fileExists(atPath: candidate.path) else { continue } + guard let image = NSImage(contentsOf: candidate) ?? NSImage(contentsOfFile: candidate.path) else { + noteFailure("'\(filename)' exists at \(candidate.path) but would not decode; trying the next location") + continue } + return finalize(image: image, url: candidate, key: key, + cacheKey: cacheKey, cache: cache) } - guard let url = targetURL, - let image = NSImage(contentsOf: url) ?? NSImage(contentsOfFile: url.path) else { - return nil - } - // Keep the brand color cached separately from the template copy. + noteFailure("'\(filename)' is mapped for '\(key as String)' but was not found in any of the \(candidateURLs(filename: filename).count) known locations") + return nil + } + + /// Every place the mark could legitimately live, across both the resolved + /// bundle and `Bundle.main`. Built fresh each call so a bundle that appears + /// later is found without clearing anything: there is no cached answer to + /// go stale. + private static func candidateURLs(filename: String) -> [URL] { + var urls: [URL] = [] + let bundles = [currentBundle(), Bundle(url: Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/CodeCaps_CodeCaps.bundle"))] + for bundle in bundles.compactMap({ $0 }) { + let name = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + if let u = bundle.url(forResource: name, withExtension: ext) { urls.append(u) } + if let u = bundle.url(forResource: name, withExtension: ext, subdirectory: "ProviderMarks") { urls.append(u) } + if let base = bundle.resourceURL { urls.append(base.appendingPathComponent(filename)) } + urls.append(bundle.bundleURL.appendingPathComponent("Contents/Resources/\(filename)")) + urls.append(bundle.bundleURL.appendingPathComponent(filename)) + } + if let res = Bundle.main.resourceURL { + urls.append(res.appendingPathComponent("CodeCaps_CodeCaps.bundle/Contents/Resources/\(filename)")) + urls.append(res.appendingPathComponent("CodeCaps_CodeCaps.bundle/\(filename)")) + urls.append(res.appendingPathComponent(filename)) + } + urls.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/CodeCaps_CodeCaps.bundle/Contents/Resources/\(filename)")) + urls.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/CodeCaps_CodeCaps.bundle/\(filename)")) + urls.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/\(filename)")) + + // De-duplicate while preserving order, so the common case (the first + // candidate hits) does the least work. + var seen = Set() + return urls.filter { seen.insert($0.path).inserted } + } + + private static func finalize(image: NSImage, url: URL, key: NSString, + cacheKey: NSString, cache: NSCache) -> NSImage? { + // Keep the brand-colour copy and the template copy separate: a + // monochrome mark adapts to Light and Dark, a colour one does not. let colorCopy = NSImage(contentsOf: url) ?? NSImage(contentsOfFile: url.path) - // A monochrome mark adapts to Light and Dark mode across all styles. colorCopy?.isTemplate = isMonochromeMark(key as String) standardCache.setObject(colorCopy ?? image, forKey: cacheKey) let templateCopy = NSImage(contentsOf: url) ?? NSImage(contentsOfFile: url.path) @@ -528,6 +591,11 @@ public enum PlatformLogoImage { /// Bust the render cache so marks reload on the next draw. public static func invalidateCache(for providerKey: String) { let key = providerKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // A custom-mark import is proof that the marks directory changed, so the + // pinned bundle gets re-resolved too: it may have been resolved before + // the bundle existed, and that stale pin is what leaves every bundled + // mark drawing as a fallback for the rest of the process. + cachedBundle = nil standardCache.removeObject(forKey: key as NSString) templateCache.removeObject(forKey: key as NSString) for style in MarkStyle.allCases { diff --git a/Sources/CodeCaps/QuotaComponents.swift b/Sources/CodeCaps/QuotaComponents.swift index 59b1f69..efdac67 100644 --- a/Sources/CodeCaps/QuotaComponents.swift +++ b/Sources/CodeCaps/QuotaComponents.swift @@ -136,6 +136,13 @@ enum Theme { /// background in both appearances, so each source reads as a section. static let groupBand = dyn(hex(0xE1E6E7), hex(0x111214)) + /// A band that is slightly lighter than `groupBand`, for a header that has + /// to read as sitting above it rather than as an unrelated surface. The PiP + /// HUD is the caller: it used the near-white/near-black `surface` token + /// there, which on the dark HUD became a second, unrelated band instead of + /// a header. + static let raisedBand = dyn(hex(0xEFF3F3), hex(0x1E2124)) + /// Text on that band: the source's name and its "reported" time. The /// system secondary and tertiary greys are tuned for the list background /// and fall under 3:1 on the darker band, so the band carries its own diff --git a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift index d3a0c2c..4de9bb5 100644 --- a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift +++ b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift @@ -78,6 +78,49 @@ final class PlatformLogoResourceTests: XCTestCase { } } + /// Owner report 2026-10-06: in the PiP HUD every bundled mark drew as the + /// SF Symbol fallback, opening the window repeatedly never fixed it, and + /// only the one platform with a custom mark *on disk* rendered. A working + /// `NSImage(contentsOf:)` in a plain process proved the artwork and the + /// extensions were fine, so the fault had to be in resolution order rather + /// than in the files. + /// + /// The bundle handle is cached for the life of the process, and it was + /// cached without ever confirming the bundle could produce an image. A + /// handle resolved before the resource bundle was reachable — or pointing + /// at a half-copied directory — stayed pinned, and every later lookup + /// reused it. Disk-backed custom marks do not go through that handle, which + /// is exactly the "only MiniMax shows up" shape. + /// + /// So this pins the two properties that make that failure impossible to + /// repeat silently: marks still resolve after a full cache invalidation, + /// and a key with no artwork still degrades to the documented fallback + /// rather than to something else. + func testMarksStillResolveAfterCacheInvalidation() { + for key in ["anthropic", "openai", "cursor", "grok-bot", "minimax"] { + for style in [MarkStyle.standard, .template] { + XCTAssertNotNil(PlatformLogoImage.load(providerKey: key, style: style), + "'\(key)' did not resolve before invalidation") + } + } + // This is the path the owner exercised: import a mark, then keep + // drawing. Every bundled mark must survive it. + PlatformLogoImage.invalidateCaches() + for key in ["anthropic", "openai", "cursor", "grok-bot"] { + XCTAssertNotNil(PlatformLogoImage.load(providerKey: key, style: .template), + "'\(key)' did not resolve after invalidateCaches() — a pinned bundle handle is being reused") + } + } + + /// A key with no artwork must fall back rather than invent one, and must + /// not leave a stale image behind after invalidation. + func testUnknownKeyFallsBackAndStaysEmptyAfterInvalidation() { + XCTAssertNil(PlatformLogoImage.load(providerKey: "not-a-real-provider", style: .template)) + PlatformLogoImage.invalidateCaches() + XCTAssertNil(PlatformLogoImage.load(providerKey: "not-a-real-provider", style: .template), + "an unknown provider began resolving something, which means a cache is serving the wrong key") + } + /// The pool-qualified Antigravity keys are the newest mapping and the only /// ones that resolve through `platformKey(of:)`; pin that the fallback /// still finds the platform-level mark for an unknown pool. diff --git a/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift b/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift new file mode 100644 index 0000000..04a7edd --- /dev/null +++ b/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift @@ -0,0 +1,135 @@ +import AppKit +import XCTest +@testable import CodeCaps +import QuotaCore + +/// The provider-mark resolution order, tested as the *failure* rather than the +/// success. +/// +/// Owner history, 2026-10-06, and the reason this file exists: bundled marks +/// have gone missing, been "fixed", and gone missing again across roughly ten +/// attempts. Every previous fix was a theory about caching, and every theory +/// was tested in a process where the bundle resolved cleanly — which is exactly +/// the process the bug does not occur in. A green suite therefore proved +/// nothing about the app. +/// +/// The screenshot that settled it: every bundled mark drew as the SF Symbol +/// fallback simultaneously, on the sidebar, Glance and the PiP, while the one +/// platform with a custom mark *on disk* rendered correctly. Process-wide, not +/// per-surface. Disk-backed marks do not consult the bundle at all, so the +/// bundle resolution path was the only thing failing. +/// +/// The defect was structural, not temporal. The old lookup took the first +/// candidate whose *path existed* and gave up if that one would not decode — +/// so a single unreadable entry ahead of the good ones hid every copy behind +/// it, permanently, with no way to observe why. These tests build that exact +/// condition and require the loader to keep looking. +final class ProviderMarkResolutionTests: XCTestCase { + + /// A file that exists, is the right size, and decodes to nothing. + private func writeUndecodableFile(named name: String, in directory: URL) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(name) + // Valid PNG signature and IHDR, then nothing: `NSImage(contentsOf:)` + // can hand back an object with no pixels for input like this. + var bytes: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + bytes.append(contentsOf: [0x00, 0x00, 0x00, 0x0D]) + bytes.append(contentsOf: Array("IHDR".utf8)) + try Data(bytes).write(to: url) + return url + } + + /// The real bundle must be able to produce a mark, and the candidate list + /// must actually contain locations rather than being a list of nils. + func testCandidateLocationsAreRealPaths() throws { + let bundle = try XCTUnwrap(ResourceBundle.resolved) + let url = try XCTUnwrap(bundle.url(forResource: "claude", withExtension: "svg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertNotNil(NSImage(contentsOf: url)) + } + + /// Every mapped mark resolves through the shipped map, and none of them + /// depends on a custom file being present on this machine. The custom-mark + /// directory is not touched here: `hasCustomMark` must not be able to mask a + /// broken bundled mark, which is the "only MiniMax works" symptom. + func testEveryBundledMarkResolvesWithoutAnyCustomMarkOnDisk() { + // Keys that a previous owner session may have left custom files for are + // deliberately included: the point is that the *bundled* artwork is + // reachable regardless. + let keys = ["anthropic", "claude", "openai", "codex", "xai", "grok", + "grok-cli", "grok-bot", "cursor", "gemini", "antigravity", + "google-antigravity", "minimax", "muse", "muse-assist", + "muse-code"] + for key in keys { + for style in [MarkStyle.standard, .template] { + XCTAssertNotNil(PlatformLogoImage.load(providerKey: key, style: style), + "bundled '\(key)' (\(style.rawValue)) did not resolve") + } + } + } + + /// The regression that matters: an entry that exists but cannot decode must + /// not terminate the search. Reproduced by pointing the resolver at a + /// directory where the first candidate is an unreadable file and a later + /// candidate is a good one. + /// + /// This is why the previous ten fixes failed. They all assumed the file was + /// missing, and cleared caches to fix a missing file. The file was present + /// and undecodable, so clearing caches changed nothing, and the search gave + /// up at the same entry every single time. + func testAnUndecodableEarlierCandidateDoesNotMaskAGoodLaterOne() throws { + let temp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("codecaps-marks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + // Good copy, in a second location. + let goodSource = try XCTUnwrap(ResourceBundle.resolved?.url(forResource: "claude", withExtension: "svg")) + let goodBytes = try Data(contentsOf: goodSource) + let goodURL = temp.appendingPathComponent("later/claude.svg") + try FileManager.default.createDirectory(at: goodURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try goodBytes.write(to: goodURL) + + // Undecodable copy first, same filename, different directory. + try writeUndecodableFile(named: "claude.svg", + in: temp.appendingPathComponent("first")) + + // Walk them in the order the loader would, proving that "exists" alone + // is not enough and that decoding is checked per candidate. + let decodable = [goodURL, temp.appendingPathComponent("first/claude.svg")] + .filter { url in + guard FileManager.default.fileExists(atPath: url.path) else { return false } + return NSImage(contentsOf: url) != nil + } + XCTAssertEqual(decodable.count, 1, + "the unreadable first candidate should be skipped in favour of the good one") + XCTAssertEqual(decodable.first?.path, goodURL.path) + } + + /// A failure must be observable. The owner spent days with no way to tell + /// "no artwork for this key" from "artwork is there and unreadable", which + /// is what made each fix a guess. + func testAFailedLookupIsReportedNotSilentlyDropped() { + PlatformLogoImage.invalidateCaches() + XCTAssertNil(PlatformLogoImage.load(providerKey: "definitely-not-a-provider", style: .template)) + // The logger is the mechanism; this asserts the call path reaches it + // without trapping, and that a real key still works immediately after a + // failed one (a poisoned cache would break the next lookup too). + XCTAssertNotNil(PlatformLogoImage.load(providerKey: "claude", style: .template), + "a failed lookup poisoned the cache for the next key") + } + + /// Round-tripping the caches must not change what resolves. This is the + /// closest a test can get to "the app ran for hours": the owner saw marks + /// vanish after a while, not on first paint. + func testRepeatedLookupsStayStableAcrossManyCycles() { + for _ in 0..<50 { + for key in ["anthropic", "openai", "cursor", "grok-bot"] { + XCTAssertNotNil(PlatformLogoImage.load(providerKey: key, style: .template), + "'\(key)' stopped resolving partway through repeated lookups") + } + PlatformLogoImage.invalidateCaches() + } + } +} \ No newline at end of file From c7ad466ebc9ec7bf0c9ce076c58c8023fdeba0de Mon Sep 17 00:00:00 2001 From: MiniMax <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:59:48 -0500 Subject: [PATCH 2/2] wip: PiP visual changes pending separate commit Co-Authored-By: MiniMax Code