Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions packages/ui/src/providers/wallpaper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const wallpapers = [
] as const satisfies readonly WallpaperBase[]

export function getWallpaperThumbUrl(wallpaper: WallpaperBase) {
if (wallpaper.id?.startsWith('custom:')) return wallpaper.url
return `/assets/wallpapers/generated-thumbs/${wallpaper.id}.jpg`
}

Expand All @@ -136,6 +137,16 @@ export type WallpaperId = (typeof wallpapers)[number]['id']
export const wallpapersKeyed = keyBy(wallpapers, 'id')
export const wallpaperIds = wallpapers.map((w) => w.id)

const DEFAULT_BRAND_COLOR_HSL = '259 100% 59%' // Umbrel purple fallback

function resolveWallpaperAssets(id: string): WallpaperBase {
if (id.startsWith('custom:')) {
const uuid = id.slice('custom:'.length)
return {id, url: `/api/wallpapers/custom/${uuid}`, brandColorHsl: DEFAULT_BRAND_COLOR_HSL}
}
return wallpapersKeyed[id as WallpaperId] ?? wallpapersKeyed['18']
}

// ---

const nullWallpaper = {
Expand All @@ -145,10 +156,11 @@ const nullWallpaper = {
} as const satisfies WallpaperBase

type WallpaperType = {
wallpaper: Wallpaper | typeof nullWallpaper
wallpaper: WallpaperBase
isLoading: boolean
prevWallpaper: Wallpaper | undefined
setWallpaperId: (id: WallpaperId) => void
prevWallpaper: WallpaperBase | undefined
setWallpaperId: (id: string) => void
uploadWallpaper: (base64: string) => Promise<string>
wallpaperFullyVisible: boolean
setWallpaperFullyVisible: () => void
}
Expand Down Expand Up @@ -179,7 +191,13 @@ export function WallpaperProviderConnected({children}: {children: ReactNode}) {
const wallpaper = remote.isLoading ? nullWallpaper : remoteWallpaper || nullWallpaper

return (
<WallpaperProvider wallpaper={wallpaper} onWallpaperChange={(w) => remote.setWallpaperId(w.id)}>
<WallpaperProvider
wallpaper={wallpaper}
onWallpaperChange={(w) => {
if (w.id) remote.setWallpaperId(w.id)
}}
uploadWallpaper={remote.uploadWallpaper}
>
{children}
</WallpaperProvider>
)
Expand All @@ -188,10 +206,12 @@ export function WallpaperProviderConnected({children}: {children: ReactNode}) {
export function WallpaperProvider({
wallpaper,
onWallpaperChange,
uploadWallpaper = () => Promise.reject(new Error('uploadWallpaper not configured')),
children,
}: {
wallpaper: Wallpaper | typeof nullWallpaper
onWallpaperChange: (wallpaper: Wallpaper) => void
wallpaper: WallpaperBase
onWallpaperChange: (wallpaper: WallpaperBase) => void
uploadWallpaper?: (base64: string) => Promise<string>
children: ReactNode
}) {
const [isLoading, setIsLoading] = useState(true)
Expand All @@ -215,10 +235,11 @@ export function WallpaperProvider({
value={{
wallpaper,
isLoading,
prevWallpaper: (prevId && wallpapersKeyed[prevId]) || undefined,
setWallpaperId: (id: WallpaperId) => {
onWallpaperChange(wallpapersKeyed[id])
prevWallpaper: prevId ? resolveWallpaperAssets(prevId) : undefined,
setWallpaperId: (id: string) => {
onWallpaperChange(resolveWallpaperAssets(id))
},
uploadWallpaper,
wallpaperFullyVisible,
setWallpaperFullyVisible: () => setWallpaperFullyVisible(true),
}}
Expand All @@ -228,8 +249,8 @@ export function WallpaperProvider({
)
}

export function useWallpaperCssVars(wallpaperId?: WallpaperId) {
const {brandColorHsl} = wallpaperId ? wallpapersKeyed[wallpaperId] : nullWallpaper
export function useWallpaperCssVars(wallpaperId?: string) {
const {brandColorHsl} = wallpaperId ? resolveWallpaperAssets(wallpaperId) : nullWallpaper

useLayoutEffect(() => {
const el = document.documentElement
Expand Down Expand Up @@ -330,12 +351,21 @@ function useRemoteWallpaper(onSuccess?: (id: WallpaperId) => void) {
utils.user.wallpaper.invalidate()
},
})
const setWallpaperId = useCallback((id: WallpaperId) => userMut.mutate({wallpaper: id}), [userMut])
const setWallpaperId = useCallback((id: string) => userMut.mutate({wallpaper: id}), [userMut])

const uploadMut = trpcReact.user.uploadWallpaper.useMutation({
onSuccess: () => {
utils.user.get.invalidate()
utils.user.wallpaper.invalidate()
},
})
const uploadWallpaper = useCallback((base64: string) => uploadMut.mutateAsync({imageBase64: base64}), [uploadMut])

return {
isLoading: userQ.isLoading,
wallpaper: wallpaperQId && arrayIncludes(wallpaperIds, wallpaperQId) ? wallpapersKeyed[wallpaperQId] : undefined,
wallpaper: wallpaperQId ? resolveWallpaperAssets(wallpaperQId) : undefined,
setWallpaperId,
uploadWallpaper,
}
}

Expand Down
65 changes: 64 additions & 1 deletion packages/ui/src/routes/settings/_components/wallpaper-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import {useEffect, useRef} from 'react'
import {cn} from '@/lib/utils'
import {useWallpaper, wallpapers} from '@/providers/wallpaper'

// TODO: export from wallpaper.tsx when lucide-react is confirmed available
function UploadIcon({className}: {className?: string}) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className={className}
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5'
/>
</svg>
)
}

const ITEM_W = 40
const GAP = 4
const ACTIVE_SCALE = 1.4
Expand Down Expand Up @@ -44,11 +64,39 @@ function WallpaperItem({

// TODO: delay mounting for performance
export function WallpaperPicker({maxW}: {maxW?: number}) {
const {wallpaper, setWallpaperId} = useWallpaper()
const {wallpaper, setWallpaperId, uploadWallpaper} = useWallpaper()
const containerRef = useRef<HTMLDivElement>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const itemsRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)

async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return

// Normalise any browser-supported format (PNG, WebP, HEIC…) to JPEG and strip EXIF
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.src = objectUrl
await new Promise<void>((resolve) => {
img.onload = () => resolve()
})

const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
URL.revokeObjectURL(objectUrl)

const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92))
if (!blob) return

const arrayBuffer = await blob.arrayBuffer()
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
await uploadWallpaper(base64)
}

useEffect(() => {
if (!containerRef.current || !selectedItemRef.current || !itemsRef.current || !scrollerRef.current) {
Expand Down Expand Up @@ -91,6 +139,21 @@ export function WallpaperPicker({maxW}: {maxW?: number}) {
bg={`/assets/wallpapers/generated-thumbs/${w.id}.jpg`}
/>
))}
<input
ref={fileInputRef}
type='file'
accept='image/*'
className='hidden'
onChange={handleFileChange}
/>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
className='flex h-6 w-10 shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-3 border border-dashed border-white/30 hover:border-white/60 transition-colors outline-hidden focus-visible:ring-1 ring-white/50'
aria-label='Upload custom wallpaper'
>
<UploadIcon className='h-2.5 w-2.5 text-white/60' />
</button>
<div className='w-1 shrink-0' />
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions packages/umbreld/source/modules/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ class Server {

// Handle tRPC routes
this.app.use('/trpc', trpcExpressHandler)

// Serve user-uploaded custom wallpapers
// Security: only strict UUID v4 filenames are accepted (no path traversal possible)
this.app.get('/api/wallpapers/custom/:id', async (req, res) => {
const {id} = req.params as {id: string}
if (!/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/.test(id)) {
return res.status(400).end()
}
const filePath = join(this.umbreld.dataDirectory, 'data', 'wallpapers', `${id}.jpg`)
res.sendFile(filePath, (err) => {
if (err) res.status(404).end()
})
})

this.mountWebSocketServer('/trpc', (wss) => {
trpcWssHandler({wss, umbreld: this.umbreld, logger: this.logger})
})
Expand Down
22 changes: 22 additions & 0 deletions packages/umbreld/source/modules/user/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,26 @@ export default router({
const user = await ctx.user.get()
return user?.language ?? null
}),

// Uploads a custom wallpaper image and sets it as the active wallpaper
uploadWallpaper: privateProcedure
.input(
z.object({
// Base64-encoded JPEG. Cap at ~10 MB encoded.
imageBase64: z.string().max(14_000_000),
}),
)
.mutation(async ({ctx, input}) => {
const buffer = Buffer.from(input.imageBase64, 'base64')

// Basic sanity check: JPEG magic bytes FF D8 FF
if (buffer[0] !== 0xff || buffer[1] !== 0xd8 || buffer[2] !== 0xff) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only JPEG images are supported',
})
}

return ctx.user.saveCustomWallpaper(buffer)
}),
})
14 changes: 14 additions & 0 deletions packages/umbreld/source/modules/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export default class User {
return this.#store.set('user.wallpaper', wallpaper)
}

// Save a custom wallpaper image and set it as the active wallpaper
async saveCustomWallpaper(imageData: Buffer): Promise<string> {
const {randomUUID} = await import('node:crypto')
const path = await import('node:path')

const id = randomUUID()
const wallpaperDir = path.join(this.#umbreld.dataDirectory, 'data', 'wallpapers')
await fse.ensureDir(wallpaperDir)
await fse.writeFile(path.join(wallpaperDir, `${id}.jpg`), imageData)
const wallpaperId = `custom:${id}`
await this.setWallpaper(wallpaperId)
return wallpaperId
}

// Set the users password
async setPassword(password: string) {
// Hash the password with the current recommended default
Expand Down