Files
Outpost/Outpost/Support/AvatarBadge.swift
T
Puranjay Savar Mattas cd6277f5aa fix(avatar): attach the Bearer token — the real bug, not propagation delay
Your network capture of the working web-app request showed session
cookies (accessToken, authelia_session) on the attachments.redirect
call. This app authenticates every other request with an Authorization:
Bearer header instead — AvatarBadge's fetch never attached one, sending
a bare unauthenticated GET. Almost certainly a 401 the whole time, for
every avatar image, not just a freshly-uploaded one — a failed fetch
and "no avatar set" render identically here (placeholder icon, no
visible error), so there was nothing on screen to reveal it before now.

Uses KeychainTokenStore() directly, same keychain entry SessionStore
already reads, rather than threading a token through every AvatarBadge
call site. Kept the retry loop from the previous attempt too — genuinely
useful insurance against upload-consistency timing, just not the actual
cause here.
2026-08-15 16:44:45 +01:00

99 lines
3.6 KiB
Swift

import SwiftUI
import OutlineKit
#if os(macOS)
import AppKit
typealias PlatformImage = NSImage
#else
import UIKit
typealias PlatformImage = UIImage
#endif
/// Loads the avatar manually instead of using `AsyncImage`, and flattens the result
/// with `.drawingGroup()`. Hosted inside AppKit-backed controls (a macOS toolbar
/// item, a `Menu` label), this content was observed blowing past its `.frame`/
/// `.clipShape` constraints once the host re-measured it after the image loaded —
/// `.drawingGroup()` rasterizes it to a fixed bitmap at the constrained size first,
/// so there's nothing left for the host to re-measure.
struct AvatarBadge: View {
let avatarURL: URL?
var size: CGFloat = 22
var placeholderSystemImage: String = "person.crop.circle.fill"
@State private var loadedImage: PlatformImage?
var body: some View {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.2))
if let loadedImage {
platformImage(loadedImage)
.resizable()
.scaledToFill()
} else {
placeholderIcon
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.compositingGroup()
.drawingGroup()
.task(id: avatarURL) {
loadedImage = nil
guard let avatarURL else { return }
loadedImage = await Self.loadImage(from: avatarURL)
}
}
/// The real fix, confirmed against a live network capture: every other
/// request this app makes attaches `Authorization: Bearer <token>` —
/// this one never did, sending a bare unauthenticated GET. Outline's
/// browser session authenticates `attachments.redirect` via cookies
/// instead, which a native app doesn't have; the API-token equivalent
/// is the same Bearer header every RPC call already uses. Almost
/// certainly means no avatar image (not just a freshly-uploaded one)
/// has ever actually loaded in this app — a 401 and a "no avatar set"
/// look identical here, both just fall back to the placeholder icon
/// with nothing on screen to flag it as an error.
///
/// The retry loop is a secondary, independent hardening — cheap
/// insurance against a self-hosted reverse-proxied storage backend not
/// being instantly consistent right after an upload — kept alongside
/// the auth fix rather than instead of it.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
if let token = try? KeychainTokenStore().token() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
#else
Image(uiImage: image)
#endif
}
private var placeholderIcon: some View {
Image(systemName: placeholderSystemImage)
.font(.system(size: size * 0.5, weight: .semibold))
.foregroundStyle(Color.accentColor)
}
}