fixing small bugs; improving background handling and removing dead code
Build and Release to Staging / Build Vision Start (push) Successful in 1m6s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m41s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 18s

This commit is contained in:
ivanch committed 2026-10-28 21:52:07 -03:00
1 parent 99dbea0615
commit 62e187b9f5
24 files changed
+439 -250

No files matched your search

+5 -3
View File
@@ -12,8 +12,10 @@ interface ClockProps {
};
}
const getMinuteKey = (date: Date): number => Math.floor(date.getTime() / 60_000);
const Clock: React.FC<ClockProps> = ({ config }) => {
const [time, setTime] = useState(new Date());
const [minuteKey, setMinuteKey] = useState(() => getMinuteKey(new Date()));
useEffect(() => {
if (!config.clock.enabled) return;
@@ -23,7 +25,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
window.clearTimeout(timeoutId);
if (document.visibilityState !== 'visible') return;
const now = new Date();
setTime(now);
setMinuteKey(getMinuteKey(now));
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
timeoutId = window.setTimeout(refresh, msToNextMinute);
};
@@ -62,7 +64,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
fontFamily: config.clock.font,
}}
>
{formatTime(time)}
{formatTime(new Date(minuteKey * 60_000))}
</div>
);
};
+1 -1
View File
@@ -68,7 +68,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleCloseRef.current();
if (e.key === 'Escape' && !e.defaultPrevented) handleCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
+11 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useLayoutEffect } from 'react';
interface DropdownProps {
options: { value: string; label: string }[];
@@ -12,17 +12,25 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
useLayoutEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, []);
}, [isOpen]);
const handleOptionClick = (optionValue: string) => {
let newValue: string | string[];
+1 -1
View File
@@ -17,7 +17,7 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current();
if (e.key === 'Escape' && !e.defaultPrevented) onCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
+16 -18
View File
@@ -1,14 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { memo, useState, useEffect, useRef } from 'react';
import { Server } from '../types';
import ping from './utils/jsping';
interface ServerWidgetProps {
config: {
serverWidget: {
enabled: boolean;
pingFrequency: number;
servers: Server[];
};
serverWidget: {
enabled: boolean;
pingFrequency: number;
servers: Server[];
};
}
@@ -20,20 +18,20 @@ const STATUS_COLORS: Record<string, string> = {
const getStatusColor = (status: string): string =>
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const ServerWidget: React.FC<ServerWidgetProps> = ({ serverWidget }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers);
serversRef.current = config.serverWidget.servers;
const serversRef = useRef(serverWidget.servers);
serversRef.current = serverWidget.servers;
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address]));
const serversSignature = JSON.stringify(serverWidget.servers.map(s => [s.id, s.address]));
useEffect(() => {
if (!config.serverWidget.enabled || serversRef.current.length === 0) return;
if (!serverWidget.enabled || serversRef.current.length === 0) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let cancelled = false;
const frequency = Math.min(60, Math.max(5, config.serverWidget.pingFrequency || 15)) * 1000;
const frequency = Math.min(60, Math.max(5, serverWidget.pingFrequency || 15)) * 1000;
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
const pingServers = async () => {
@@ -74,19 +72,19 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [
config.serverWidget.enabled,
config.serverWidget.pingFrequency,
serverWidget.enabled,
serverWidget.pingFrequency,
serversSignature,
]);
if (!config.serverWidget.enabled) {
if (!serverWidget.enabled) {
return null;
}
return (
<div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2">
<div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2">
{config.serverWidget.servers.map((server) => (
{serverWidget.servers.map((server) => (
<div key={server.id} className="flex items-center gap-2">
<div className={`liquid-status-dot w-2.5 h-2.5 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
<span className="text-slate-100 text-sm font-medium">
@@ -99,4 +97,4 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
);
};
export default ServerWidget;
export default memo(ServerWidget);
+67 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { memo, useState, useEffect } from 'react';
import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
@@ -27,9 +27,56 @@ const rememberWallpaperUrl = (name: string, resolved: string | undefined): void
}
};
const MAX_WALLPAPER_OBJECT_URLS = 2;
const wallpaperObjectUrls = new Map<string, string>();
const revokeWallpaperObjectUrls = (keepNames?: string[]): void => {
for (const [name, url] of wallpaperObjectUrls) {
if (keepNames?.includes(name)) continue;
URL.revokeObjectURL(url);
wallpaperObjectUrls.delete(name);
}
};
const rememberWallpaperObjectUrl = (name: string, url: string): string => {
const existing = wallpaperObjectUrls.get(name);
if (existing) {
URL.revokeObjectURL(url);
return existing;
}
wallpaperObjectUrls.set(name, url);
while (wallpaperObjectUrls.size > MAX_WALLPAPER_OBJECT_URLS) {
const [oldestName, oldestUrl] = wallpaperObjectUrls.entries().next().value ?? [];
if (oldestName === undefined || oldestUrl === undefined) break;
URL.revokeObjectURL(oldestUrl);
wallpaperObjectUrls.delete(oldestName);
}
return url;
};
const dataUrlToObjectUrl = (dataUrl: string): string => {
const header = dataUrl.match(/^data:([^;,]*)((?:;[^;,]*)*),/);
if (!header || typeof URL.createObjectURL !== 'function') return dataUrl;
try {
const payload = dataUrl.slice(header[0].length);
const type = header[1] || 'application/octet-stream';
if (!/;base64/i.test(header[2])) {
return URL.createObjectURL(new Blob([decodeURIComponent(payload)], { type }));
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return URL.createObjectURL(new Blob([bytes], { type }));
} catch {
return dataUrl;
}
};
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
const objectUrl = wallpaperObjectUrls.get(name);
if (objectUrl) return objectUrl;
let resolved: string | undefined;
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
@@ -56,10 +103,22 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
}
}
if (resolved?.startsWith('data:')) {
const url = dataUrlToObjectUrl(resolved);
return url.startsWith('blob:') ? rememberWallpaperObjectUrl(name, url) : url;
}
rememberWallpaperUrl(name, resolved);
return resolved;
};
const getWallpaperFilter = (blur: number, brightness: number): string => {
const filters = [
blur > 0 ? `blur(${blur}px)` : '',
brightness !== 100 ? `brightness(${brightness / 100})` : '',
].filter(Boolean);
return filters.length > 0 ? filters.join(' ') : 'none';
};
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
@@ -68,6 +127,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
let timer: ReturnType<typeof setTimeout> | undefined;
let revision = 0;
wallpaperUrlCache.clear();
revokeWallpaperObjectUrls(wallpaperNames);
const updateWallpaper = async () => {
const request = ++revision;
@@ -119,6 +179,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
const onStorage = (event: StorageEvent) => {
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
wallpaperUrlCache.clear();
if (event.key !== 'wallpaperState') revokeWallpaperObjectUrls();
refresh();
}
};
@@ -137,6 +198,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
};
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
useEffect(() => () => revokeWallpaperObjectUrls(), []);
if (!imageUrl) return null;
return (
@@ -145,7 +208,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
className="wallpaper-layer wallpaper-transition"
style={{
backgroundImage: `url(${JSON.stringify(imageUrl)})`,
filter: `blur(${blur}px) brightness(${brightness / 100})`,
filter: getWallpaperFilter(blur, brightness),
transform: blur > 0 ? 'scale(1.015)' : 'none',
opacity: opacity / 100,
}}
aria-label="Wallpaper background"
@@ -155,4 +219,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
);
};
export default Wallpaper;
export default memo(Wallpaper);
+28 -6
View File
@@ -21,6 +21,8 @@ interface IconMetadata {
const getIconPickUrl = (iconData: IconMetadata): string =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
const isIconUrl = (value: string): boolean => /^(https?:|data:image\/)/i.test(value.trim());
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : '');
@@ -28,6 +30,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
const [iconQuery, setIconQuery] = useState('');
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [previewFailed, setPreviewFailed] = useState(false);
const metadataRequest = useRef<AbortController | null>(null);
const debounceRef = useRef<number | null>(null);
@@ -105,13 +108,30 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
alert('Please enter a valid website URL (http, https, ftp, or mailto).');
return;
}
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon });
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon: resolveIcon(icon) });
};
const resolveIcon = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || isIconUrl(trimmed)) return trimmed;
const query = trimmed.toLowerCase();
for (const ic of iconMetadata) {
if (ic.name.toLowerCase() === query) return getIconPickUrl(ic);
const color = ic.colors.find(c => c.toLowerCase() === query);
if (color) return getIconPickUrl({ ...ic, name: color });
}
return trimmed;
};
const updateIcon = (value: string) => {
setIcon(value);
setPreviewFailed(false);
};
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon);
if (fetchedIcon) updateIcon(fetchedIcon);
}
};
@@ -125,8 +145,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
onDelete={edit ? onDelete : undefined}
>
<div className="flex justify-center mb-4">
{icon ? (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
{isIconUrl(icon) && !previewFailed ? (
<img src={icon.trim()} alt="Website Icon" className="h-24 w-24 object-contain" onError={() => setPreviewFailed(true)} />
) : (
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
@@ -158,7 +178,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
placeholder="Icon URL or name"
value={icon}
onChange={(e) => {
setIcon(e.target.value);
updateIcon(e.target.value);
setIconQuery(e.target.value);
}}
onFocus={ensureIconMetadata}
@@ -170,7 +190,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
<div
key={`${iconData.name}-${index}`}
onClick={() => {
setIcon(getIconPickUrl(iconData));
updateIcon(getIconPickUrl(iconData));
setIconQuery('');
setFilteredIcons([]);
}}
@@ -179,6 +199,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
<img
src={getIconPickUrl(iconData)}
alt={iconData.name}
loading="lazy"
decoding="async"
className="h-6 w-6 mr-2"
/>
<span>{iconData.name}</span>
+23 -10
View File
@@ -1,6 +1,6 @@
import React, { memo, useEffect, useState } from 'react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
import { cacheWebsiteIcon, getCachedWebsiteIcon, getInitialIconSource, removeCachedWebsiteIcon } from './utils/iconService';
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
@@ -12,22 +12,33 @@ interface WebsiteTileProps {
tileSize?: string;
}
const LOADING_RESET_MS = 10_000;
const isNavigableUrl = (url: string): boolean => /^https?:/i.test(url);
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const [isLoading, setIsLoading] = useState(false);
const [iconSource, setIconSource] = useState<string | null>(null);
const [iconSource, setIconSource] = useState<string | null | undefined>(() => getInitialIconSource(website.icon));
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
const loadingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
const resetLoading = () => setIsLoading(false);
const resetLoading = () => {
clearTimeout(loadingTimer.current);
setIsLoading(false);
};
window.addEventListener('pageshow', resetLoading);
return () => window.removeEventListener('pageshow', resetLoading);
return () => {
clearTimeout(loadingTimer.current);
window.removeEventListener('pageshow', resetLoading);
};
}, []);
useEffect(() => {
let cancelled = false;
setIconSource(null);
setIconSource(getInitialIconSource(website.icon));
setUsingCachedIcon(false);
const loadIcon = async () => {
@@ -40,7 +51,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
return;
}
setIconSource(website.icon);
setIconSource(website.icon || null);
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
if (!cancelled && newlyCachedIcon) {
setIconSource(newlyCachedIcon);
@@ -70,8 +81,10 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
e.preventDefault();
return;
}
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && isNavigableUrl(website.url)) {
setIsLoading(true);
clearTimeout(loadingTimer.current);
loadingTimer.current = setTimeout(() => setIsLoading(false), LOADING_RESET_MS);
}
};
@@ -105,9 +118,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
decoding="async"
onError={handleIconError}
/>
) : (
) : iconSource === null ? (
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
)}
) : null}
</div>
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name}
+1 -1
View File
@@ -184,7 +184,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
<p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span>
</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc. (up to 20MB)</p>
</div>
<input
id="file-upload"
+2 -3
View File
@@ -1,5 +1,4 @@
import { memo } from 'react';
interface ConfigurationButtonProps {
onClick: () => void;
}
@@ -21,4 +20,4 @@ const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) =>
);
};
export default ConfigurationButton;
export default memo(ConfigurationButton);
+2 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { PencilIcon } from '../icons';
interface EditButtonProps {
@@ -20,4 +21,4 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
);
};
export default EditButton;
export default memo(EditButton);
+2 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react';
import Clock from '../Clock';
import { Config } from '../../types';
import { getTitleSizeClass } from '../utils/styleUtils';
@@ -29,4 +30,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
);
};
export default Header;
export default memo(Header);
+55 -24
View File
@@ -138,6 +138,50 @@ export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
});
};
const MAX_WALLPAPER_SOURCE_BYTES = 20 * 1024 * 1024;
const MAX_WALLPAPER_DATA_URL_LENGTH = 4.5 * 1024 * 1024;
const MIN_WALLPAPER_EDGE = 1920;
const MAX_WALLPAPER_EDGE = 2560;
const readFileAsDataUrl = (file: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result);
else reject(new Error('Could not read the image file.'));
};
reader.onerror = () => reject(reader.error || new Error('Could not read the image file.'));
reader.readAsDataURL(file);
});
const downscaleWallpaper = async (file: File): Promise<Blob> => {
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined' || file.type === 'image/gif' || file.type === 'image/svg+xml') {
return file;
}
try {
const bitmap = await createImageBitmap(file);
try {
const screenEdge = Math.max(window.screen?.width ?? 0, window.screen?.height ?? 0) * (window.devicePixelRatio || 1);
const maxEdge = Math.min(MAX_WALLPAPER_EDGE, Math.max(MIN_WALLPAPER_EDGE, screenEdge));
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext('2d');
if (!context) return file;
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.85));
canvas.width = 0;
canvas.height = 0;
return blob && blob.type === 'image/webp' && blob.size < file.size ? blob : file;
} finally {
bitmap.close();
}
} catch {
return file;
}
};
const assertWallpaperNameAvailable = (name: string): void => {
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
@@ -208,29 +252,16 @@ export const ConfigurationService = {
async addWallpaperFile(file: File): Promise<Wallpaper> {
if (!file.type.startsWith('image/')) throw new Error('Please choose an image file.');
assertWallpaperNameAvailable(file.name.trim());
return new Promise((resolve, reject) => {
if (file.size > 4 * 1024 * 1024) {
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
return;
}
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
return;
}
try {
assertWallpaperNameAvailable(file.name.trim());
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
resolve({ name: finalName });
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
if (file.size > MAX_WALLPAPER_SOURCE_BYTES) {
throw new Error('File size exceeds 20MB. Please choose a smaller file.');
}
const base64 = await readFileAsDataUrl(await downscaleWallpaper(file));
if (base64.length > MAX_WALLPAPER_DATA_URL_LENGTH) {
throw new Error('The uploaded image is too large. Please choose a smaller file.');
}
assertWallpaperNameAvailable(file.name.trim());
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
return { name: finalName };
},
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
@@ -260,7 +291,7 @@ export const ConfigurationService = {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 0);
},
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
+32 -11
View File
@@ -8,6 +8,7 @@ declare global {
local?: {
set: (items: object, callback?: () => void) => void;
get: (keys: string[] | string | null, callback: (items: { [key: string]: string }) => void) => void;
getKeys?: (callback: (keys: string[]) => void) => void;
remove: (keys: string | string[], callback?: () => void) => void;
};
};
@@ -57,17 +58,36 @@ const chromeLocalCall = <T>(
});
});
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return null;
let pendingIconLookups: Map<string, ((value: string | null) => void)[]> | null = null;
const flushIconLookups = async (): Promise<void> => {
const lookups = pendingIconLookups;
pendingIconLookups = null;
if (!lookups) return;
let result: StorageResult = {};
try {
const key = getIconCacheKey(sourceUrl);
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([key], cb),
result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([...lookups.keys()], cb),
);
return result[key] || null;
} catch {
return null;
result = {};
}
for (const [key, resolvers] of lookups) {
const value = typeof result[key] === 'string' ? result[key] || null : null;
resolvers.forEach(resolve => resolve(value));
}
};
export function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return Promise.resolve(null);
return new Promise(resolve => {
if (!pendingIconLookups) {
pendingIconLookups = new Map();
queueMicrotask(() => void flushIconLookups());
}
const key = getIconCacheKey(sourceUrl);
pendingIconLookups.set(key, [...(pendingIconLookups.get(key) ?? []), resolve]);
});
}
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
@@ -98,10 +118,11 @@ export async function pruneCachedIconsFromChromeStorageLocal(keepSourceUrls: str
if (!checkChromeStorageLocalAvailable()) return 0;
try {
const keep = new Set(keepSourceUrls.map(getIconCacheKey));
const items = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get(null, cb),
);
const stale = Object.keys(items).filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
const local = window.chrome?.storage?.local;
const keys = local?.getKeys
? await chromeLocalCall<string[]>((cb) => local.getKeys?.(cb))
: Object.keys(await chromeLocalCall<StorageResult>((cb) => local?.get(null, cb)));
const stale = keys.filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
if (stale.length === 0) return 0;
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(stale, cb),
+19 -12
View File
@@ -4,6 +4,7 @@ import {
removeCachedIconFromChromeStorageLocal,
saveCachedIconToChromeStorageLocal,
} from './StorageLocalManager';
import { normalizeWebsiteUrl } from './urlUtils';
const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50;
@@ -138,20 +139,25 @@ const getIconFetchSource = (iconUrl: string): string | null => {
async function getWebsiteIcon(rawUrl: string): Promise<string> {
const trimmed = rawUrl.trim();
const hasProtocol = /^https?:\/\//i.test(trimmed);
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`;
try {
const parsed = new URL(targetUrl);
if (!isTrustedTld(parsed.hostname)) {
if (!hasProtocol) {
return `http://${parsed.host}/favicon.ico`;
}
return `${parsed.origin}/favicon.ico`;
const normalized = normalizeWebsiteUrl(trimmed);
if (!normalized) return '';
const parsed = new URL(normalized);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (!isTrustedTld(parsed.hostname)) {
if (!hasProtocol) {
return `http://${parsed.host}/favicon.ico`;
}
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
} catch {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
return `${parsed.origin}/favicon.ico`;
}
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
}
function getInitialIconSource(iconUrl: string): string | null | undefined {
if (!iconUrl) return null;
if (isDataUrl(iconUrl)) return iconUrl;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
return isCacheableIconUrl(iconUrl) && checkChromeStorageLocalAvailable() ? undefined : iconUrl;
}
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
@@ -245,6 +251,7 @@ async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
export {
cacheWebsiteIcon,
getCachedWebsiteIcon,
getInitialIconSource,
getWebsiteIcon,
removeCachedWebsiteIcon,
};