diff --git a/src/apps/settings/WallpaperSection.test.tsx b/src/apps/settings/WallpaperSection.test.tsx new file mode 100644 index 0000000..7f540af --- /dev/null +++ b/src/apps/settings/WallpaperSection.test.tsx @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { TestApp } from '@/test/TestApp'; +import { WallpaperSection } from './index'; + +// Radix's Slider (used for the dimming control) measures its track via +// ResizeObserver. The vi.fn()-based mock in src/test/setup.ts cannot be +// used with `new` on this environment/vitest combination, so provide a +// minimal real constructor for just this file. +class StubResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('WallpaperSection', () => { + beforeEach(() => { + global.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver; + }); + + it('shows the dot-grid curated wallpaper selected by default', async () => { + render(, { wrapper: TestApp }); + + const dotGrid = await screen.findByRole('radio', { name: 'Dot grid' }); + expect(dotGrid).toBeChecked(); + const aurora = screen.getByRole('radio', { name: 'Aurora bands' }); + expect(aurora).not.toBeChecked(); + }); + + it('selects a different curated wallpaper when clicked', async () => { + render(, { wrapper: TestApp }); + + const aurora = await screen.findByRole('radio', { name: 'Aurora bands' }); + fireEvent.click(aurora); + + await waitFor(() => expect(aurora).toBeChecked()); + expect(screen.getByRole('radio', { name: 'Dot grid' })).not.toBeChecked(); + }); + + it('does not enable saving a custom URL until it has been previewed', async () => { + render(, { wrapper: TestApp }); + + const saveButton = await screen.findByRole('button', { name: 'Save wallpaper' }); + expect(saveButton).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Custom image'), { + target: { value: 'http://not-https.example.com/a.png' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + + // An insecure URL must never start a preview/decode. + expect(saveButton).toBeDisabled(); + expect(screen.queryByAltText('Wallpaper preview')).not.toBeInTheDocument(); + }); +}); diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index 8cad688..fc0f9d1 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -165,7 +165,7 @@ 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']); -function WallpaperSection() { +export function WallpaperSection() { const { config, updateConfig } = useAppContext(); const { user } = useCurrentUser(); const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile(); @@ -195,13 +195,14 @@ function WallpaperSection() { }; const applyUrl = () => { - if (preview.status !== 'ready' || !preview.url || preview.url !== urlDraft.trim()) { + const previewedUrl = preview.url; + if (preview.status !== 'ready' || !previewedUrl || previewedUrl !== urlDraft.trim()) { toast({ title: 'Preview the image before saving it', variant: 'destructive' }); return; } updateConfig((current) => ({ ...current, - wallpaper: { version: 1, selection: { source: 'url', url: preview.url as string, presentation: { fit, dim } } }, + wallpaper: { version: 1, selection: { source: 'url', url: previewedUrl, presentation: { fit, dim } } }, })); toast({ title: 'Wallpaper saved' }); }; diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx index f8770b3..0b05d47 100644 --- a/src/components/os/Desktop.tsx +++ b/src/components/os/Desktop.tsx @@ -21,6 +21,7 @@ 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 { sanitizeUrl } from '@/lib/nostrUtils'; import { cn } from '@/lib/utils'; const CELL_WIDTH = 96; @@ -57,10 +58,20 @@ export function Desktop() { const slots = layout.desktop; const wallpaper = config.wallpaper.selection; - const isCustomWallpaper = wallpaper.source === 'url' && isSafeWallpaperUrl(wallpaper.url); - const decoded = useDecodedImage(isCustomWallpaper ? wallpaper.url : undefined); - const showWallpaperImage = isCustomWallpaper && decoded.status === 'ready' && decoded.url === wallpaper.url; - const wallpaperDataAttr = wallpaper.source === 'curated' ? wallpaper.id : undefined; + const customWallpaper = wallpaper.source === 'url' && isSafeWallpaperUrl(wallpaper.url) ? wallpaper : undefined; + const decoded = useDecodedImage(customWallpaper?.url); + // `decoded.url` holds the last *successfully* decoded image regardless of + // whether a newer selection is still loading or has failed, so switching + // to a new custom wallpaper (or a failed one) never blanks the desktop. + // Re-validated at the render boundary (rather than trusted from state) so + // the only place an is ever set from external data is guarded + // right next to the sink, independent of how `decoded.url` got here. Uses + // the same protocol-allowlist sanitizer as every other untrusted URL in + // the app (see `sanitizeUrl` in `nostrUtils.ts`), tightened to https-only. + const sanitizedWallpaperUrl = sanitizeUrl(decoded.url); + const safeWallpaperImageUrl = + customWallpaper && sanitizedWallpaperUrl && isSafeWallpaperUrl(sanitizedWallpaperUrl) ? sanitizedWallpaperUrl : undefined; + const curatedId = wallpaper.source === 'curated' ? wallpaper.id : undefined; const setCuratedWallpaper = useCallback((id: string) => { updateConfig((current) => ({ ...current, wallpaper: { version: 1, selection: { source: 'curated', id } } })); @@ -155,30 +166,28 @@ export function Desktop() {
{ if (event.target === event.currentTarget) setSelected(null); }} > - {isCustomWallpaper && ( + {customWallpaper && safeWallpaperImageUrl && ( <> - {showWallpaperImage && ( - - )} - {showWallpaperImage && wallpaper.presentation.dim > 0 && ( + + {customWallpaper.presentation.dim > 0 && (