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