I spent a chunk of last week fighting a profile picture. I wanted Gmail's account avatar to show up in the sidebar of a native macOS menu-bar app, without it turning into a generic letter-monogram circle.
This is a writeup of that fight, because the eventual fix (WKWebView.takeSnapshot, not a network request) isn't something I'd have guessed on the first four tries, and I couldn't find it written up anywhere.
The setup
The app is a multi-account Gmail/Calendar client. Each account gets its own WKWebView backed by a per-account WKWebsiteDataStore, so cookies, service workers, and local storage never bleed between accounts. That isolation is the whole point of the app, and it's also exactly what caused this bug.
The sidebar shows a small circular avatar per account, same idea as the tab switcher inside Gmail's own UI. The obvious approach is to find the avatar <img> in the DOM, read its src, download that image natively, done.
// The avatar lives in Google's top-right account button.
static let avatarImg = "img.gbii, img.gb_V"
The src on that <img> is a URL under /ogw/, which is Google's photo-serving endpoint. Grab it, URLSession.shared.data(from:), cache it to disk. Should be a ten-minute feature.
It was not.
Attempt 1: fetch the /ogw/ URL with URLSession
Downloading that URL from outside the webview, plain URLSession with no special headers, returns a generated letter monogram instead of the user's actual photo. Every time, for every account, consistently. It isn't a caching issue and it isn't a timing issue. The endpoint appears to key the response on request context (cookies and session) that only exists inside the page's own live browsing session. A native process making the same GET, cookies or no cookies, gets the placeholder.
Why it failed. The URL is not a stable resource identifier the way a normal CDN image URL is. It's session-scoped. Whatever proves to Google's servers that "this request came from an authenticated Gmail tab" isn't reproducible from outside the WKWebView's own network stack.
Attempt 2: fetch() from inside the page
Fine. If it has to come from inside the session, do it from inside the session. Inject a script and fetch() the same URL from the page's own JS context, where the cookies definitely are attached correctly.
fetch(imgSrc).then(r => r.blob())...
Blocked. Gmail's Content-Security-Policy doesn't allow the page's own script to fetch that resource in a way that hands the bytes back to us. CSP restrictions on connect-src and img-src handling stop the request from completing in a form we can read.
Why it failed. CSP isn't scoped to "who set the cookies," it's scoped to "what is this document allowed to talk to and read." Being in the right session doesn't help if the policy still says no.
Attempt 3: draw it to a <canvas> and read pixels back
There's a common workaround for "I have an <img> I can see rendered but can't fetch()". Draw it into a hidden <canvas> and call toDataURL() or getImageData() on the canvas instead.
const canvas = document.createElement('canvas');
canvas.getContext('2d').drawImage(imgEl, 0, 0);
canvas.toDataURL(); // throws
This throws a security error. The image element is cross-origin relative to the canvas's origin, and without a CORS header on the /ogw/ response granting the canvas read access, the canvas becomes "tainted" the instant you draw a cross-origin image into it. You can still draw it, you just can't read the pixels back out.
Why it failed. The <img> tag doesn't need CORS to display an image, but reading its pixels back through canvas does, and Google's avatar endpoint doesn't send the header that would allow that.
Attempt 4: scrape a different URL, try harder
My instinct at this point was to look for some other way to get bytes. A different-sized variant of the same image, a base64 copy somewhere in the page's initial JSON payload, a service-worker cache entry. All of these either don't exist in a stable form or hit the same CORS and CSP wall one layer down. The pixel data for that endpoint simply isn't exposed to script in a readable form, by anyone but the page's own renderer.
Three failures, three different mechanisms (session-scoped URL, CSP, CORS tainting), one root cause. The photo is only real inside a rendered, authenticated WKWebView, and nothing that treats it as "a URL to fetch" works, because it isn't being served as a fetchable resource to anyone but the page itself.
The fix: don't fetch it, screenshot it
Here's the reframe. We don't actually need the file. We need the pixels the user is already looking at. WKWebView can already render those pixels correctly, and that's the one context where the real photo shows up reliably. So instead of trying to get bytes out of a URL, snapshot the rendered element directly.
WKWebView.takeSnapshot(configuration:) renders a rect of the already-loaded, already-authenticated page to an NSImage. No network request, no CORS, no CSP. It's not making the browser fetch anything, it's just rasterizing what's already on screen.
let js = #"""
var img = document.querySelector('\#(GmailDOM.avatarImg)');
if (!img) return '';
var r = img.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return '';
return JSON.stringify({ x: r.left, y: r.top, w: r.width, h: r.height });
"""#
let config = WKSnapshotConfiguration()
config.rect = CGRect(x: x, y: y, width: w, height: h)
config.snapshotWidth = NSNumber(value: 128)
let snapshot = try await webView.takeSnapshot(configuration: config)
First, JS reads the avatar <img>'s getBoundingClientRect() so we know exactly which rectangle on screen to capture. Then takeSnapshot captures just that rect, scaled to 128px wide, as a plain NSImage.
That got us a real photo. It also got us a square image with white corners, because Google's avatar is a circle sitting on a white square backing and a snapshot captures the whole square. Sidebar circles with white corner artifacts look broken. So we clip to a circle in CoreGraphics, with a small inset to eat the anti-aliased fringe at the circle's edge.
private static func circularPNG(from image: NSImage, side: Int = 128, inset: CGFloat = 1.5) -> Data? {
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return nil }
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return nil }
NSGraphicsContext.current = ctx
let s = CGFloat(side)
let full = NSRect(x: 0, y: 0, width: s, height: s)
ctx.cgContext.clear(full)
NSBezierPath(ovalIn: full.insetBy(dx: inset, dy: inset)).addClip()
image.draw(in: full, from: .zero, operation: .sourceOver, fraction: 1.0)
ctx.flushGraphics()
return rep.representation(using: .png, properties: [:])
}
The explicit NSBitmapImageRep and NSGraphicsContext bitmap context matters here. NSImage.lockFocus(), the usual shortcut for "draw into an image," depends on ambient window and graphics state that isn't reliably present when the source webview is backgrounded or hidden, which is common for prewarmed accounts that aren't the currently active tab. Building the context explicitly makes it work off-screen too.
One more wrinkle is worth naming. A snapshot taken before the avatar has actually painted comes back as a flat white circle rather than an error. takeSnapshot succeeds, it just captures nothing useful. We sample a grid of pixels and reject anything that's over 96% one uniform color, then retry. Without that check, backgrounded accounts occasionally got a blank white dot persisted as their permanent avatar.
The result is written to a per-account PNG on disk and the sidebar reads it back with NSImage(contentsOfFile:). No cookies, no URL, no network dependency at read time, ever again.
The general lesson
When something is visibly rendering correctly in a browser context but every attempt to extract it as data keeps failing for a different reason each time (auth-scoped URL, then CSP, then CORS), that pattern itself is informative. The constraints aren't independent bugs to route around. They're converging on "this isn't meant to be fetched by anyone but the renderer." At that point, stop asking "how do I get the bytes" and ask "can I use the renderer's own screenshot API instead." For WKWebView specifically, takeSnapshot sits outside the entire fetch, CORS and CSP stack because it isn't a network operation. It's asking the engine to rasterize what it already drew.
I built this into Orbit, a native multi-account Gmail/Calendar client for Mac where each account gets its own isolated WebKit session, so signing in to one account never signs another one out. But the snapshot-instead-of-fetch trick applies to any WKWebView-based app trying to pull an image out of a page that doesn't want to hand it over as a URL.
Top comments (0)