Skip to content

Commit e4ec830

Browse files
committed
feat(avatar-cache): cache author avatar bytes to bypass Brave Shields
Brave's Shields bypasses HTTP caching for cross-origin requests from extension pages, so <img> tags pointing at avatars.githubusercontent.com re-download on every popup open. Cache the decoded bytes as base64 data URLs in chrome.storage.local so popups render with no network and no flicker. Design: - Single writer (background), popup is read-only via loadSnapshot() - Freshness: URL match + 7d TTL + If-Modified-Since revalidation - LRU eviction bounded by both entry count (100) and total bytes (5 MB) - Generation counter gates cross-account logout/re-login races - Concurrency capped at 5 in-flight fetches Bumps minimum_chrome_version to 114 so the 5 MB avatar cap fits within storage.local's 10 MB quota (Chrome <114 was 5 MB total).
1 parent 615b810 commit e4ec830

19 files changed

Lines changed: 813 additions & 13 deletions

manifest-firefox.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
"version": "1.3.1",
55
"description": "GitHub notifications in the browser toolbar",
66
"permissions": ["alarms", "storage", "notifications"],
7-
"host_permissions": ["https://api.github.com/*", "https://github.com/*"],
7+
"host_permissions": [
8+
"https://api.github.com/*",
9+
"https://github.com/*",
10+
"https://avatars.githubusercontent.com/*"
11+
],
812
"content_security_policy": {
913
"extension_pages": "script-src 'self'; object-src 'self'"
1014
},

manifest.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"name": "GitHub Notifier Pro",
44
"version": "1.3.1",
55
"description": "GitHub notifications in the browser toolbar",
6-
"minimum_chrome_version": "99",
6+
"minimum_chrome_version": "114",
77
"permissions": ["alarms", "storage", "notifications"],
8-
"host_permissions": ["https://api.github.com/*", "https://github.com/*"],
8+
"host_permissions": [
9+
"https://api.github.com/*",
10+
"https://github.com/*",
11+
"https://avatars.githubusercontent.com/*"
12+
],
913
"content_security_policy": {
1014
"extension_pages": "script-src 'self'; object-src 'self'"
1115
},

src/background/notification-fetcher.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "../lib/constants.js";
2929
import { LRUCache, DEFAULT_LRU_CACHE_SIZE } from "../lib/lru-cache.js";
3030
import { applyRulesWithStats, isVisible } from "../lib/filter-rules.js";
31+
import { ensureAvatarsCached } from "../lib/avatar-cache.js";
3132
import { showDesktopNotificationsForNew } from "./desktop-notifications.js";
3233

3334
const SESSION_KEY_COMMENT_CACHE = "latestCommentUrlCache";
@@ -209,6 +210,19 @@ export function createNotificationFetcher(deps) {
209210
}
210211
}
211212

213+
// ─── Avatar bytes warmup ──────────────────────────────────────────────
214+
// Fire-and-forget — the avatar-cache module skips entries within its TTL,
215+
// deduplicates by login, and bounds concurrency internally.
216+
function warmAuthorAvatars(notifications) {
217+
const authors = [];
218+
for (const n of notifications) {
219+
if (n?.author?.login && n.author.avatar_url) authors.push(n.author);
220+
}
221+
if (authors.length > 0) {
222+
ensureAvatarsCached(authors).catch(() => {});
223+
}
224+
}
225+
212226
// ─── Detail fetch helpers ─────────────────────────────────────────────
213227
async function fetchWithConcurrencyLimit(tasks, limit = 5) {
214228
const results = [];
@@ -461,6 +475,7 @@ export function createNotificationFetcher(deps) {
461475
console.log(
462476
`Fetch #${currentFetchVersion} saved ${priorityNotifications.length} priority notifications`,
463477
);
478+
warmAuthorAvatars(detailedNotifications);
464479
}
465480
}
466481

@@ -502,6 +517,7 @@ export function createNotificationFetcher(deps) {
502517
console.log(
503518
`Fetch #${currentFetchVersion} updated storage with detailed notifications`,
504519
);
520+
warmAuthorAvatars(detailedNotifications);
505521
prefetchLatestCommentUrls(detailedNotifications).catch((error) => {
506522
console.error("Error prefetching latest comment URLs:", error);
507523
});

src/background/service-worker.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "../lib/constants.js";
1414
import { classifyError } from "../lib/http.js";
1515
import { buildNotificationUrl } from "../lib/url-builder.js";
16+
import { ensureAvatarsCached, setActive as setAvatarCacheActive } from "../lib/avatar-cache.js";
1617
import {
1718
applyRulesWithStats,
1819
isVisible,
@@ -121,6 +122,7 @@ async function doInitialize() {
121122
const token = await storage.getToken();
122123
if (token) {
123124
github.token = token;
125+
setAvatarCacheActive(true);
124126
const username = await storage.getUsername();
125127
if (username) {
126128
github.username = username;
@@ -131,9 +133,30 @@ async function doInitialize() {
131133
// recycling). No-op on Firefox or when session storage has no saved data.
132134
await fetcher.restoreCommentCache();
133135

136+
// Warm the avatar data-URL cache for the signed-in user and any
137+
// notification authors already on disk. Fire-and-forget; the cache
138+
// module skips entries that are still TTL-fresh. Covers the case
139+
// where notifications survived a SW restart but the avatar cache
140+
// was evicted/cleared — runFetch only warms when detail fetches
141+
// run, so steady state with no updated_at changes would otherwise
142+
// leave authors uncached.
143+
Promise.all([storage.getUserInfo(), storage.getNotifications()])
144+
.then(([userInfo, notifs]) => {
145+
const people = [];
146+
if (userInfo) people.push({ login: userInfo.login, avatar_url: userInfo.avatar_url });
147+
for (const n of notifs || []) {
148+
if (n?.author) people.push(n.author);
149+
}
150+
return ensureAvatarsCached(people);
151+
})
152+
.catch((err) => {
153+
console.warn("Avatar cache warmup failed", err);
154+
});
155+
134156
await startPolling();
135157
await checkNotifications();
136158
} else {
159+
setAvatarCacheActive(false);
137160
fetcher.resetHasMore();
138161
await updateBadge(null);
139162
}
@@ -413,6 +436,16 @@ async function handleLogin(authMethod = "oauth", token = null) {
413436
await storage.setUserInfo(github.userInfo);
414437
await storage.setAuthMethod(authMethod);
415438

439+
setAvatarCacheActive(true);
440+
// Warm the avatar data-URL cache so the popup never has to refetch the
441+
// image — Brave's Shields bypass HTTP caching for cross-origin requests
442+
// and would otherwise reload the avatar on every popup open.
443+
ensureAvatarsCached([
444+
{ login: github.userInfo?.login, avatar_url: github.userInfo?.avatar_url },
445+
]).catch((err) => {
446+
console.warn("Avatar cache warmup failed", err);
447+
});
448+
416449
await startPolling();
417450
await checkNotifications();
418451

@@ -426,6 +459,11 @@ async function handleLogin(authMethod = "oauth", token = null) {
426459
}
427460

428461
async function handleLogout() {
462+
// Gate the avatar cache BEFORE clearing storage. clearAuthData() bypasses
463+
// the avatar-cache writeQueue, so a later ensureAvatarsCached() — queued
464+
// from an in-flight runFetch — would otherwise repopulate the cache with
465+
// the previous user's avatars after the wipe.
466+
setAvatarCacheActive(false);
429467
github.logout();
430468
fetcher.resetHasMore();
431469
await fetcher.clearCommentCache();

0 commit comments

Comments
 (0)