Address wallpaper review feedback: curated id validation, referrer policy, GIF, bitmap leak

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 10:24:56 +00:00
committed by GitHub
parent 0f43066137
commit ae6eb695f3
4 changed files with 53 additions and 10 deletions

View File

@@ -164,7 +164,9 @@ function AppearanceSection() {
const MAX_WALLPAPER_FILE_BYTES = 5 * 1024 * 1024;
const MAX_WALLPAPER_DIMENSION = 6000;
const ALLOWED_WALLPAPER_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
// GIFs are excluded: the upload path always re-encodes to JPEG, which would
// silently drop animation/transparency, so we don't advertise GIF support.
const ALLOWED_WALLPAPER_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
export function WallpaperSection() {
const { config, updateConfig } = useAppContext();
@@ -237,7 +239,7 @@ export function WallpaperSection() {
if (!file) return;
if (!ALLOWED_WALLPAPER_TYPES.has(file.type)) {
toast({ title: 'Unsupported image type', description: 'Use JPEG, PNG, WebP, or GIF.', variant: 'destructive' });
toast({ title: 'Unsupported image type', description: 'Use JPEG, PNG, or WebP.', variant: 'destructive' });
return;
}
if (file.size > MAX_WALLPAPER_FILE_BYTES) {
@@ -264,7 +266,10 @@ export function WallpaperSection() {
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('This browser cannot process images.');
if (!ctx) {
bitmap.close();
throw new Error('This browser cannot process images.');
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
@@ -355,7 +360,7 @@ export function WallpaperSection() {
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
aria-label="Upload a local image to use as wallpaper"
onChange={(event) => { void onFileSelected(event.target.files?.[0]); }}
@@ -382,6 +387,7 @@ export function WallpaperSection() {
<img
src={preview.url}
alt="Wallpaper preview"
referrerPolicy="no-referrer"
className={cn(
'h-24 w-full rounded-md border border-border bg-muted',
fit === 'contain' ? 'object-contain' : 'object-cover',

View File

@@ -20,7 +20,7 @@ import { swapDesktopSlots, type DesktopSlot, type GridGeometry } from '@/os/icon
import { useIconLayout } from '@/os/useIconLayout';
import { useAppContext } from '@/hooks/useAppContext';
import { useDecodedImage } from '@/hooks/useDecodedImage';
import { CURATED_WALLPAPERS, DEFAULT_CURATED_ID, isSafeWallpaperUrl, resolveCurated } from '@/lib/wallpaper';
import { CURATED_WALLPAPERS, DEFAULT_CURATED_ID, DEFAULT_PRESENTATION, isSafeWallpaperUrl, resolveCurated } from '@/lib/wallpaper';
import { sanitizeUrl } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
@@ -73,6 +73,31 @@ export function Desktop() {
customWallpaper && sanitizedWallpaperUrl && isSafeWallpaperUrl(sanitizedWallpaperUrl) ? sanitizedWallpaperUrl : undefined;
const curatedId = wallpaper.source === 'curated' ? wallpaper.id : undefined;
// `decoded.url` can lag behind `customWallpaper` (a newer selection is
// still decoding, or failed) while the previous image keeps rendering. If
// we read `customWallpaper.presentation` directly in that window, a fit/dim
// change meant for the *new* (not-yet-visible) image would briefly apply to
// the still-displayed old one. This tracks (by reference, mirroring the
// `trackedSelection` pattern in the Settings wallpaper form) the
// presentation that belongs to whichever url is actually decoded, adjusted
// during render rather than in a useEffect body.
//
// This intentionally follows React's "adjusting state during render"
// pattern and relies on reference equality: `activePresentation` must keep
// returning the *same* `customWallpaper.presentation` object across
// re-renders until the selection actually changes (it does today, because
// it's read straight from `config` rather than freshly constructed here).
// Don't refactor it into a `useMemo`/derived value that produces a new
// object identity every render — that would break the `!==` check below
// and re-run `setRenderedPresentation` on every render.
const activePresentation = customWallpaper && decoded.url === customWallpaper.url ? customWallpaper.presentation : undefined;
const [trackedPresentation, setTrackedPresentation] = useState(activePresentation);
const [renderedPresentation, setRenderedPresentation] = useState(activePresentation ?? DEFAULT_PRESENTATION);
if (activePresentation !== trackedPresentation) {
setTrackedPresentation(activePresentation);
if (activePresentation) setRenderedPresentation(activePresentation);
}
const setCuratedWallpaper = useCallback((id: string) => {
updateConfig((current) => ({ ...current, wallpaper: { version: 1, selection: { source: 'curated', id } } }));
}, [updateConfig]);
@@ -179,15 +204,16 @@ export function Desktop() {
src={safeWallpaperImageUrl}
alt=""
aria-hidden="true"
referrerPolicy="no-referrer"
className={cn(
'pointer-events-none absolute inset-0 h-full w-full',
customWallpaper.presentation.fit === 'contain' ? 'object-contain' : 'object-cover',
renderedPresentation.fit === 'contain' ? 'object-contain' : 'object-cover',
)}
/>
{customWallpaper.presentation.dim > 0 && (
{renderedPresentation.dim > 0 && (
<div
className="pointer-events-none absolute inset-0 bg-black"
style={{ opacity: customWallpaper.presentation.dim / 100 }}
style={{ opacity: renderedPresentation.dim / 100 }}
aria-hidden="true"
/>
)}

View File

@@ -69,6 +69,11 @@ describe('WallpaperPreferenceSchema', () => {
expect(WallpaperPreferenceSchema.parse('not an object')).toEqual(DEFAULT_WALLPAPER);
});
it('falls back to the default wallpaper for an unknown/stale curated id', () => {
const value = { version: 1, selection: { source: 'curated', id: 'some-removed-id' } };
expect(WallpaperPreferenceSchema.parse(value)).toEqual(DEFAULT_WALLPAPER);
});
it('falls back to the default wallpaper for an out-of-range dim value', () => {
const value = {
version: 1,

View File

@@ -67,7 +67,11 @@ export const DEFAULT_WALLPAPER: WallpaperPreference = {
/** Looks up a curated wallpaper by id, falling back to the default pattern for unknown/stale ids. */
export function resolveCurated(id: string): CuratedWallpaper {
return CURATED_WALLPAPERS.find((wallpaper) => wallpaper.id === id) ?? CURATED_WALLPAPERS[0];
return (
CURATED_WALLPAPERS.find((wallpaper) => wallpaper.id === id) ??
CURATED_WALLPAPERS.find((wallpaper) => wallpaper.id === DEFAULT_CURATED_ID) ??
CURATED_WALLPAPERS[0]
);
}
/**
@@ -98,9 +102,11 @@ const WallpaperPresentationSchema = z.object({
dim: z.number().min(0).max(80),
}) satisfies z.ZodType<WallpaperPresentation>;
const curatedIds = CURATED_WALLPAPERS.map((wallpaper) => wallpaper.id) as [string, ...string[]];
const CuratedSelectionSchema = z.object({
source: z.literal('curated'),
id: z.string().min(1).max(64),
id: z.enum(curatedIds),
});
const UrlSelectionSchema = z.object({