improving general usability and stability
This commit is contained in:
1 parent
643e7acf30
commit
b9159cf61f
17 files changed
+736
-200
No files matched your search
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||
import ServerWidget from './components/ServerWidget';
|
||||
import { DEFAULT_CATEGORIES } from './constants';
|
||||
import { Category, Website, Config } from './types';
|
||||
import Header from './components/layout/Header';
|
||||
import EditButton from './components/layout/EditButton';
|
||||
@@ -11,23 +10,14 @@ import { ConfigurationService } from './components/services/ConfigurationService
|
||||
import { getAlignmentClass } from './components/utils/styleUtils';
|
||||
import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils';
|
||||
import { PlusIcon } from './components/icons';
|
||||
import { createId } from './components/utils/idUtils';
|
||||
|
||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [categories, setCategories] = useState<Category[]>(() => {
|
||||
try {
|
||||
const storedCategories = localStorage.getItem('categories');
|
||||
if (storedCategories) {
|
||||
return JSON.parse(storedCategories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing categories from localStorage', error);
|
||||
}
|
||||
return DEFAULT_CATEGORIES;
|
||||
});
|
||||
const [categories, setCategories] = useState<Category[]>(() => ConfigurationService.loadCategories());
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [editingWebsite, setEditingWebsite] = useState<Website | null>(null);
|
||||
@@ -74,90 +64,87 @@ const App: React.FC = () => {
|
||||
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
|
||||
if (editingWebsite) {
|
||||
const idToUpdate = website.id ?? editingWebsite.id;
|
||||
const newCategories = categories.map(category => ({
|
||||
setCategories(previous => previous.map(category => category.id === editingWebsite.categoryId ? {
|
||||
...category,
|
||||
websites: category.websites.map(w =>
|
||||
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
|
||||
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate, categoryId: category.id } : w
|
||||
),
|
||||
}));
|
||||
setCategories(newCategories);
|
||||
} : category));
|
||||
setEditingWebsite(null);
|
||||
} else if (addingWebsite) {
|
||||
const newWebsite: Website = {
|
||||
id: Date.now().toString(),
|
||||
id: createId(),
|
||||
name: website.name || '',
|
||||
url: website.url || '',
|
||||
icon: website.icon || '',
|
||||
categoryId: addingWebsite.id,
|
||||
};
|
||||
const newCategories = categories.map(category =>
|
||||
setCategories(previous => previous.map(category =>
|
||||
category.id === addingWebsite.id
|
||||
? { ...category, websites: [...category.websites, newWebsite] }
|
||||
: category
|
||||
);
|
||||
setCategories(newCategories);
|
||||
));
|
||||
setAddingWebsite(null);
|
||||
}
|
||||
}, [editingWebsite, addingWebsite, categories]);
|
||||
}, [editingWebsite, addingWebsite]);
|
||||
|
||||
const handleSaveCategory = useCallback((name: string) => {
|
||||
if (editingCategory) {
|
||||
const newCategories = categories.map(category =>
|
||||
setCategories(previous => previous.map(category =>
|
||||
category.id === editingCategory.id ? { ...category, name } : category
|
||||
);
|
||||
setCategories(newCategories);
|
||||
));
|
||||
} else {
|
||||
const newCategory: Category = {
|
||||
id: Date.now().toString(),
|
||||
id: createId(),
|
||||
name,
|
||||
websites: [],
|
||||
};
|
||||
setCategories([...categories, newCategory]);
|
||||
setCategories(previous => [...previous, newCategory]);
|
||||
}
|
||||
setEditingCategory(null);
|
||||
setIsCategoryModalOpen(false);
|
||||
}, [editingCategory, categories]);
|
||||
}, [editingCategory]);
|
||||
|
||||
const handleDeleteWebsite = useCallback(() => {
|
||||
if (!editingWebsite) return;
|
||||
|
||||
const newCategories = categories.map(category => ({
|
||||
setCategories(previous => previous.map(category => category.id === editingWebsite.categoryId ? {
|
||||
...category,
|
||||
websites: category.websites.filter(w => w.id !== editingWebsite.id),
|
||||
}));
|
||||
setCategories(newCategories);
|
||||
} : category));
|
||||
setEditingWebsite(null);
|
||||
}, [editingWebsite, categories]);
|
||||
}, [editingWebsite]);
|
||||
|
||||
const handleDeleteCategory = useCallback(() => {
|
||||
if (!editingCategory) return;
|
||||
|
||||
const newCategories = categories.filter(c => c.id !== editingCategory.id);
|
||||
setCategories(newCategories);
|
||||
setCategories(previous => previous.filter(c => c.id !== editingCategory.id));
|
||||
setEditingCategory(null);
|
||||
setIsCategoryModalOpen(false);
|
||||
}, [editingCategory, categories]);
|
||||
}, [editingCategory]);
|
||||
|
||||
const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => {
|
||||
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
||||
if (categoryIndex === -1) return;
|
||||
setCategories(previous => {
|
||||
const categoryIndex = previous.findIndex(cat => cat.id === website.categoryId);
|
||||
if (categoryIndex === -1) return previous;
|
||||
|
||||
const category = categories[categoryIndex];
|
||||
const category = previous[categoryIndex];
|
||||
const websiteIndex = category.websites.findIndex(w => w.id === website.id);
|
||||
if (websiteIndex === -1) return;
|
||||
if (websiteIndex === -1) return previous;
|
||||
|
||||
const targetIndex = direction === 'left' ? websiteIndex - 1 : websiteIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= category.websites.length) return;
|
||||
if (targetIndex < 0 || targetIndex >= category.websites.length) return previous;
|
||||
|
||||
const newWebsites = [...category.websites];
|
||||
const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
|
||||
newWebsites.splice(targetIndex, 0, movedWebsite);
|
||||
|
||||
const newCategories = [...categories];
|
||||
const newCategories = [...previous];
|
||||
newCategories[categoryIndex] = { ...category, websites: newWebsites };
|
||||
|
||||
setCategories(newCategories);
|
||||
}, [categories]);
|
||||
return newCategories;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
+14
-13
@@ -18,22 +18,23 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
useEffect(() => {
|
||||
if (!config.clock.enabled) return;
|
||||
|
||||
const scheduleNext = () => {
|
||||
const now = new Date();
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setTime(new Date());
|
||||
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
|
||||
}, msToNextMinute);
|
||||
};
|
||||
|
||||
let timeoutId = 0;
|
||||
let intervalId = 0;
|
||||
scheduleNext();
|
||||
const refresh = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = new Date();
|
||||
setTime(now);
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(refresh, msToNextMinute);
|
||||
};
|
||||
refresh();
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
window.addEventListener('focus', refresh);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
window.clearTimeout(timeoutId);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
window.removeEventListener('focus', refresh);
|
||||
};
|
||||
}, [config.clock.enabled]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import { Config, Wallpaper } from '../types';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||
@@ -6,7 +6,7 @@ import { ConfigurationService } from './services/ConfigurationService';
|
||||
import GeneralTab from './configuration/GeneralTab';
|
||||
import ThemeTab from './configuration/ThemeTab';
|
||||
import ClockTab from './configuration/ClockTab';
|
||||
import ServerWidgetTab from './configuration/ServerWidgetTab';
|
||||
const ServerWidgetTab = lazy(() => import('./configuration/ServerWidgetTab'));
|
||||
|
||||
interface ConfigurationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -30,6 +30,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const isSaving = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const wallpaperMutation = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||
@@ -43,8 +45,10 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(closeTimer.current);
|
||||
if (!isSaving.current) {
|
||||
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
|
||||
const availableNames = new Set([...baseWallpapers, ...ConfigurationService.loadUserWallpapers()].map(wallpaper => wallpaper.name));
|
||||
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers.filter(name => availableNames.has(name)) });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -55,39 +59,49 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleConfigChange = (updates: Partial<Config>) => {
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||
const newWallpaper = await promise;
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
const mutateWallpapers = (mutate: () => Promise<void>): Promise<void> => {
|
||||
const operation = wallpaperMutation.current.then(mutate);
|
||||
wallpaperMutation.current = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
|
||||
const handleAddWallpaperEntry = (createWallpaper: () => Promise<Wallpaper>) => mutateWallpapers(async () => {
|
||||
const newWallpaper = await createWallpaper();
|
||||
const updated = [...ConfigurationService.loadUserWallpapers(), newWallpaper];
|
||||
if (updated.slice(0, -1).some(wallpaper => wallpaper.name === newWallpaper.name)) {
|
||||
throw new Error('A wallpaper with this name already exists. Please choose a different name.');
|
||||
}
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const handleAddWallpaper = (name: string, url: string) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||
handleAddWallpaperEntry(() => ConfigurationService.addWallpaper(name, url));
|
||||
|
||||
const handleAddWallpaperFile = (file: File) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||
handleAddWallpaperEntry(() => ConfigurationService.addWallpaperFile(file));
|
||||
|
||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||
try {
|
||||
await mutateWallpapers(async () => {
|
||||
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
|
||||
setUserWallpapers(updated);
|
||||
const updated = ConfigurationService.loadUserWallpapers().filter((w) => w.name !== wallpaper.name);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
|
||||
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
|
||||
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
|
||||
setUserWallpapers(updated);
|
||||
setConfig((prev) => ({ ...prev, currentWallpapers: prev.currentWallpapers.filter(name => name !== wallpaper.name) }));
|
||||
});
|
||||
} catch (error) {
|
||||
alert('Error deleting wallpaper. Please try again.');
|
||||
console.error(error);
|
||||
@@ -103,6 +117,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
setConfig(importedConfig);
|
||||
setUserWallpapers(importedWallpapers);
|
||||
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
|
||||
isSaving.current = true;
|
||||
onSave(importedConfig);
|
||||
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||
window.location.reload();
|
||||
@@ -176,7 +191,9 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
<ClockTab config={config} onChange={handleConfigChange} />
|
||||
)}
|
||||
{activeTab === 'serverWidget' && (
|
||||
<Suspense fallback={null}>
|
||||
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+43
-27
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Server } from '../types';
|
||||
import ping from './utils/jsping.js';
|
||||
import ping from './utils/jsping';
|
||||
|
||||
interface ServerWidgetProps {
|
||||
config: {
|
||||
@@ -25,42 +25,58 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
const serversRef = useRef(config.serverWidget.servers);
|
||||
serversRef.current = config.serverWidget.servers;
|
||||
|
||||
const serversSignature = config.serverWidget.servers
|
||||
.map(s => `${s.id}:${s.address}`)
|
||||
.join('|');
|
||||
const serverCount = config.serverWidget.servers.length;
|
||||
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.serverWidget.enabled) return;
|
||||
if (!config.serverWidget.enabled || serversRef.current.length === 0) return;
|
||||
|
||||
const pingServers = () => {
|
||||
const pending: Record<string, string> = {};
|
||||
for (const s of serversRef.current) pending[s.id] = 'pending';
|
||||
setServerStatus(prev => ({ ...prev, ...pending }));
|
||||
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;
|
||||
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
|
||||
|
||||
serversRef.current.forEach((server) => {
|
||||
ping(server.address)
|
||||
.then(() => {
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
|
||||
);
|
||||
});
|
||||
});
|
||||
const pingServers = async () => {
|
||||
if (cancelled || document.visibilityState !== 'visible') return;
|
||||
const cycle = new AbortController();
|
||||
controller = cycle;
|
||||
const servers = serversRef.current;
|
||||
const statuses: Record<string, string> = {};
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, async () => {
|
||||
while (next < servers.length && !cycle.signal.aborted) {
|
||||
const server = servers[next++];
|
||||
try {
|
||||
await ping(server.address, 1, cycle.signal);
|
||||
statuses[server.id] = 'online';
|
||||
} catch {
|
||||
statuses[server.id] = 'offline';
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (cancelled || cycle.signal.aborted) return;
|
||||
setServerStatus(previous => servers.every(server => previous[server.id] === statuses[server.id])
|
||||
? previous : statuses);
|
||||
timer = setTimeout(() => void pingServers(), frequency);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
if (document.visibilityState === 'visible') void pingServers();
|
||||
};
|
||||
|
||||
pingServers();
|
||||
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
||||
return () => clearInterval(interval);
|
||||
void pingServers();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [
|
||||
config.serverWidget.enabled,
|
||||
config.serverWidget.pingFrequency,
|
||||
serversSignature,
|
||||
serverCount,
|
||||
]);
|
||||
|
||||
if (!config.serverWidget.enabled) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
|
||||
import { ConfigurationService } from './services/ConfigurationService';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
@@ -17,6 +18,7 @@ const MAX_WALLPAPER_URL_CACHE = 3;
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||
if (resolved?.startsWith('data:')) return;
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||
const oldest = wallpaperUrlCache.keys().next().value;
|
||||
@@ -35,8 +37,7 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
||||
resolved = foundInBase.url || foundInBase.base64;
|
||||
} else {
|
||||
try {
|
||||
const storedUserWallpapers: WallpaperType[] =
|
||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||
const storedUserWallpapers = ConfigurationService.loadUserWallpapers();
|
||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||
if (foundInUser) {
|
||||
resolved = foundInUser.url || foundInUser.base64;
|
||||
@@ -71,6 +72,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
const updateWallpaper = async () => {
|
||||
const request = ++revision;
|
||||
clearTimeout(timer);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (wallpaperNames.length === 0) {
|
||||
setImageUrl(undefined);
|
||||
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
|
||||
@@ -109,6 +111,10 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') refresh();
|
||||
else {
|
||||
++revision;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
|
||||
@@ -124,6 +130,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
wallpaperUrlCache.clear();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
|
||||
@@ -14,20 +14,9 @@ interface WebsiteEditModalProps {
|
||||
interface IconMetadata {
|
||||
name: string;
|
||||
base: string;
|
||||
aliases: string[];
|
||||
categories: string[];
|
||||
update: {
|
||||
timestamp: string;
|
||||
author: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
colors: any;
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
let iconMetadataCache: IconMetadata[] | null = null;
|
||||
|
||||
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
|
||||
@@ -37,34 +26,45 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||
const metadataRequest = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
iconMetadataCache = null;
|
||||
metadataRequest.current?.abort();
|
||||
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
return;
|
||||
}
|
||||
if (iconsFetched) return;
|
||||
setIconsFetched(true);
|
||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||
.then(response => response.json())
|
||||
if (iconMetadata.length > 0 || metadataRequest.current) return;
|
||||
const controller = new AbortController();
|
||||
metadataRequest.current = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10_000);
|
||||
fetch(new URL('icon-metadata.json', document.baseURI).href, { cache: 'force-cache', signal: controller.signal })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Icon metadata is unavailable');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
|
||||
name,
|
||||
...(details as object),
|
||||
})) as IconMetadata[];
|
||||
iconMetadataCache = iconsArray;
|
||||
if (controller.signal.aborted) return;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid icon metadata');
|
||||
const iconsArray: IconMetadata[] = Object.entries(data).flatMap(([name, details]) => {
|
||||
if (!details || typeof details !== 'object' || !('base' in details) || typeof details.base !== 'string') return [];
|
||||
const colors = 'colors' in details && details.colors && typeof details.colors === 'object'
|
||||
? Object.values(details.colors).filter((color): color is string => typeof color === 'string' && color !== name) : [];
|
||||
return [{ name, base: details.base, colors }];
|
||||
});
|
||||
setIconMetadata(iconsArray);
|
||||
})
|
||||
.catch(err => console.error('Failed to load icon metadata', err));
|
||||
.catch(err => {
|
||||
if (!controller.signal.aborted) console.error('Failed to load icon metadata', err);
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timeout);
|
||||
if (metadataRequest.current === controller) metadataRequest.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,9 +78,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
filtered.push(ic);
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
if (ic.colors && typeof ic.colors === 'object') {
|
||||
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||
for (const color of colors as string[]) {
|
||||
if (ic.colors.length > 0) {
|
||||
for (const color of ic.colors) {
|
||||
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push({ ...ic, name: color });
|
||||
if (filtered.length >= 50) break;
|
||||
@@ -161,6 +160,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
key={`${iconData.name}-${index}`}
|
||||
onClick={() => {
|
||||
setIcon(getIconPickUrl(iconData));
|
||||
setIconQuery('');
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||
|
||||
@@ -18,6 +18,12 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
const [iconSource, setIconSource] = useState<string | null>(null);
|
||||
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const resetLoading = () => setIsLoading(false);
|
||||
window.addEventListener('pageshow', resetLoading);
|
||||
return () => window.removeEventListener('pageshow', resetLoading);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -50,7 +56,10 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
}, [website.icon]);
|
||||
|
||||
const handleIconError = () => {
|
||||
if (!usingCachedIcon) return;
|
||||
if (!usingCachedIcon) {
|
||||
setIconSource(null);
|
||||
return;
|
||||
}
|
||||
setIconSource(website.icon);
|
||||
setUsingCachedIcon(false);
|
||||
void removeCachedWebsiteIcon(website.icon);
|
||||
@@ -61,11 +70,12 @@ 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) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||
const iconSize = isLoading ? getIconLoadingPixelSize(tileSize) : getIconPixelSize(tileSize);
|
||||
|
||||
return (
|
||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
|
||||
@@ -86,14 +96,17 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
|
||||
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||
{iconSource && (
|
||||
<div className="flex items-center justify-center transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)]" style={{ width: iconSize, height: iconSize }}>
|
||||
{iconSource ? (
|
||||
<img
|
||||
src={iconSource}
|
||||
alt={`${website.name} icon`}
|
||||
className="object-contain w-full h-full"
|
||||
decoding="async"
|
||||
onError={handleIconError}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</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' : ''}`}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config, Server } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
import { createId } from '../utils/idUtils';
|
||||
|
||||
interface ServerWidgetTabProps {
|
||||
config: Config;
|
||||
@@ -21,9 +22,9 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
const handleAddServer = () => {
|
||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||
const newServer: Server = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerName,
|
||||
address: newServerAddress,
|
||||
id: createId(),
|
||||
name: newServerName.trim(),
|
||||
address: newServerAddress.trim(),
|
||||
};
|
||||
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
|
||||
setNewServerName('');
|
||||
@@ -36,7 +37,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (result: any) => {
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const items = Array.from(config.serverWidget.servers);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
|
||||
@@ -179,13 +179,14 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
<span className="font-semibold">Click to upload</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
import { Category, Config, Wallpaper } from '../../types';
|
||||
import { DEFAULT_CATEGORIES } from '../../constants';
|
||||
import { baseWallpapers } from '../utils/baseWallpapers';
|
||||
import {
|
||||
addWallpaperToChromeStorageLocal,
|
||||
checkChromeStorageLocalAvailable,
|
||||
@@ -53,21 +55,88 @@ const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
const storedObj = isPlainObject(stored) ? stored : {};
|
||||
for (const key of Object.keys(result)) {
|
||||
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
|
||||
result[key] = deepMerge(result[key], storedObj[key]);
|
||||
}
|
||||
result[key] = deepMerge(result[key], Object.prototype.hasOwnProperty.call(storedObj, key) ? storedObj[key] : undefined);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
if (Array.isArray(base)) {
|
||||
return (Array.isArray(stored) ? stored : base) as T;
|
||||
return [...(Array.isArray(stored) ? stored : base)] as T;
|
||||
}
|
||||
if (stored === null) return base;
|
||||
return typeof stored === typeof base ? (stored as T) : base;
|
||||
};
|
||||
|
||||
export const normalizeConfig = (stored: unknown): Config =>
|
||||
deepMerge(DEFAULT_CONFIG, stored);
|
||||
const boundedNumber = (value: number, fallback: number, min: number, max: number): number =>
|
||||
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
||||
|
||||
const choice = (value: string, values: string[], fallback: string): string =>
|
||||
values.includes(value) ? value : fallback;
|
||||
|
||||
const hasStrings = (value: unknown, keys: string[]): value is Record<string, string> =>
|
||||
isPlainObject(value) && keys.every(key => typeof value[key] === 'string');
|
||||
|
||||
export const normalizeConfig = (stored: unknown): Config => {
|
||||
const config = deepMerge(DEFAULT_CONFIG, stored);
|
||||
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
|
||||
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
|
||||
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
|
||||
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
|
||||
config.titleSize = choice(config.titleSize, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.titleSize);
|
||||
config.tileSize = choice(config.tileSize || 'medium', ['small', 'medium', 'large'], 'medium');
|
||||
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
|
||||
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
|
||||
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
|
||||
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
|
||||
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
|
||||
const serverIds = new Set<string>();
|
||||
config.serverWidget.servers = config.serverWidget.servers.filter(server => {
|
||||
if (!hasStrings(server, ['id', 'name', 'address']) || !server.id || serverIds.has(server.id)) return false;
|
||||
serverIds.add(server.id);
|
||||
return true;
|
||||
}).map(server => ({ id: server.id, name: server.name, address: server.address }));
|
||||
return config;
|
||||
};
|
||||
|
||||
export const normalizeCategories = (stored: unknown): Category[] => {
|
||||
if (!Array.isArray(stored)) return normalizeCategories(DEFAULT_CATEGORIES);
|
||||
const categoryIds = new Set<string>();
|
||||
const websiteIds = new Set<string>();
|
||||
return stored.flatMap(category => {
|
||||
if (!hasStrings(category, ['id', 'name']) || !category.id || categoryIds.has(category.id)) return [];
|
||||
categoryIds.add(category.id);
|
||||
const websites = isPlainObject(category) && Array.isArray(category.websites) ? category.websites : [];
|
||||
return [{
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
websites: websites.flatMap(website => {
|
||||
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
|
||||
websiteIds.add(website.id);
|
||||
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
|
||||
}),
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
|
||||
if (!Array.isArray(stored)) return [];
|
||||
const names = new Set(baseWallpapers.map(wallpaper => wallpaper.name));
|
||||
return stored.flatMap(wallpaper => {
|
||||
if (!hasStrings(wallpaper, ['name']) || !wallpaper.name || names.has(wallpaper.name)) return [];
|
||||
names.add(wallpaper.name);
|
||||
return [{
|
||||
name: wallpaper.name,
|
||||
...(typeof wallpaper.url === 'string' ? { url: wallpaper.url } : {}),
|
||||
...(typeof wallpaper.base64 === 'string' ? { base64: wallpaper.base64 } : {}),
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const assertWallpaperNameAvailable = (name: string): void => {
|
||||
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
|
||||
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
|
||||
throw new Error('A wallpaper with this name already exists or the name is reserved. Please choose a different name.');
|
||||
}
|
||||
};
|
||||
|
||||
export const ConfigurationService = {
|
||||
loadConfig(): Config {
|
||||
@@ -80,19 +149,31 @@ export const ConfigurationService = {
|
||||
} catch (error) {
|
||||
console.error('Error parsing config from localStorage', error);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
return normalizeConfig(null);
|
||||
},
|
||||
|
||||
saveConfig(config: Config): void {
|
||||
try {
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
} catch (error) {
|
||||
console.error('Error saving config to localStorage', error);
|
||||
}
|
||||
},
|
||||
|
||||
loadCategories(): Category[] {
|
||||
try {
|
||||
return normalizeCategories(safeParse(localStorage.getItem('categories')));
|
||||
} catch (error) {
|
||||
console.error('Error reading categories from localStorage', error);
|
||||
return normalizeCategories(null);
|
||||
}
|
||||
},
|
||||
|
||||
loadUserWallpapers(): Wallpaper[] {
|
||||
try {
|
||||
const stored = localStorage.getItem('userWallpapers');
|
||||
if (stored) return JSON.parse(stored);
|
||||
if (stored) return normalizeUserWallpapers(JSON.parse(stored));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
},
|
||||
@@ -113,10 +194,13 @@ export const ConfigurationService = {
|
||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||
}
|
||||
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||
assertWallpaperNameAvailable(finalName);
|
||||
return { name: finalName, url: parsedUrl.href };
|
||||
},
|
||||
|
||||
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.'));
|
||||
@@ -130,6 +214,7 @@ export const ConfigurationService = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertWallpaperNameAvailable(file.name.trim());
|
||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||
resolve({ name: finalName });
|
||||
} catch (error) {
|
||||
@@ -172,6 +257,7 @@ export const ConfigurationService = {
|
||||
},
|
||||
|
||||
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||
if (file.size > 10 * 1024 * 1024) throw new Error('Configuration files must be no larger than 10MB.');
|
||||
const fileContent = await file.text();
|
||||
const parsed = JSON.parse(fileContent);
|
||||
const localStorageData =
|
||||
@@ -179,30 +265,47 @@ export const ConfigurationService = {
|
||||
? parsed.localStorage
|
||||
: parsed;
|
||||
|
||||
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||
if (!isPlainObject(localStorageData)) {
|
||||
throw new Error('Invalid import file format.');
|
||||
}
|
||||
|
||||
let importedAny = false;
|
||||
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||
const updates = new Map<RequiredLocalStorageKey, string>();
|
||||
for (const key of REQUIRED_LOCAL_STORAGE_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||
localStorage.setItem(key, toStorageString(rawValue));
|
||||
importedAny = true;
|
||||
const raw = localStorageData[key];
|
||||
const value = typeof raw === 'string' ? safeParse(raw) : raw;
|
||||
if (value === null) continue;
|
||||
if ((key === 'config' || key === 'wallpaperState') && !isPlainObject(value)) {
|
||||
throw new Error(`Invalid ${key} in import file.`);
|
||||
}
|
||||
if ((key === 'categories' || key === 'userWallpapers') && !Array.isArray(value)) {
|
||||
throw new Error(`Invalid ${key} in import file.`);
|
||||
}
|
||||
const normalized = key === 'config' ? normalizeConfig(value)
|
||||
: key === 'categories' ? normalizeCategories(value)
|
||||
: key === 'userWallpapers' ? normalizeUserWallpapers(value) : value;
|
||||
updates.set(key, toStorageString(normalized));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!importedAny) {
|
||||
if (updates.size === 0) {
|
||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||
}
|
||||
|
||||
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||
.userWallpapers;
|
||||
const previous = new Map([...updates.keys()].map(key => [key, localStorage.getItem(key)]));
|
||||
try {
|
||||
for (const [key, value] of updates) localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
for (const key of updates.keys()) localStorage.removeItem(key);
|
||||
for (const [key, value] of previous) {
|
||||
if (value !== null) localStorage.setItem(key, value);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
config: normalizeConfig(importedConfig),
|
||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||
config: this.loadConfig(),
|
||||
userWallpapers: this.loadUserWallpapers(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -142,7 +142,7 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
|
||||
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([name], cb),
|
||||
);
|
||||
return result[name] || null;
|
||||
return typeof result[name] === 'string' ? result[name] || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,15 +7,25 @@ import {
|
||||
|
||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||
const MAX_RESOLVED_ICONS = 50;
|
||||
const MAX_RESOLVED_ICON_MEMORY = 2 * 1024 * 1024;
|
||||
const MAX_CONCURRENT_ICON_REQUESTS = 4;
|
||||
let resolvedIconBytes = 0;
|
||||
let activeIconRequests = 0;
|
||||
const iconRequestQueue: (() => void)[] = [];
|
||||
const resolvedIconCache = new Map<string, string>();
|
||||
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
|
||||
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(iconUrl);
|
||||
resolvedIconCache.set(iconUrl, dataUrl);
|
||||
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||
resolvedIconBytes += dataUrl.length * 2;
|
||||
while (resolvedIconCache.size > MAX_RESOLVED_ICONS || resolvedIconBytes > MAX_RESOLVED_ICON_MEMORY) {
|
||||
const oldest = resolvedIconCache.keys().next().value;
|
||||
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||
if (oldest === undefined) break;
|
||||
resolvedIconBytes -= (resolvedIconCache.get(oldest)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,7 +41,33 @@ const isCacheableIconUrl = (value: string): boolean => {
|
||||
};
|
||||
|
||||
const isValidCachedIcon = (value: string | null): value is string =>
|
||||
typeof value === 'string' && isDataUrl(value);
|
||||
typeof value === 'string' && value.startsWith('data:image/') && value.length <= Math.ceil(MAX_CACHED_ICON_BYTES * 4 / 3) + 256;
|
||||
|
||||
const readIconBlob = async (response: Response): Promise<Blob | null> => {
|
||||
const contentType = (response.headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase();
|
||||
if (!contentType.startsWith('image/') || Number(response.headers.get('content-length')) > MAX_CACHED_ICON_BYTES || !response.body) {
|
||||
await response.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_CACHED_ICON_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value as Uint8Array<ArrayBuffer>);
|
||||
}
|
||||
return bytes > 0 ? new Blob(chunks, { type: contentType }) : null;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
@@ -134,6 +170,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
rememberResolvedIcon(iconUrl, cachedIcon);
|
||||
return cachedIcon;
|
||||
}
|
||||
if (cachedIcon) void removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||
return null;
|
||||
})
|
||||
.catch(() => null)
|
||||
@@ -153,6 +190,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
|
||||
const existingRequest = iconCacheRequests.get(iconUrl);
|
||||
if (existingRequest) return existingRequest;
|
||||
if (iconCacheRequests.size >= MAX_RESOLVED_ICONS) return null;
|
||||
|
||||
const request = (async () => {
|
||||
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
||||
@@ -161,18 +199,20 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
const fetchSource = getIconFetchSource(iconUrl);
|
||||
if (!fetchSource) return null;
|
||||
|
||||
if (activeIconRequests >= MAX_CONCURRENT_ICON_REQUESTS) {
|
||||
await new Promise<void>(resolve => iconRequestQueue.push(resolve));
|
||||
} else {
|
||||
activeIconRequests++;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchSource);
|
||||
const response = await fetch(fetchSource, { signal: controller.signal, credentials: 'omit' });
|
||||
if (!response.ok || response.type === 'opaque') return null;
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentType = (blob.type || response.headers.get('content-type') || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!contentType.startsWith('image/') || blob.size === 0 || blob.size > MAX_CACHED_ICON_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const blob = await readIconBlob(response);
|
||||
if (!blob) return null;
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
rememberResolvedIcon(iconUrl, dataUrl);
|
||||
@@ -180,6 +220,12 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
return dataUrl;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
const nextRequest = iconRequestQueue.shift();
|
||||
if (nextRequest) nextRequest();
|
||||
else activeIconRequests--;
|
||||
}
|
||||
})().finally(() => {
|
||||
iconCacheRequests.delete(iconUrl);
|
||||
@@ -190,6 +236,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
|
||||
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
|
||||
resolvedIconCache.delete(iconUrl);
|
||||
iconCacheLookups.delete(iconUrl);
|
||||
await removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const createId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
@@ -45,7 +45,11 @@ export const saveWallpaperState = (names: string[], currentIndex: number, lastCh
|
||||
currentName: names[currentIndex],
|
||||
lastWallpaperChange: new Date(lastChange).toISOString(),
|
||||
});
|
||||
try {
|
||||
if (localStorage.getItem('wallpaperState') !== payload) {
|
||||
localStorage.setItem('wallpaperState', payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving wallpaper state', error);
|
||||
}
|
||||
};
|
||||
@@ -56,7 +56,6 @@ body {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform: scale(1.015);
|
||||
will-change: filter, opacity, transform;
|
||||
}
|
||||
|
||||
.wallpaper-luminance {
|
||||
@@ -368,7 +367,6 @@ body {
|
||||
background 260ms var(--ease-ios),
|
||||
border-color 260ms var(--ease-ios),
|
||||
box-shadow 340ms var(--ease-liquid);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.liquid-tile:hover {
|
||||
|
||||
+33
-19
@@ -1,6 +1,6 @@
|
||||
---
|
||||
project_name: Vision Start
|
||||
date: 2026-08-11
|
||||
date: 2026-10-09
|
||||
type: general_overview
|
||||
---
|
||||
|
||||
@@ -54,7 +54,7 @@ The startpage is composed of widgets and a configuration panel:
|
||||
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
|
||||
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||
- **Title** — Optional big header title (text + size configurable).
|
||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Probes use cancellable, credential-free HTTP HEAD requests with a 5s timeout. Online means the browser received a response; cross-origin HTTP status codes are opaque, so this is reachability rather than an HTTP health check. Network failures, timeouts, and browser-blocked requests appear offline.
|
||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
|
||||
@@ -65,14 +65,16 @@ The startpage is composed of widgets and a configuration panel:
|
||||
|
||||
Performance notes:
|
||||
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ServerWidgetTab` is independently lazy-loaded, so its `@hello-pangea/dnd` dependency is loaded only when the Server Widget settings tab is selected. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||
- The website icon service keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
|
||||
- `Clock` schedules one timeout for the next minute boundary, pauses while hidden, and refreshes immediately on focus or visibility return.
|
||||
- `jsping` uses HTTP HEAD without downloading or decoding probe images, propagates failures, and cleans up its timeout and abort listener.
|
||||
- `ServerWidget` runs at most four probes concurrently, batches completed results into one state update, avoids overlapping polling cycles, cancels work when hidden or unmounted, and depends on a stable ids/addresses signature. Hidden tabs stop polling and refresh on visibility return; removed servers leave no retained status entries.
|
||||
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. Only names, file formats, and color variants are retained in component state; requests are aborted on close, failed loads can retry, and metadata URLs resolve relative to the page so subdirectory deployments work (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||
- `Wallpaper` resolves remote wallpaper URLs through a module-level `Map` bounded to the last 3 entries. Uploaded data URLs are excluded from that cache, and the cache clears on unmount; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||
- The website icon service bounds its extra resolved data-URL cache to 50 entries and 2MiB of string storage (conservatively counted at two bytes per character). Cache downloads run at most four at a time, with at most 50 outstanding requests and an 8s timeout. Streaming reads stop at 256KiB before decoding; invalid or oversized persisted entries are discarded. Tile images still retain the data they display, separate from this extra cache.
|
||||
- Tile icon dimensions use numeric inline styles, independent of generated Tailwind classes. Failed icons show the website initial, modified link clicks avoid navigation spinners, and browser history restoration clears the loading state. Category mutations preserve unaffected object references and use stable functional state handlers. Shared IDs use UUIDs with a fallback for ordinary HTTP deployments.
|
||||
- Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers.
|
||||
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||
|
||||
Planned / To-do (tracked in `README.md`):
|
||||
@@ -125,8 +127,9 @@ vision-start/
|
||||
│ └── utils/
|
||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
||||
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||
│ ├── jsping.ts # Cancellable HTTP HEAD reachability probe with 5s timeout
|
||||
│ ├── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
||||
│ ├── idUtils.ts # Shared UUID generation with an ordinary-HTTP fallback
|
||||
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
|
||||
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
|
||||
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths)
|
||||
@@ -137,6 +140,7 @@ vision-start/
|
||||
│
|
||||
├── screenshots/ # README/release screenshots (home, editing, configuration; regenerated at 1280×800)
|
||||
├── scripts/
|
||||
│ ├── check_regressions.mjs # Persistence/network regressions, browser memory checks, unpacked-extension smoke test
|
||||
│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL
|
||||
│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots
|
||||
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
|
||||
@@ -152,7 +156,7 @@ vision-start/
|
||||
├── .dockerignore
|
||||
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||
├── tailwind.config.js # Content globs + safelist of dynamic w-/h- sizes
|
||||
├── tailwind.config.js # Content globs + legacy pixel-size safelist
|
||||
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
|
||||
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
|
||||
├── package.json # Scripts: dev, build, preview
|
||||
@@ -174,7 +178,7 @@ The shape of all persisted data lives in `types.ts`:
|
||||
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
||||
|
||||
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
|
||||
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. Nested defaults are cloned, option values and numeric ranges are validated, and malformed wallpaper/server entries are filtered. Categories and the user wallpaper index are normalized before rendering; website category IDs are repaired to match their containing category. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
|
||||
|
||||
Storage layout (browser-side):
|
||||
|
||||
@@ -194,7 +198,7 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper
|
||||
## 6. Application Flow (high level)
|
||||
|
||||
1. `index.html` loads `index.tsx`, which mounts `<App/>` into `#root`.
|
||||
2. `App.tsx` initializes state from `localStorage` (`categories`) and `ConfigurationService.loadConfig()` (`config`), falling back to defaults.
|
||||
2. `App.tsx` initializes normalized categories and configuration through `ConfigurationService`, falling back to independent copies of defaults.
|
||||
3. `useEffect` hooks persist `config` and `categories` back to `localStorage` whenever they change.
|
||||
4. The screen renders:
|
||||
- `<Wallpaper>` behind everything (fetches URL/base64 from base catalog or chrome.storage.local; rotates per frequency).
|
||||
@@ -216,8 +220,14 @@ npm install
|
||||
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing)
|
||||
npm run build # Production build → dist/
|
||||
npm run preview # Serve built dist/
|
||||
npx tsc --noEmit # Strict TypeScript check
|
||||
npx playwright install chromium
|
||||
node scripts/check_regressions.mjs # Run after building; isolated fixtures and temporary extension profile
|
||||
npm audit # Audit the locked dependency tree
|
||||
```
|
||||
|
||||
The regression script covers malformed persisted data, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata.
|
||||
|
||||
### Chrome extension install (manual)
|
||||
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
|
||||
|
||||
@@ -226,7 +236,7 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image. The release ZIP contains the contents of `dist/` at its root alongside `manifest.json` and `extension/icons/`, so the manifest's `index.html` new-tab path resolves directly. Before upload, the workflow tests ZIP integrity, extracts it, and validates the manifest version (no leading zeros, components at most 65535, not all zero), metadata, new-tab page, and icon paths. This is the Chrome Web Store upload ZIP; screenshots remain separate release assets.
|
||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive with `dist/` contents, `manifest.json`, and `extension/` at its root, then unpacks that archive into `dist/` in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
||||
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
||||
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
||||
@@ -241,13 +251,15 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
## 8. Notable Behaviors & Quirks
|
||||
|
||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
|
||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which is independently lazy-loaded by `ConfigurationModal`, so opening General/Theme/Clock settings does not load the drag-and-drop chunk. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||
- **Import validation** accepts both export bundles and legacy objects containing serialized storage values. Files larger than 10MB are rejected before reading. Required keys are normalized and validated before writes; storage failures clear affected keys before restoring previous values so rollback also works near the quota limit. Omitted keys retain their existing settings. Importing marks the settings drawer as saved, preventing its cancel cleanup from restoring pre-import wallpapers.
|
||||
- **Wallpaper mutations** reject duplicate/built-in/reserved names and non-image file uploads. The settings drawer serializes wallpaper mutations and reads the latest persisted index, only updates its list after persistence succeeds, and cancel does not reselect deleted wallpapers.
|
||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||
- **Wallpaper rotation** uses a timeout scheduled from the persisted last-change timestamp, with checks on mount, focus, and return to a visible tab. Overdue backgrounds rotate once and start a new interval; frequency changes use elapsed time rather than restarting the countdown. Frequency is clamped to 1–48 hours and legacy `1d`/`2d` values remain supported. Invalid or future timestamps recover to the current time. State tracks the wallpaper name as well as its legacy index so reordering or shrinking the selection preserves the current wallpaper when possible. Missing data is skipped and an empty selection hides the background. Manual random changes exclude the current wallpaper and restart the interval. Opening settings does not reset rotation. Wallpaper state writes skip identical serialized values, and empty selections preserve the saved timestamp during refreshes to prevent repeated cross-tab updates. Storage events synchronize wallpaper changes across tabs; outdated asynchronous resolutions are discarded. URL caches are cleared when wallpaper inputs or stored wallpaper data change.
|
||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||
- **Icon picker** in `WebsiteEditModal` loads page-relative `icon-metadata.json` on focus, validates the response, and lazily expands color variants into up to 50 searchable results. Closing it cancels metadata fetching and filtering timers.
|
||||
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery.
|
||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||
- **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. The legacy pixel-size safelist remains in `tailwind.config.js`, but tile icons no longer depend on it.
|
||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
|
||||
|
||||
@@ -264,14 +276,16 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
| Main app wiring (state, handlers, layout) | `App.tsx` |
|
||||
| Settings UI | `components/ConfigurationModal.tsx` + `components/configuration/*Tab.tsx` |
|
||||
| Wallpaper rendering/rotation | `components/Wallpaper.tsx` |
|
||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.ts` |
|
||||
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
||||
| Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` |
|
||||
| Regression and memory checks | `scripts/check_regressions.mjs` (run after `npm run build`) |
|
||||
| Shared IDs | `components/utils/idUtils.ts` |
|
||||
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
||||
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
|
||||
| Docker build | `Dockerfile` |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-08-11. Generated as a general project overview; not a coding-style guide._
|
||||
_Last updated: 2026-10-09. Generated as a general project overview; not a coding-style guide._
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { build, preview } from 'vite';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const loadModule = async (entry) => {
|
||||
const result = await build({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
lib: { entry: resolve(entry), formats: ['es'] },
|
||||
},
|
||||
});
|
||||
const output = Array.isArray(result) ? result[0] : result;
|
||||
const code = output.output.find(chunk => chunk.type === 'chunk' && chunk.isEntry).code;
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||
};
|
||||
|
||||
const storage = new Map();
|
||||
const chromeStorage = new Map();
|
||||
let failWrite = null;
|
||||
let maximumStorageBytes = Infinity;
|
||||
let chromeReads = 0;
|
||||
globalThis.localStorage = {
|
||||
getItem: key => storage.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
if (key === failWrite) {
|
||||
failWrite = null;
|
||||
throw new Error('Quota exceeded');
|
||||
}
|
||||
const next = new Map(storage).set(key, value);
|
||||
if ([...next.values()].reduce((bytes, entry) => bytes + entry.length, 0) > maximumStorageBytes) throw new Error('Quota exceeded');
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: key => storage.delete(key),
|
||||
};
|
||||
globalThis.window = { chrome: { storage: { local: {
|
||||
get: (keys, callback) => {
|
||||
chromeReads++;
|
||||
callback(Object.fromEntries(keys.map(key => [key, chromeStorage.get(key)])));
|
||||
},
|
||||
set: (values, callback) => {
|
||||
Object.entries(values).forEach(([key, value]) => chromeStorage.set(key, value));
|
||||
callback();
|
||||
},
|
||||
remove: (key, callback) => { chromeStorage.delete(key); callback(); },
|
||||
} } } };
|
||||
globalThis.FileReader = class {
|
||||
readAsDataURL(blob) {
|
||||
blob.arrayBuffer().then(buffer => {
|
||||
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
|
||||
this.onloadend?.();
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
};
|
||||
const [{ ConfigurationService, DEFAULT_CONFIG, normalizeConfig, normalizeCategories }, icons, { default: ping }] = await Promise.all([
|
||||
loadModule('components/services/ConfigurationService.ts'),
|
||||
loadModule('components/utils/iconService.ts'),
|
||||
loadModule('components/utils/jsping.ts'),
|
||||
]);
|
||||
const file = value => new File([JSON.stringify(value)], 'config.json', { type: 'application/json' });
|
||||
const iconUrl = name => `https://www.google.com/s2/favicons?domain=${name}.com&sz=128`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
await test('malformed stored data recovers without shared defaults or unsafe polling rates', () => {
|
||||
const config = normalizeConfig({ currentWallpapers: [null, 'Beach', 'Beach'], serverWidget: { pingFrequency: -1, servers: [null, {}, { id: '1', name: 'Server', address: 'localhost' }] }, clock: { size: 'unknown' }, wallpaperBlur: Infinity });
|
||||
assert.deepEqual(config.currentWallpapers, ['Beach']);
|
||||
assert.equal(config.serverWidget.pingFrequency, 5);
|
||||
assert.equal(config.serverWidget.servers.length, 1);
|
||||
assert.equal(config.clock.size, 'medium');
|
||||
assert.equal(config.wallpaperBlur, 0);
|
||||
const defaults = normalizeConfig(null);
|
||||
defaults.clock.enabled = false;
|
||||
defaults.currentWallpapers.length = 0;
|
||||
assert.equal(DEFAULT_CONFIG.clock.enabled, true);
|
||||
assert.deepEqual(DEFAULT_CONFIG.currentWallpapers, ['Beach']);
|
||||
storage.set('categories', 'null');
|
||||
assert.equal(ConfigurationService.loadCategories()[0].name, 'Search');
|
||||
assert.deepEqual(normalizeCategories([]), []);
|
||||
assert.deepEqual(normalizeCategories([null, { id: 'c', name: 'Group', websites: [null, { id: 'w', name: 'Site', url: 'https://example.com', categoryId: 'wrong' }] }])[0].websites[0].categoryId, 'c');
|
||||
storage.set('userWallpapers', '{}');
|
||||
assert.deepEqual(ConfigurationService.loadUserWallpapers(), []);
|
||||
});
|
||||
|
||||
await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => {
|
||||
await assert.rejects(ConfigurationService.importConfig({ size: 10 * 1024 * 1024 + 1, text: () => assert.fail('Oversized files must not be read into memory') }), /10MB/);
|
||||
storage.clear();
|
||||
storage.set('config', JSON.stringify({ title: 'Original' }));
|
||||
storage.set('categories', '[]');
|
||||
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: {} })), /Invalid categories/);
|
||||
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
|
||||
failWrite = 'categories';
|
||||
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: [] })), /Quota/);
|
||||
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
|
||||
const partial = await ConfigurationService.importConfig(file({ categories: '[]' }));
|
||||
assert.equal(partial.config.title, 'Original');
|
||||
const legacy = await ConfigurationService.importConfig(file({ config: JSON.stringify({ title: 'Legacy', clock: { enabled: false } }) }));
|
||||
assert.equal(legacy.config.title, 'Legacy');
|
||||
assert.equal(legacy.config.clock.enabled, false);
|
||||
assert.equal(legacy.config.clock.size, 'medium');
|
||||
storage.set('config', JSON.stringify(normalizeConfig({ title: 'Original'.repeat(50) })));
|
||||
storage.set('userWallpapers', '[]');
|
||||
const previous = new Map(storage);
|
||||
maximumStorageBytes = [...storage.values()].reduce((bytes, value) => bytes + value.length, 0) + 32;
|
||||
try {
|
||||
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Small' }, categories: [{ id: 'c', name: 'Group'.repeat(40), websites: [] }], userWallpapers: [{ name: 'Large', url: `https://example.com/${'image'.repeat(200)}` }] })), /Quota/);
|
||||
assert.deepEqual(storage, previous);
|
||||
} finally { maximumStorageBytes = Infinity; }
|
||||
});
|
||||
|
||||
await test('wallpaper collisions and non-image uploads cannot overwrite storage', async () => {
|
||||
await assert.rejects(ConfigurationService.addWallpaper('Beach', 'https://example.com/image.png'), /already exists/);
|
||||
await assert.rejects(ConfigurationService.addWallpaper('vision-start:icon:reserved', 'https://example.com/image.png'), /reserved/);
|
||||
await assert.rejects(ConfigurationService.addWallpaperFile(new File(['text'], 'image.png', { type: 'text/plain' })), /image file/);
|
||||
});
|
||||
|
||||
await test('server probes reject network failures, timeouts, and cancellation', async () => {
|
||||
try {
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
assert.equal(options.method, 'HEAD');
|
||||
assert.equal(options.credentials, 'omit');
|
||||
return new Response(null);
|
||||
};
|
||||
assert.equal(typeof await ping('localhost:8080'), 'number');
|
||||
globalThis.fetch = async () => { throw new Error('Network failure'); };
|
||||
await assert.rejects(ping('https://offline.example'), /Network failure/);
|
||||
await assert.rejects(ping('ftp://example.com'), /HTTP or HTTPS/);
|
||||
globalThis.fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
|
||||
if (signal.aborted) reject(new Error('Aborted'));
|
||||
else signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const request = ping('https://slow.example', 1, controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(request, /Aborted/);
|
||||
const originalTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = callback => { queueMicrotask(callback); return 0; };
|
||||
try { await assert.rejects(ping('https://slow.example'), /Aborted/); }
|
||||
finally { globalThis.setTimeout = originalTimeout; }
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
|
||||
await test('icon requests deduplicate, cap concurrency, reject oversized streams, and evict by bytes', async () => {
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
let requests = 0;
|
||||
try {
|
||||
globalThis.fetch = async () => {
|
||||
requests++;
|
||||
maximum = Math.max(maximum, ++active);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
active--;
|
||||
return new Response(new Uint8Array(200 * 1024), { headers: { 'content-type': 'image/png' } });
|
||||
};
|
||||
const results = await Promise.all(Array.from({ length: 12 }, (_, index) => icons.cacheWebsiteIcon(iconUrl(`site${index}`))));
|
||||
assert(results.every(result => result?.startsWith('data:image/png')));
|
||||
assert(maximum <= 4);
|
||||
const reads = chromeReads;
|
||||
await icons.getCachedWebsiteIcon(iconUrl('site0'));
|
||||
assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache');
|
||||
const before = requests;
|
||||
await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]);
|
||||
assert.equal(requests - before, 1);
|
||||
let cancelled = false;
|
||||
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||
pull(controller) { controller.enqueue(new Uint8Array(128 * 1024)); },
|
||||
cancel() { cancelled = true; },
|
||||
}), { headers: { 'content-type': 'image/png' } });
|
||||
assert.equal(await icons.cacheWebsiteIcon(iconUrl('oversized')), null);
|
||||
assert.equal(cancelled, true);
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
|
||||
await test('production UI survives malformed data, distinguishes server status, and releases modal memory', async () => {
|
||||
const server = await preview({ logLevel: 'silent', preview: { host: '127.0.0.1', port: 0, strictPort: true } });
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const address = server.httpServer.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const context = await browser.newContext();
|
||||
const errors = [];
|
||||
const page = await context.newPage();
|
||||
await page.clock.install();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('config', JSON.stringify({ currentWallpapers: [], serverWidget: { enabled: true, pingFrequency: 5, servers: [null, { id: 'on', name: 'Online', address: 'https://online.example' }, { id: 'off', name: 'Offline', address: 'https://offline.example' }] } }));
|
||||
localStorage.setItem('categories', JSON.stringify([null, { id: 'c', name: 'Group', websites: [{ id: 'a', name: 'Alpha', url: 'https://example.com', icon: '' }, { id: 'b', name: 'Beta', url: 'https://example.org', icon: '' }] }]));
|
||||
localStorage.setItem('userWallpapers', 'null');
|
||||
});
|
||||
let onlineProbes = 0;
|
||||
await context.route('https://online.example/**', route => {
|
||||
onlineProbes++;
|
||||
return route.fulfill({ status: 200, body: '' });
|
||||
});
|
||||
await context.route('https://offline.example/**', route => route.abort('connectionfailed'));
|
||||
const metadata = Object.fromEntries(Array.from({ length: 4000 }, (_, index) => [`service-${index}`, { base: 'svg', colors: { blue: `service-${index}-blue` }, aliases: ['unused'.repeat(100)], update: { author: { name: 'unused'.repeat(100) } } }]));
|
||||
let metadataLoads = 0;
|
||||
let delayMetadata = false;
|
||||
let releaseMetadata = () => {};
|
||||
await context.route('**/icon-metadata.json', async route => {
|
||||
metadataLoads++;
|
||||
if (delayMetadata) await new Promise(resolve => { releaseMetadata = resolve; });
|
||||
return route.fulfill({ json: metadata });
|
||||
});
|
||||
await context.route('https://cdn.jsdelivr.net/**', route => route.abort());
|
||||
await page.goto(baseUrl);
|
||||
await page.getByRole('link', { name: 'Open Alpha' }).waitFor();
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')) && [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-red-400')));
|
||||
const beforeHidden = onlineProbes;
|
||||
const clockBeforeHidden = await page.locator('.liquid-clock-text').textContent();
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.clock.fastForward(120_000);
|
||||
assert.equal(onlineProbes, beforeHidden);
|
||||
assert.equal(await page.locator('.liquid-clock-text').textContent(), clockBeforeHidden);
|
||||
const resumedProbe = page.waitForRequest('https://online.example/');
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await resumedProbe;
|
||||
await page.waitForFunction(before => document.querySelector('.liquid-clock-text').textContent !== before, clockBeforeHidden);
|
||||
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')));
|
||||
const downloaded = [];
|
||||
page.on('request', request => downloaded.push(request.url()));
|
||||
await page.getByRole('button', { name: 'Open configuration' }).click();
|
||||
await page.getByRole('dialog').waitFor();
|
||||
assert(!downloaded.some(url => url.includes('ServerWidgetTab.js')));
|
||||
await page.getByRole('button', { name: 'Server Widget', exact: true }).click();
|
||||
await page.getByPlaceholder('Server Name').waitFor();
|
||||
assert(downloaded.some(url => url.includes('ServerWidgetTab.js')));
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: 'Edit page' }).click();
|
||||
await page.getByRole('button', { name: 'Move Beta left' }).click();
|
||||
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']);
|
||||
const openPicker = async () => {
|
||||
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
|
||||
const input = page.getByPlaceholder('Icon URL or name');
|
||||
await input.fill('service');
|
||||
await input.focus();
|
||||
await page.locator('.liquid-dropdown-list').waitFor();
|
||||
assert.equal(await page.locator('.liquid-dropdown-list img').count(), 50);
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await page.getByPlaceholder('Icon URL or name').waitFor({ state: 'hidden' });
|
||||
};
|
||||
await openPicker();
|
||||
const session = await context.newCDPSession(page);
|
||||
const heap = async () => {
|
||||
await session.send('HeapProfiler.collectGarbage');
|
||||
return (await session.send('Runtime.getHeapUsage')).usedSize;
|
||||
};
|
||||
const before = await heap();
|
||||
for (let cycle = 0; cycle < 15; cycle++) await openPicker();
|
||||
const growth = (await heap()) - before;
|
||||
assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`);
|
||||
assert.equal(metadataLoads, 16);
|
||||
await page.evaluate(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = (url, options) => {
|
||||
if (String(url).endsWith('/icon-metadata.json')) window.metadataSignal = options.signal;
|
||||
return original(url, options);
|
||||
};
|
||||
});
|
||||
delayMetadata = true;
|
||||
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
|
||||
const pendingMetadata = page.waitForRequest('**/icon-metadata.json');
|
||||
await page.getByPlaceholder('Icon URL or name').focus();
|
||||
await pendingMetadata;
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await page.waitForFunction(() => window.metadataSignal?.aborted === true);
|
||||
releaseMetadata();
|
||||
delayMetadata = false;
|
||||
await openPicker();
|
||||
assert.equal(metadataLoads, 18);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`Retained JS heap change after 15 picker cycles: ${Math.round(growth / 1024)} KiB`);
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise(resolve => server.httpServer.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
await test('unpacked MV3 extension loads its new-tab page with working chrome storage', async () => {
|
||||
const temporary = await mkdtemp(resolve(tmpdir(), 'vision-start-check-'));
|
||||
const extension = resolve(temporary, 'extension');
|
||||
let context;
|
||||
try {
|
||||
await cp(resolve('dist'), extension, { recursive: true });
|
||||
await cp(resolve('manifest.json'), resolve(extension, 'manifest.json'));
|
||||
await cp(resolve('extension'), resolve(extension, 'extension'), { recursive: true });
|
||||
context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), {
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
||||
});
|
||||
await context.route(/^https?:/, route => route.abort());
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('chrome://newtab');
|
||||
await page.getByRole('button', { name: 'Open configuration' }).waitFor();
|
||||
const origin = await page.evaluate(() => document.location.origin);
|
||||
assert(origin.startsWith('chrome-extension://'), `Unexpected new-tab origin: ${origin}`);
|
||||
assert.equal(await page.evaluate(async () => {
|
||||
await chrome.storage.local.set({ regression: 'working' });
|
||||
return (await chrome.storage.local.get('regression')).regression;
|
||||
}), 'working');
|
||||
await page.getByRole('button', { name: 'Open configuration' }).click();
|
||||
await page.getByRole('dialog').waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await context?.close();
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user