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.
99 lines
3.6 KiB
Swift
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)
|
|
}
|
|
}
|