mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
11 KiB
Swift
268 lines
11 KiB
Swift
import Foundation
|
|
import ImageIO
|
|
import Testing
|
|
|
|
@testable import BuzzPushKit
|
|
|
|
@Suite("Native notification avatar decoding")
|
|
struct BuzzNotificationAvatarLoaderTests {
|
|
@Test("Avatar proofs preserve IPv6 brackets and explicit ports")
|
|
func signedServerAuthority() throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
for authority in ["[::1]:3000", "[2001:db8::1]", "relay.example:443", "relay.example"] {
|
|
let url = try #require(URL(string: "https://\(authority)/media/avatar.png"))
|
|
let header = try BuzzNotificationAvatarLoader.authorization(url: url, key: key, account: account)
|
|
let data = try #require(Data(base64Encoded: String(header.dropFirst(6))))
|
|
let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: data)
|
|
#expect(event.tags.contains(["server", authority]))
|
|
#expect(event.kind == 24242)
|
|
#expect(event.hasValidIDAndSignature())
|
|
}
|
|
}
|
|
|
|
@Test("Relay media uses canonical hosts and default ports without trusting other origins")
|
|
func mediaOrigins() throws {
|
|
let hash = String(repeating: "a", count: 64)
|
|
for (relay, media, expected) in [
|
|
("wss://relay.example:443", "https://relay.example", true),
|
|
("wss://relay.example", "https://RELAY.EXAMPLE:443", true),
|
|
("ws://relay.example:80", "http://RELAY.EXAMPLE", true),
|
|
("wss://relay.example:8443", "https://relay.example:8443", true),
|
|
("wss://relay.example", "https://relay.example:8443", false),
|
|
("wss://relay.example", "http://relay.example", false),
|
|
("wss://relay.example", "https://other.example", false),
|
|
] {
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: relay, pubkey: "account", policies: [],
|
|
notificationSessionID: "session")))
|
|
let url = try #require(URL(string: "\(media)/media/\(hash).png"))
|
|
#expect(
|
|
BuzzNotificationAvatarLoader.thumbnailURLs(url, scope: scope).map(\.lastPathComponent)
|
|
== (expected ? ["\(hash).thumb.webp", "\(hash).thumb.jpg"] : []))
|
|
}
|
|
}
|
|
|
|
@Test("Animated avatar selects the poster without fetching the animation")
|
|
func animatedPoster() throws {
|
|
let source =
|
|
"https://images.example/poster.png#buzz-anim=https%3A%2F%2Fimages.example%2Fanimation.apng"
|
|
#expect(
|
|
BuzzNotificationAvatarLoader.posterURL(source)?.absoluteString
|
|
== "https://images.example/poster.png")
|
|
#expect(BuzzNotificationAvatarLoader.posterURL("file:///private/avatar.png") == nil)
|
|
}
|
|
|
|
@Test("Raster decoder produces a bounded PNG and rejects non-image data")
|
|
func raster() throws {
|
|
let bytes = try #require(
|
|
Data(
|
|
base64Encoded:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
|
|
))
|
|
let png = try BuzzNotificationAvatarLoader.decode(bytes)
|
|
let source = try #require(CGImageSourceCreateWithData(png as CFData, nil))
|
|
let properties = try #require(
|
|
CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
|
|
#expect(properties[kCGImagePropertyPixelWidth] as? Int == 1)
|
|
#expect(properties[kCGImagePropertyPixelHeight] as? Int == 1)
|
|
#expect(png.count <= BuzzNotificationCache.maximumAvatarBytes)
|
|
#expect(throws: BuzzNotificationCacheError.self) {
|
|
try BuzzNotificationAvatarLoader.decode(Data("not an image".utf8))
|
|
}
|
|
}
|
|
|
|
@Test("A scope revoked while the assertion is issued never starts the signed download")
|
|
func scopeRevokedDuringAssertionIssuance() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let state = AvatarScopeState()
|
|
AssertionIssuanceStub.onRequest = { state.revoke() }
|
|
defer { AssertionIssuanceStub.onRequest = nil }
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration),
|
|
loadSession: {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: "session-credential",
|
|
relayHosts: ["relay.org.example"])
|
|
})
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in state.isCurrent },
|
|
assertionIssuer: issuer)
|
|
loader.startDownload = { _, _, _, completion in
|
|
state.recordDownload()
|
|
completion(.failure(URLError(.cancelled)))
|
|
}
|
|
|
|
let result = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
|
|
Issue.record("Expected a revoked failure, got \(result)")
|
|
return
|
|
}
|
|
#expect(state.downloads == 0)
|
|
}
|
|
|
|
@Test("An organization relay avatar is fetched with the issued assertion")
|
|
func relayAvatarCarriesAssertion() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration),
|
|
loadSession: {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: "session-credential",
|
|
relayHosts: ["relay.org.example"])
|
|
})
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
|
|
assertionIssuer: issuer)
|
|
let requests = AvatarRequests()
|
|
loader.startDownload = { request, _, _, completion in
|
|
requests.record(request)
|
|
completion(.failure(URLError(.cancelled)))
|
|
}
|
|
|
|
_ = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
let request = try #require(requests.first)
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Nostr-Federated-Identity")
|
|
== "Bearer header.payload.signature")
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
|
|
}
|
|
|
|
@Test("An avatar fetched with an assertion from a replaced organization session is refused")
|
|
func replacedOrganizationSessionRefusesAvatar() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let sessions = OrganizationSessions()
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration), loadSession: { sessions.current })
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
|
|
assertionIssuer: issuer)
|
|
let png = try #require(
|
|
Data(
|
|
base64Encoded:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
|
|
))
|
|
loader.startDownload = { _, _, _, completion in
|
|
// The member signs in again while the avatar is in flight; the community is unchanged.
|
|
sessions.replace()
|
|
completion(.success(png))
|
|
}
|
|
|
|
let result = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
|
|
Issue.record("Expected a revoked failure, got \(result)")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class AvatarRequests: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var requests: [URLRequest] = []
|
|
|
|
var first: URLRequest? { lock.withLock { requests.first } }
|
|
func record(_ request: URLRequest) { lock.withLock { requests.append(request) } }
|
|
}
|
|
|
|
private final class OrganizationSessions: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var credential = "session-credential"
|
|
|
|
var current: BuzzOrganizationSession {
|
|
lock.withLock {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: credential,
|
|
relayHosts: ["relay.org.example"])
|
|
}
|
|
}
|
|
func replace() { lock.withLock { credential = "replacement-credential" } }
|
|
}
|
|
|
|
private final class AvatarScopeState: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var current = true
|
|
private var started = 0
|
|
|
|
var isCurrent: Bool { lock.withLock { current } }
|
|
var downloads: Int { lock.withLock { started } }
|
|
func revoke() { lock.withLock { current = false } }
|
|
func recordDownload() { lock.withLock { started += 1 } }
|
|
}
|
|
|
|
/// Answers the organization's assertion issuer, running `onRequest` while issuance is in flight.
|
|
private final class AssertionIssuanceStub: URLProtocol, @unchecked Sendable {
|
|
nonisolated(unsafe) static var onRequest: (() -> Void)?
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
Self.onRequest?()
|
|
let body = try! JSONSerialization.data(withJSONObject: [
|
|
"assertion": "header.payload.signature",
|
|
"nostr_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
"expires_at": Int(Date().timeIntervalSince1970) + 300,
|
|
])
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
client?.urlProtocol(self, didLoad: body)
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|