feat: cache repository icon catalogs separately

This commit is contained in:
najdek committed 2026-10-13 13:20:02 +02:00
1 parent a5a1760070
commit 9e6c5918a7
50 files changed
+931 -100

No files matched your search

+3
View File
@@ -24,6 +24,7 @@ jobs:
- uses: actions/checkout@v4
- name: Check concurrent config, sources, history, autoload and logs with ThreadSanitizer
run: |
python3 tests/run_repository_icons_tests.py --thread-sanitize
python3 tests/run_config_tests.py --thread-sanitize
python3 tests/run_autoload_tests.py --thread-sanitize
python3 tests/run_log_stream_tests.py --thread-sanitize
@@ -50,6 +51,8 @@ jobs:
run: CHROME_BIN="$(command -v google-chrome)" node tests/run_autoload_editor_browser_tests.mjs --lifecycle
- name: Check markdown DOM security, offline rendering and locale selection
run: CHROME_BIN="$(command -v google-chrome)" node tests/run_markdown_locale_browser_tests.mjs
- name: Check repository icons and Settings cache clearing
run: CHROME_BIN="$(command -v google-chrome)" node tests/run_markdown_locale_browser_tests.mjs --repository-icons
- name: Build native HTTP fixture for browser security checks
run: docker build -f tests/Dockerfile -t pldmgr-browser-tests .
- name: Check Chrome actions, cross-origin attacks, unload beacons, and Vite proxy
+39 -7
View File
@@ -19,6 +19,7 @@ To create a repository, you must host a JSON file that conforms to the following
```json
{
"name": "My Custom Payloads",
"icons_url": "icons.json",
"payloads": [
{
"id": "ftpsrv",
@@ -31,7 +32,7 @@ To create a repository, you must host a JSON file that conforms to the following
"category": "Networking",
"checksum": "e6c1babbfd5e1b766d12b659853b514b9faedf6333cbe8cb514b1a3e79b7ce39",
"author": "hippie68",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPScjM2I4MmY2Jy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkZUUDwvdGV4dD48L3N2Zz4="
"icon_key": "ftp-server"
},
{
"id": "debug-tool",
@@ -61,7 +62,8 @@ To create a repository, you must host a JSON file that conforms to the following
| **`category`** (item-level) | String | No | The category name used to group payloads in the frontend (e.g. `"Networking"`). Defaults to `"Uncategorized"`. |
| **`checksum`** (item-level) | String | No | **SHA-256** hash: exactly 64 hexadecimal characters when nonempty. Missing/empty remains optional; malformed supplied values reject the entry. Every download attempt, including retries, is checked when a hash is supplied. If versions are identical but checksum differs, it is flagged as an update. |
| **`author`** (item-level) | String | No | Author or creator of the payload. |
| **`icon`** (item-level) | String | No | Inline base64 data URI icon (e.g. `data:image/svg+xml;base64,...` or `data:image/png;base64,...`). See [Payload Icons](#payload-icons) below. |
| **`icons_url`** (top-level) | String | No | Relative or absolute HTTP/HTTPS URL of a separate icon catalog. Requires the object wrapper. |
| **`icon_key`** (item-level) | String | No | Key in the separate catalog’s `icons` map, up to 127 UTF-8 bytes. Multiple payloads can share one key. |
| **`details_url`** (item-level) | String | No | URL to documentation or README markdown for the payload. |
| **`release_notes`** (item-level) | String | No | Markdown release notes for this version. The backend field holds up to 2047 UTF-8 bytes, with UTF-8-safe truncation and an ellipsis when exceeded. This is a byte budget, not 2000 Unicode characters. |
| **`source`** (item-level) | String | No | Link to the upstream source repository or releases page. |
@@ -70,11 +72,41 @@ To create a repository, you must host a JSON file that conforms to the following
### Payload Icons
- **Data URIs Only**: The `icon` field must be an inline data URI starting with `data:image/` (e.g., SVG `data:image/svg+xml;base64,...` or PNG `data:image/png;base64,...`).
- **No External Image URLs**: Direct HTTP/HTTPS image URLs (such as `https://example.com/icon.png`) are **not supported**. Inline data URIs are embedded directly inside the repository catalog, ensuring icons load instantly without extra network roundtrips, work 100% offline, and are unaffected by DNS blockers commonly configured on PS5 consoles.
- **Maximum Length**: The data URI must not exceed **12 KB** (~12,288 characters). Icons exceeding this limit are ignored.
- **Recommended Dimensions**: Square icons (e.g. 128x128 or 256x256) with rounded or transparent borders look best across the Dashboard, Autoload, and Repository views.
- **Automatic Fallback**: If `icon` is omitted, malformed, or exceeds the size limit, Payload Manager automatically renders a colorful gradient icon based on a hash of the payload's name.
Declare `icons_url` on the object wrapper, such as `"icons_url": "icons.json"`.
Relative URLs resolve against the payload catalog URL; absolute HTTP/HTTPS URLs
are also supported. HTTPS catalogs cannot downgrade to HTTP for icons. Sources
without `icons_url`, including bare arrays, use the normal generated fallback.
Inline item-level `icon` data is not supported.
The separate file has this shape:
```json
{
"icons": {
"ftp-server": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPScjM2I4MmY2Jy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkZUUDwvdGV4dD48L3N2Zz4="
}
}
```
Values must be base64 PNG, JPEG or SVG data URIs, each at most 12,288 characters.
Direct image URLs and WebP are unsupported. Use square images around 128×128.
An icon catalog must be smaller than 4 MiB, with at most 4,096 unique keys;
keys are nonempty and at most 127 UTF-8 bytes. Malformed JSON, duplicate keys, unsupported data URIs or exceeded limits reject
that icon update while preserving the last valid cache and the payload catalog.
Images that fail to decode in the browser use the generated fallback.
The PS5 caches each source's icon catalog persistently. It checks for a refresh
when the source is listed and the cache is seven days old, or when a referenced
key is missing. Changed `icons_url` values trigger a refresh too. Failed updates
and still-missing keys are retried at most hourly unless a different missing-key
set is discovered. Catalog refreshes do not otherwise force icon refreshes.
When a catalog refresh fails, a valid cached payload list remains available with
a source error indicator. Cached icons are served locally to the browser, separately from payload metadata;
the browser reuses them while their content revision is unchanged.
**Settings → Repository Cache → Clear Cache** removes downloaded catalogs, icons
and their freshness records, then reloads them. Installed payloads, configured
sources, favorites, autoload and settings are preserved.
## Version Comparison Rules
+22 -2
View File
@@ -319,8 +319,8 @@ normally uses **5173** with its same-origin proxy.
| Request class | Method and behavior |
| --- | --- |
| Read APIs | GET: `/list_payloads`, `/get_config`, `/processes_list`, `/history_list`, `/repository_payloads`, `/sources_list`, `/autoload_status`, `/log`, `/events`, `/version`, `/getip`, `/manage:check`, `/usb_move_check`. Ordinary repository reads may refresh their cache. Anonymous/foreign autoload status reads do not signal frontend readiness. |
| Explicit mutations | POST: `/manage:upload`, `/manage:delete`, `/loadpayload:<slot-or-path>`, `/set_config`, `/abort`, `/autoload_clear`, `/repository_refresh`, `/repository_install`, `/sources_set`, `/sources_add`, `/sources_remove`, `/usb_move_perform`, `/process_kill`, `/shutdown`. GET does not perform these actions. |
| Read APIs | GET: `/list_payloads`, `/get_config`, `/processes_list`, `/history_list`, `/repository_payloads`, `/repository_icons?source=<source-id>`, `/sources_list`, `/autoload_status`, `/log`, `/events`, `/version`, `/getip`, `/manage:check`, `/usb_move_check`. Ordinary repository reads may refresh their cache. Anonymous/foreign autoload status reads do not signal frontend readiness. |
| Explicit mutations | POST: `/manage:upload`, `/manage:delete`, `/loadpayload:<slot-or-path>`, `/set_config`, `/abort`, `/autoload_clear`, `/repository_refresh`, `/repository_cache_clear`, `/repository_install`, `/sources_set`, `/sources_add`, `/sources_remove`, `/usb_move_perform`, `/process_kill`, `/shutdown`. GET does not perform these actions. |
| Preflight | OPTIONS is validated against the requesting origin, route/method and supported headers. Foreign origins do not gain mutation access. |
| Removed updater compatibility | GET `/update_check` returns `{"status":"none"}`. It does not check, download or apply updates. |
@@ -353,3 +353,23 @@ remaps and multi-file settings updates have the limits described above.
Automated host/browser checks use stubs and local transports. They do not prove
PS5 kernel ABI, native TLS behavior, fonts/layout, rest/resume or the external
loader. Verify these on the target console after runtime changes.
## Repository icon caching
Catalogs use `icons_url` and item-level `icon_key`; see
[CUSTOM_REPOSITORIES.md](CUSTOM_REPOSITORIES.md) for the format and refresh policy.
The daemon stores `repo_icons_<source-url-sha256>.json` and `.meta` alongside
`repo_cache_*.json`. Catalog/icon refresh, icon snapshots and cache clearing share
a mutex. Icon downloads are bounded and validated before atomic publication;
failed updates preserve good data and use a one-hour retry delay.
`GET /repository_icons?source=<configured-id>` serves the local icon catalog.
It cannot fetch an arbitrary requested URL. `POST /repository_cache_clear` uses
the existing mutation-origin policy and deletes only recognized cache filenames.
`python3 tests/run_repository_icons_tests.py` checks the production caching code
with recorded downloads and sanitizers, without contacting a console or WAN.
`node tests/run_markdown_locale_browser_tests.mjs --repository-icons` checks the
actual repository provider, rendered images and Settings action in Chrome, with
all external requests blocked. Mock repository fixtures use `test_repo.json` and
`test_icons.json`.
+1
View File
@@ -56,6 +56,7 @@ test-host:
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_json_size_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_allocation_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_integrity_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_repository_icons_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_storage_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_autoload_tests.py
HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_launcher_tests.py
+1 -1
View File
@@ -1,7 +1,7 @@
// Mirrors the native HTTP contract so development cannot hide GET mutations.
export const mutationRoutes = [
'/manage:upload', '/manage:delete', '/shutdown', '/set_config', '/abort',
'/autoload_clear', '/repository_refresh', '/repository_install', '/sources_set',
'/autoload_clear', '/repository_cache_clear', '/repository_refresh', '/repository_install', '/sources_set',
'/sources_add', '/sources_remove', '/usb_move_perform', '/process_kill',
]
export function isMutationRoute(path) {
+14
View File
@@ -22,6 +22,7 @@ const publicFixture = (req, res, next) => {
};
// Serve the locally generated details/ README folder (repo root)
app.use('/details', publicFixture, express.static(path.resolve(process.cwd(), '..', 'details')));
app.use('/test_icons.json', publicFixture, express.static(path.resolve(process.cwd(), '..', 'test_icons.json')));
app.use('/test_repo.json', publicFixture, express.static(path.resolve(process.cwd(), '..', 'test_repo.json')));
// Log all requests to terminal
@@ -40,9 +41,11 @@ let logs = [
// Load the new-schema test repository (icons, author, release_notes, details_url)
let remoteRepository = [];
let remoteIcons = {};
try {
const testRepo = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), '..', 'test_repo.json'), 'utf8'));
remoteRepository = testRepo.payloads || [];
remoteIcons = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), "..", "test_icons.json"), "utf8")).icons;
} catch {
console.log('[Mock] Could not load ../test_repo.json — using built-in payloads');
remoteRepository = [
@@ -164,6 +167,13 @@ app.get('/get_config', (req, res) => {
});
});
app.get('/repository_icons', (req, res) => {
const items = req.query.source === 'default' ? remoteRepository : req.query.source === 'source_community' ? communityRepository : null;
if (!items) return res.status(404).json({ icons: {} });
res.json({ icons: req.query.source === 'default' ? remoteIcons : {} });
});
app.post('/repository_cache_clear', (req, res) => res.json({ ok: true }));
app.get('/repository_payloads', (req, res) => {
// Return source-grouped format (multi-source mode always on in mock)
res.json({
@@ -174,6 +184,7 @@ app.get('/repository_payloads', (req, res) => {
url: 'http://localhost:8081/test_repo.json',
last_update: lastRepositoryUpdate,
error: false,
icons_revision: 'mock-icons-v1',
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
},
{
@@ -182,6 +193,7 @@ app.get('/repository_payloads', (req, res) => {
url: 'https://example.com/community-payloads.json',
last_update: lastRepositoryUpdate,
error: false,
icons_revision: 'mock-icons-v1',
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
}
]
@@ -199,6 +211,7 @@ app.post('/repository_refresh', (req, res) => {
url: 'http://localhost:8081/test_repo.json',
last_update: lastRepositoryUpdate,
error: false,
icons_revision: 'mock-icons-v1',
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
},
{
@@ -207,6 +220,7 @@ app.post('/repository_refresh', (req, res) => {
url: 'https://example.com/community-payloads.json',
last_update: lastRepositoryUpdate,
error: false,
icons_revision: 'mock-icons-v1',
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
}
]
+1 -1
View File
@@ -17,7 +17,7 @@
"preview": "vite preview",
"extract": "i18next",
"prebuild": "npm run lint && npm test",
"test:browser": "node ../tests/run_autoload_editor_browser_tests.mjs && node ../tests/run_autoload_editor_browser_tests.mjs --lifecycle && node ../tests/run_markdown_locale_browser_tests.mjs",
"test:browser": "node ../tests/run_autoload_editor_browser_tests.mjs && node ../tests/run_autoload_editor_browser_tests.mjs --lifecycle && node ../tests/run_markdown_locale_browser_tests.mjs && node ../tests/run_markdown_locale_browser_tests.mjs --repository-icons",
"postbuild": "node ../tools/check_frontend_bundle.mjs"
},
"dependencies": {
+2 -1
View File
@@ -51,7 +51,7 @@ let toastSeq = 0
function App() {
const { t } = useTranslation();
const { getRepoEntry, findRepoEntryById, fetchRemote, sources } = useRepository()
const { getRepoEntry, findRepoEntryById, fetchRemote, clearCache, sources } = useRepository()
const [items, setItems] = useState([])
const [usbPayloads, setUsbPayloads] = useState([])
const [uploadConflictModal, setUploadConflictModal] = useState({ show: false, file: null, folderName: '' })
@@ -1107,6 +1107,7 @@ function App() {
showLogs={showLogs}
setShowLogs={setShowLogs}
onNavigate={(v) => navigate(v)}
onClearCache={clearCache}
/>
)}
+3 -3
View File
@@ -22,17 +22,17 @@ const stringToHash = (str) => {
};
const PayloadIcon = ({ icon, name, className }) => {
const [loadError, setLoadError] = useState(false);
const [failedIcon, setFailedIcon] = useState(null);
const isDataUri = typeof icon === 'string' && icon.startsWith('data:image/');
if (isDataUri && !loadError) {
if (isDataUri && failedIcon !== icon) {
return (
<div className={cn("rounded-2xl overflow-hidden border border-white/10 shrink-0 bg-white/5", className)}>
<img
src={icon}
alt={name || 'Payload'}
className="w-full h-full object-cover"
onError={() => setLoadError(true)}
onError={() => setFailedIcon(icon)}
/>
</div>
);
+28 -1
View File
@@ -41,7 +41,7 @@ const SettingRow = ({ title, description, children, icon: Icon, vertical }) => (
</div>
)
const SettingsView = ({ config, onSaveConfig, setShowLogs, onNavigate }) => {
const SettingsView = ({ config, onSaveConfig, setShowLogs, onNavigate, onClearCache }) => {
const { t, i18n } = useTranslation()
const autoOpen = config.AUTO_BROWSER_OPEN !== false
const autoInstall = config.AUTO_INSTALL_APP !== false
@@ -49,6 +49,20 @@ const SettingsView = ({ config, onSaveConfig, setShowLogs, onNavigate }) => {
const multiSources = config.MULTI_SOURCES_ENABLED === true
const [followBrowserLanguage, setFollowBrowserLanguage] = useState(isFollowingBrowserLanguage)
const [clearingCache, setClearingCache] = useState(false)
const [cacheMessage, setCacheMessage] = useState('')
const handleClearCache = async () => {
if (clearingCache) return
setClearingCache(true)
setCacheMessage('')
try {
await onClearCache()
setCacheMessage(t('settings.cache_cleared', 'Repository cache cleared.'))
} catch {
setCacheMessage(t('settings.cache_failed', 'Could not clear repository cache.'))
} finally { setClearingCache(false) }
}
const getLanguageDisplayName = lang => LANGUAGE_NAMES[lang] || lang;
const currentLang = i18n.resolvedLanguage || i18n.language || 'en';
@@ -224,6 +238,19 @@ const SettingsView = ({ config, onSaveConfig, setShowLogs, onNavigate }) => {
</button>
</SettingRow>
<SettingRow
title={t('settings.cache_title', 'Repository Cache')}
description={t('settings.cache_desc', 'Clear downloaded catalogs and icons, then fetch fresh copies. Installed payloads and your settings are preserved.')}
icon={Globe}
vertical
>
<button onClick={handleClearCache} disabled={clearingCache}
className="px-5 py-3 rounded-xl bg-white/10 hover:bg-ps-blue/20 disabled:opacity-50 font-bold">
{clearingCache ? t('settings.cache_clearing', 'Clearing…') : t('settings.cache_clear', 'Clear Cache')}
</button>
{cacheMessage && <p role="status" className="mt-3 text-sm text-zinc-400">{cacheMessage}</p>}
</SettingRow>
{multiSources && (
<button
onClick={() => onNavigate('sources')}
+25 -6
View File
@@ -1,6 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { slug } from '../utils/versions';
import { createRepositoryIconCache } from '../utils/repositoryIcons';
import { normalizeUrl } from '../utils/payloadStatus';
import { apiFetch, mutationFetch } from '../utils/mutationRequest';
@@ -14,7 +15,11 @@ export const RepositoryProvider = ({ children }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [iconCache] = useState(() => createRepositoryIconCache(apiFetch));
const requestGeneration = useRef(0);
const fetchRemote = useCallback(async (force = false) => {
const generation = ++requestGeneration.current;
setLoading(true);
setError(false);
try {
@@ -22,16 +27,29 @@ export const RepositoryProvider = ({ children }) => {
const res = await (force ? mutationFetch(endpoint) : apiFetch(endpoint));
if (!res.ok) throw new Error();
const data = await res.json();
if (generation !== requestGeneration.current) return;
setRepoData(data);
const hydrated = await iconCache.hydrate(data);
if (generation === requestGeneration.current) setRepoData(hydrated);
} catch (err) {
console.error(err);
setError(true);
setRepoData({ sources: [] });
if (generation === requestGeneration.current) {
setError(true);
setRepoData({ sources: [] });
}
} finally {
setLoading(false);
if (generation === requestGeneration.current) setLoading(false);
}
}, []);
}, [iconCache]);
const clearCache = useCallback(async () => {
const response = await mutationFetch('/repository_cache_clear');
if (!response.ok) throw Error('Repository cache could not be cleared');
requestGeneration.current++;
iconCache.clear();
setRepoData({ sources: [] });
await fetchRemote();
}, [fetchRemote, iconCache]);
// Fetch once on mount
useEffect(() => {
@@ -174,6 +192,7 @@ export const RepositoryProvider = ({ children }) => {
loading,
error,
fetchRemote,
clearCache,
repoIndex,
getRepoEntry,
findRepoEntryById,
+7 -1
View File
@@ -247,5 +247,11 @@
"storage_hub.payload_count_two": "{{count}} حمولتان",
"storage_hub.payload_count_few": "{{count}} حمولات",
"storage_hub.payload_count_many": "{{count}} حمولة",
"storage_hub.payload_count_other": "{{count}} حمولة"
"storage_hub.payload_count_other": "{{count}} حمولة",
"settings.cache_title": "ذاكرة المستودعات المؤقتة",
"settings.cache_clear": "مسح الذاكرة المؤقتة",
"settings.cache_clearing": "جارٍ المسح…",
"settings.cache_cleared": "تم مسح ذاكرة المستودعات المؤقتة.",
"settings.cache_failed": "تعذر مسح ذاكرة المستودعات المؤقتة.",
"settings.cache_desc": "امسح الكتالوجات والأيقونات المحملة ثم أعد تنزيلها. تبقى الحمولات المثبتة والإعدادات محفوظة."
}
+7 -1
View File
@@ -239,5 +239,11 @@
"storage_hub.files_count_one": "{{count}} Datei",
"storage_hub.files_count_other": "{{count}} Dateien",
"storage_hub.payload_count_one": "{{count}} Payload",
"storage_hub.payload_count_other": "{{count}} Payloads"
"storage_hub.payload_count_other": "{{count}} Payloads",
"settings.cache_title": "Repository-Cache",
"settings.cache_clear": "Cache leeren",
"settings.cache_clearing": "Wird geleert…",
"settings.cache_cleared": "Repository-Cache geleert.",
"settings.cache_failed": "Repository-Cache konnte nicht geleert werden.",
"settings.cache_desc": "Geladene Kataloge und Symbole löschen und neu laden. Installierte Payloads und Einstellungen bleiben erhalten."
}
+7 -1
View File
@@ -239,5 +239,11 @@
"move_usb.replace_copy_btn": "Replace & Copy",
"move_usb.replace_move_btn": "Replace & Move",
"move_usb.keep_both_copy_btn": "Keep both & Copy",
"move_usb.keep_both_move_btn": "Keep both & Move"
"move_usb.keep_both_move_btn": "Keep both & Move",
"settings.cache_title": "Repository Cache",
"settings.cache_clear": "Clear Cache",
"settings.cache_clearing": "Clearing…",
"settings.cache_cleared": "Repository cache cleared.",
"settings.cache_failed": "Could not clear repository cache.",
"settings.cache_desc": "Clear downloaded catalogs and icons, then fetch fresh copies. Installed payloads and your settings are preserved."
}
+7 -1
View File
@@ -241,5 +241,11 @@
"storage_hub.files_count_other": "{{count}} archivos",
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_many": "{{count}} payloads",
"storage_hub.payload_count_other": "{{count}} payloads"
"storage_hub.payload_count_other": "{{count}} payloads",
"settings.cache_title": "Caché de repositorios",
"settings.cache_clear": "Borrar caché",
"settings.cache_clearing": "Borrando…",
"settings.cache_cleared": "Caché de repositorios borrada.",
"settings.cache_failed": "No se pudo borrar la caché de repositorios.",
"settings.cache_desc": "Borra los catálogos e iconos descargados y vuelve a descargarlos. Se conservan los payloads instalados y la configuración."
}
+7 -1
View File
@@ -239,5 +239,11 @@
"storage_hub.files_count_one": "{{count}} فایل",
"storage_hub.files_count_other": "{{count}} فایل",
"storage_hub.payload_count_one": "{{count}} پیلود",
"storage_hub.payload_count_other": "{{count}} پیلود"
"storage_hub.payload_count_other": "{{count}} پیلود",
"settings.cache_title": "حافظهٔ موقت مخزن‌ها",
"settings.cache_clear": "پاک کردن حافظه",
"settings.cache_clearing": "در حال پاک کردن…",
"settings.cache_cleared": "حافظهٔ موقت مخزن‌ها پاک شد.",
"settings.cache_failed": "پاک کردن حافظهٔ موقت مخزن‌ها ممکن نشد.",
"settings.cache_desc": "فهرست‌ها و نمادهای دریافت‌شده را پاک و دوباره دریافت می‌کند. پیلودهای نصب‌شده و تنظیمات حفظ می‌شوند."
}
+7 -1
View File
@@ -241,5 +241,11 @@
"storage_hub.files_count_other": "{{count}} fichiers",
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_many": "{{count}} payloads",
"storage_hub.payload_count_other": "{{count}} payloads"
"storage_hub.payload_count_other": "{{count}} payloads",
"settings.cache_title": "Cache des dépôts",
"settings.cache_clear": "Vider le cache",
"settings.cache_clearing": "Suppression…",
"settings.cache_cleared": "Cache des dépôts vidé.",
"settings.cache_failed": "Impossible de vider le cache des dépôts.",
"settings.cache_desc": "Supprime les catalogues et icônes téléchargés, puis les télécharge à nouveau. Les payloads installés et les paramètres sont conservés."
}
+7 -1
View File
@@ -241,5 +241,11 @@
"storage_hub.files_count_other": "{{count}} file",
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_many": "{{count}} payload",
"storage_hub.payload_count_other": "{{count}} payload"
"storage_hub.payload_count_other": "{{count}} payload",
"settings.cache_title": "Cache dei repository",
"settings.cache_clear": "Svuota cache",
"settings.cache_clearing": "Svuotamento…",
"settings.cache_cleared": "Cache dei repository svuotata.",
"settings.cache_failed": "Impossibile svuotare la cache dei repository.",
"settings.cache_desc": "Elimina i cataloghi e le icone scaricati, poi scaricali di nuovo. I payload installati e le impostazioni vengono mantenuti."
}
+7 -1
View File
@@ -237,5 +237,11 @@
"move_usb.keep_both_copy_btn": "両方を保持してコピー",
"move_usb.keep_both_move_btn": "両方を保持して移動",
"storage_hub.files_count_other": "{{count}} ファイル",
"storage_hub.payload_count_other": "{{count}} ペイロード"
"storage_hub.payload_count_other": "{{count}} ペイロード",
"settings.cache_title": "リポジトリのキャッシュ",
"settings.cache_clear": "キャッシュを削除",
"settings.cache_clearing": "削除中…",
"settings.cache_cleared": "リポジトリのキャッシュを削除しました。",
"settings.cache_failed": "リポジトリのキャッシュを削除できませんでした。",
"settings.cache_desc": "ダウンロード済みのカタログとアイコンを削除し、再取得します。インストール済みのペイロードと設定は保持されます。"
}
+7 -1
View File
@@ -237,5 +237,11 @@
"move_usb.keep_both_copy_btn": "둘 다 유지하고 복사",
"move_usb.keep_both_move_btn": "둘 다 유지하고 이동",
"storage_hub.files_count_other": "{{count}} 개 파일",
"storage_hub.payload_count_other": "{{count}} 개 페이로드"
"storage_hub.payload_count_other": "{{count}} 개 페이로드",
"settings.cache_title": "저장소 캐시",
"settings.cache_clear": "캐시 지우기",
"settings.cache_clearing": "지우는 중…",
"settings.cache_cleared": "저장소 캐시를 지웠습니다.",
"settings.cache_failed": "저장소 캐시를 지울 수 없습니다.",
"settings.cache_desc": "다운로드한 목록과 아이콘을 지우고 다시 다운로드합니다. 설치된 페이로드와 설정은 유지됩니다."
}
+7 -1
View File
@@ -239,5 +239,11 @@
"move_usb.replace_copy_btn": "Vervangen en kopiëren",
"move_usb.replace_move_btn": "Vervangen en verplaatsen",
"move_usb.keep_both_copy_btn": "Beide behouden en kopiëren",
"move_usb.keep_both_move_btn": "Beide behouden en verplaatsen"
"move_usb.keep_both_move_btn": "Beide behouden en verplaatsen",
"settings.cache_title": "Repositorycache",
"settings.cache_clear": "Cache wissen",
"settings.cache_clearing": "Wissen…",
"settings.cache_cleared": "Repositorycache gewist.",
"settings.cache_failed": "De repositorycache kon niet worden gewist.",
"settings.cache_desc": "Wis gedownloade catalogi en pictogrammen en download ze opnieuw. Geïnstalleerde payloads en instellingen blijven behouden."
}
+7 -1
View File
@@ -243,5 +243,11 @@
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_few": "{{count}} payloady",
"storage_hub.payload_count_many": "{{count}} payloadów",
"storage_hub.payload_count_other": "{{count}} payloadu"
"storage_hub.payload_count_other": "{{count}} payloadu",
"settings.cache_title": "Pamięć podręczna repozytoriów",
"settings.cache_clear": "Wyczyść pamięć",
"settings.cache_clearing": "Czyszczenie…",
"settings.cache_cleared": "Wyczyszczono pamięć podręczną repozytoriów.",
"settings.cache_failed": "Nie udało się wyczyścić pamięci podręcznej repozytoriów.",
"settings.cache_desc": "Usuń pobrane katalogi i ikony, a następnie pobierz je ponownie. Zainstalowane payloady i ustawienia zostaną zachowane."
}
+7 -1
View File
@@ -241,5 +241,11 @@
"storage_hub.files_count_other": "{{count}} arquivos",
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_many": "{{count}} payloads",
"storage_hub.payload_count_other": "{{count}} payloads"
"storage_hub.payload_count_other": "{{count}} payloads",
"settings.cache_title": "Cache dos repositórios",
"settings.cache_clear": "Limpar cache",
"settings.cache_clearing": "Limpando…",
"settings.cache_cleared": "Cache dos repositórios limpo.",
"settings.cache_failed": "Não foi possível limpar o cache dos repositórios.",
"settings.cache_desc": "Remove os catálogos e ícones baixados e baixa novas cópias. Os payloads instalados e as configurações são preservados."
}
+7 -1
View File
@@ -243,5 +243,11 @@
"storage_hub.payload_count_one": "{{count}} пейлоад",
"storage_hub.payload_count_few": "{{count}} пейлоада",
"storage_hub.payload_count_many": "{{count}} пейлоадов",
"storage_hub.payload_count_other": "{{count}} пейлоада"
"storage_hub.payload_count_other": "{{count}} пейлоада",
"settings.cache_title": "Кэш репозиториев",
"settings.cache_clear": "Очистить кэш",
"settings.cache_clearing": "Очистка…",
"settings.cache_cleared": "Кэш репозиториев очищен.",
"settings.cache_failed": "Не удалось очистить кэш репозиториев.",
"settings.cache_desc": "Удаляет загруженные каталоги и значки, затем загружает их заново. Установленные пейлоады и настройки сохраняются."
}
+7 -1
View File
@@ -237,5 +237,11 @@
"move_usb.keep_both_copy_btn": "เก็บทั้งสองและคัดลอก",
"move_usb.keep_both_move_btn": "เก็บทั้งสองและย้าย",
"storage_hub.files_count_other": "{{count}} ไฟล์",
"storage_hub.payload_count_other": "{{count}} เพย์โหลด"
"storage_hub.payload_count_other": "{{count}} เพย์โหลด",
"settings.cache_title": "แคชคลังเพย์โหลด",
"settings.cache_clear": "ล้างแคช",
"settings.cache_clearing": "กำลังล้าง…",
"settings.cache_cleared": "ล้างแคชคลังเพย์โหลดแล้ว",
"settings.cache_failed": "ไม่สามารถล้างแคชคลังเพย์โหลดได้",
"settings.cache_desc": "ล้างรายการและไอคอนที่ดาวน์โหลด แล้วดาวน์โหลดใหม่ เพย์โหลดที่ติดตั้งและการตั้งค่าจะยังคงอยู่"
}
+7 -1
View File
@@ -239,5 +239,11 @@
"storage_hub.files_count_one": "{{count}} dosya",
"storage_hub.files_count_other": "{{count}} dosya",
"storage_hub.payload_count_one": "{{count}} payload",
"storage_hub.payload_count_other": "{{count}} payload"
"storage_hub.payload_count_other": "{{count}} payload",
"settings.cache_title": "Depo Önbelleği",
"settings.cache_clear": "Önbelleği Temizle",
"settings.cache_clearing": "Temizleniyor…",
"settings.cache_cleared": "Depo önbelleği temizlendi.",
"settings.cache_failed": "Depo önbelleği temizlenemedi.",
"settings.cache_desc": "İndirilen katalogları ve simgeleri silip yeniden indirir. Yüklü payloadlar ve ayarlar korunur."
}
+7 -1
View File
@@ -243,5 +243,11 @@
"storage_hub.payload_count_one": "{{count}} пейлоад",
"storage_hub.payload_count_few": "{{count}} пейлоади",
"storage_hub.payload_count_many": "{{count}} пейлоадів",
"storage_hub.payload_count_other": "{{count}} пейлоада"
"storage_hub.payload_count_other": "{{count}} пейлоада",
"settings.cache_title": "Кеш репозиторіїв",
"settings.cache_clear": "Очистити кеш",
"settings.cache_clearing": "Очищення…",
"settings.cache_cleared": "Кеш репозиторіїв очищено.",
"settings.cache_failed": "Не вдалося очистити кеш репозиторіїв.",
"settings.cache_desc": "Видаляє завантажені каталоги та значки, потім завантажує їх знову. Встановлені пейлоади й налаштування зберігаються."
}
+7 -1
View File
@@ -237,5 +237,11 @@
"move_usb.keep_both_copy_btn": "保留两者并复制",
"move_usb.keep_both_move_btn": "保留两者并移动",
"storage_hub.files_count_other": "{{count}} 个文件",
"storage_hub.payload_count_other": "{{count}} 个载荷"
"storage_hub.payload_count_other": "{{count}} 个载荷",
"settings.cache_title": "仓库缓存",
"settings.cache_clear": "清除缓存",
"settings.cache_clearing": "正在清除…",
"settings.cache_cleared": "仓库缓存已清除。",
"settings.cache_failed": "无法清除仓库缓存。",
"settings.cache_desc": "清除已下载的目录和图标,然后重新下载。已安装的载荷和设置将保留。"
}
+39
View File
@@ -0,0 +1,39 @@
const imageData = /^data:image\/(?:png|jpeg|svg\+xml);base64,[A-Za-z0-9+/]+={0,2}$/
export function createRepositoryIconCache(fetchIcons) {
const cache = new Map()
const tokens = new Map()
let generation = 0
return {
clear() { generation++; cache.clear(); tokens.clear() },
async hydrate(data) {
if (!Array.isArray(data?.sources)) return data
const current = generation
const sources = await Promise.all(data.sources.map(async source => {
const key = `${source.url}\n${source.id}`
let entry = cache.get(key)
const token = {}
tokens.set(key, token)
if (source.icons_revision && entry?.revision !== source.icons_revision) {
try {
const response = await fetchIcons(`/repository_icons?source=${encodeURIComponent(source.id)}&revision=${encodeURIComponent(source.icons_revision)}`)
if (!response.ok) throw Error('Icon catalog unavailable')
const body = await response.json()
if (!body.icons || typeof body.icons !== 'object' || Array.isArray(body.icons)) throw Error('Invalid icon catalog')
const icons = Object.create(null)
for (const [name, value] of Object.entries(body.icons)) {
if (typeof value === 'string' && value.length <= 12288 && imageData.test(value)) icons[name] = value
}
entry = { revision: source.icons_revision, icons }
if (generation === current && tokens.get(key) === token) cache.set(key, entry)
} catch { /* Preserve the previous icons when a local fetch fails. */ }
}
return { ...source, payloads: (Array.isArray(source.payloads) ? source.payloads : []).map(payload => ({
...payload,
icon: source.icons_revision ? entry?.icons[payload.icon_key] || null : null,
})) }
}))
return { ...data, sources }
},
}
}
@@ -0,0 +1,23 @@
import React, { useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { RepositoryProvider, useRepository } from '../src/contexts/RepositoryContext'
import SettingsView from '../src/components/views/SettingsView'
import PayloadIcon from '../src/components/ui/PayloadIcon'
import '../src/i18n'
import '../src/index.css'
window.__errors = []
window.addEventListener('error', event => window.__errors.push(event.message))
window.addEventListener('unhandledrejection', event => window.__errors.push(String(event.reason)))
function Harness() {
const repository = useRepository()
useEffect(() => { window.refreshIcons = repository.fetchRemote }, [repository.fetchRemote])
return <>
<div data-testid="icons">{repository.sources.flatMap(source => source.payloads.map(payload =>
<div key={`${source.id}/${payload.id}`} data-key={payload.icon_key}>
<PayloadIcon icon={payload.icon} name={payload.name} className="w-12 h-12" />
</div>))}</div>
<SettingsView config={{}} onSaveConfig={() => {}} setShowLogs={() => {}} onNavigate={() => {}} onClearCache={repository.clearCache} />
</>
}
createRoot(document.getElementById('root')).render(<RepositoryProvider><Harness /></RepositoryProvider>)
window.fixtureReady = true
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRepositoryIconCache } from '../src/utils/repositoryIcons.js'
const icon = 'data:image/png;base64,aGVsbG8='
const catalog = (revision = 'r1', id = 'default', url = 'https://mirror.invalid/catalog.json') => ({ sources: [{ id, url, icons_revision: revision, payloads: [{ icon_key: 'shared', icon: 'data:image/png;base64,d29ybGQ=' }] }] })
test('icons load separately, shared keys reuse data and unchanged revisions avoid requests', async () => {
let requests = 0
const cache = createRepositoryIconCache(async url => {
requests++; assert.match(url, /^\/repository_icons\?source=default&revision=r1$/)
return { ok: true, json: async () => ({ icons: { shared: icon } }) }
})
assert.equal((await cache.hydrate(catalog())).sources[0].payloads[0].icon, icon)
assert.equal((await cache.hydrate(catalog())).sources[0].payloads[0].icon, icon)
assert.equal(requests, 1)
})
test('failed updates retain previous images and can retry; explicit clearing removes cached images', async () => {
let fail = false, requests = 0
const cache = createRepositoryIconCache(async () => { requests++; return { ok: !fail, json: async () => ({ icons: { shared: icon } }) } })
await cache.hydrate(catalog()); fail = true
assert.equal((await cache.hydrate(catalog('r2'))).sources[0].payloads[0].icon, icon)
fail = false; await cache.hydrate(catalog('r2')); assert.equal(requests, 3)
cache.clear(); fail = true
assert.equal((await cache.hydrate(catalog('r2'))).sources[0].payloads[0].icon, null)
})
test('source namespaces are independent, absent descriptors use fallback and remote image URLs are ignored', async () => {
let calls = 0
const cache = createRepositoryIconCache(async () => ({ ok: true, json: async () => ({ icons: { shared: ++calls === 1 ? icon : 'https://bad.invalid/image.png' } }) }))
assert.equal((await cache.hydrate(catalog())).sources[0].payloads[0].icon, icon)
assert.equal((await cache.hydrate(catalog('r1', 'other'))).sources[0].payloads[0].icon, null)
assert.equal((await cache.hydrate(catalog(''))).sources[0].payloads[0].icon, null)
})
test('clearing during a pending fetch prevents that result from repopulating the cache', async () => {
let resolve, calls = 0
const cache = createRepositoryIconCache(() => { calls++; return new Promise(done => { resolve = done }) })
const first = cache.hydrate(catalog()); cache.clear()
resolve({ ok: true, json: async () => ({ icons: { shared: icon } }) }); await first
const next = cache.hydrate(catalog()); assert.equal(calls, 2)
resolve({ ok: true, json: async () => ({ icons: { shared: icon } }) }); await next
})
test('out-of-order icon responses cannot replace the latest revision cache', async () => {
const pending = new Map(); let calls = 0
const cache = createRepositoryIconCache(url => { calls++; return new Promise(resolve => pending.set(url.includes('revision=r2') ? 'r2' : 'r1', resolve)) })
const old = cache.hydrate(catalog('r1')), newer = cache.hydrate(catalog('r2'))
const fresh = 'data:image/png;base64,d29ybGQ='
pending.get('r2')({ok:true,json:async()=>({icons:{shared:fresh}})}); await newer
pending.get('r1')({ok:true,json:async()=>({icons:{shared:icon}})}); await old
assert.equal((await cache.hydrate(catalog('r2'))).sources[0].payloads[0].icon, fresh)
assert.equal(calls, 2)
})
test('a cached latest revision also prevents an older pending fetch from replacing it', async () => {
let oldResolve, calls = 0
const cache = createRepositoryIconCache(url => {
calls++
if (url.includes('revision=r1')) return new Promise(resolve => { oldResolve = resolve })
return Promise.resolve({ok:true,json:async()=>({icons:{shared:icon}})})
})
await cache.hydrate(catalog('r2'))
const old = cache.hydrate(catalog('r1'))
await cache.hydrate(catalog('r2'))
oldResolve({ok:true,json:async()=>({icons:{shared:icon}})}); await old
await cache.hydrate(catalog('r2')); assert.equal(calls, 2)
})
+2
View File
@@ -21,6 +21,8 @@
#define ROUTE_ABORT "/abort"
#define ROUTE_AUTOLOAD_STATUS "/autoload_status"
#define ROUTE_AUTOLOAD_CLEAR "/autoload_clear"
#define ROUTE_REPO_ICONS "/repository_icons"
#define ROUTE_REPO_CACHE_CLEAR "/repository_cache_clear"
#define ROUTE_REPO_LIST "/repository_payloads"
#define ROUTE_REPO_REFRESH "/repository_refresh"
#define ROUTE_REPO_INSTALL "/repository_install"
+1 -1
View File
@@ -17,7 +17,7 @@ typedef struct RepoPayload {
char checksum[65];
char source_name[256];
char category[128];
char icon[16384];
char icon_key[128];
char release_notes[2048];
char author[128];
char details_url[1024];
+4
View File
@@ -22,6 +22,10 @@ int sources_ensure_fresh(const char *url, int force_refresh);
* Returns complete bytes written, or 0 on serialization failure. */
size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refresh);
/* Cached icon catalog for a configured source ID; no remote URL accepted. */
size_t sources_icons_json(const char *source_id, char *buf, size_t size);
int sources_clear_cache(void);
/* Unified repository install pipeline.
* Finds entry by id in source cache (refreshing if missing or on error).
* Handles conflicts, mode=replace|keep_both, and downloads to slot/.part before commit. */
+21 -1
View File
@@ -64,7 +64,7 @@ static int is_mutation_route(const char *url) {
const char *routes[] = { ROUTE_UPLOAD, ROUTE_DELETE, ROUTE_SHUTDOWN,
ROUTE_SET_CONFIG, ROUTE_ABORT, ROUTE_AUTOLOAD_CLEAR, ROUTE_REPO_REFRESH,
ROUTE_REPO_INSTALL, ROUTE_SOURCES_SET, ROUTE_SOURCES_ADD,
ROUTE_SOURCES_REMOVE, ROUTE_USB_MOVE_PERFORM, ROUTE_PROCESS_KILL };
ROUTE_SOURCES_REMOVE, ROUTE_REPO_CACHE_CLEAR, ROUTE_USB_MOVE_PERFORM, ROUTE_PROCESS_KILL };
for (size_t i = 0; i < sizeof(routes)/sizeof(routes[0]); i++)
if (!strcmp(url, routes[i])) return 1;
return !strncmp(url, ROUTE_LOAD_PAYLOAD, strlen(ROUTE_LOAD_PAYLOAD));
@@ -742,6 +742,26 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn,
add_response_header(resp, "Content-Type", "application/json");
return send_response(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp);
} else if (strcmp(url, ROUTE_REPO_CACHE_CLEAR) == 0) {
int result = sources_clear_cache();
const char *body = result ? "{\"ok\":false}" : "{\"ok\":true}";
resp = create_buffer_response(strlen(body), (void *)body, MHD_RESPMEM_PERSISTENT);
add_response_header(resp, "Content-Type", "application/json");
return send_response(conn, result ? MHD_HTTP_INTERNAL_SERVER_ERROR : MHD_HTTP_OK, resp);
} else if (strcmp(url, ROUTE_REPO_ICONS) == 0) {
const char *source = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "source");
char *buffer;
if (!alloc_response_buffer(&buffer)) return send_out_of_memory(conn);
size_t length = sources_icons_json(source, buffer, RESPONSE_BUFFER_SIZE);
if (!length) {
free(buffer);
resp = create_buffer_response(strlen("{\"icons\":{}}"), (void *)"{\"icons\":{}}", MHD_RESPMEM_PERSISTENT);
add_response_header(resp, "Content-Type", "application/json");
return send_response(conn, MHD_HTTP_NOT_FOUND, resp);
}
resp = create_buffer_response(length, buffer, MHD_RESPMEM_MUST_FREE);
add_response_header(resp, "Content-Type", "application/json");
add_response_header(resp, "Cache-Control", "no-store");
} else if (strcmp(url, ROUTE_REPO_LIST) == 0) {
char *resp_buf;
if (!alloc_response_buffer(&resp_buf))
+1 -5
View File
@@ -226,11 +226,7 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
strcpy(item->release_notes + length, "...");
}
json_extract_string(p, end + 1, "icon", item->icon, sizeof(item->icon));
/* Only allow inline data URIs (e.g. data:image/svg+xml;base64,... or data:image/png;base64,...); reject external URLs */
if (strncmp(item->icon, "data:image/", 11) != 0 || strlen(item->icon) > 12288) {
item->icon[0] = '\0';
}
json_extract_string(p, end + 1, "icon_key", item->icon_key, sizeof(item->icon_key));
if (json_extract_string(p, end + 1, "category", item->category, sizeof(item->category)) != 0 ||
strlen(item->category) == 0) {
+232 -25
View File
@@ -8,6 +8,8 @@
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
#include <curl/curl.h>
#include <ctype.h>
#include "sources.h"
#include "repository.h"
@@ -308,7 +310,9 @@ static void get_cache_paths(const char *url, char *cache_path, size_t csize,
snprintf(ts_path, tsize, "%s/repo_cache_%s.json.ts", BASE_DATA_DIR, h8);
}
int sources_ensure_fresh(const char *url, int force_refresh) {
static pthread_mutex_t repository_cache_mutex = PTHREAD_MUTEX_INITIALIZER;
static int ensure_catalog_fresh(const char *url, int force_refresh) {
if (!url || !url[0]) return -1;
char cache_path[512], ts_path[512];
@@ -374,7 +378,209 @@ int sources_ensure_fresh(const char *url, int force_refresh) {
return 0;
}
size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refresh) {
#define ICON_CATALOG_LIMIT ((size_t)4 * 1024 * 1024 - 1024)
#define ICON_REFRESH_SECONDS (7L * 86400)
#define ICON_RETRY_SECONDS 3600L
typedef struct {
char (*keys)[128];
size_t count;
const char *map_start, *map_end;
int found;
} IconCatalog;
static int valid_icon(const char *value) {
const char *prefixes[] = {"data:image/png;base64,", "data:image/jpeg;base64,", "data:image/svg+xml;base64,"};
const char *data = NULL;
for (size_t i = 0; i < sizeof(prefixes)/sizeof(prefixes[0]); i++)
if (!strncmp(value, prefixes[i], strlen(prefixes[i]))) data = value + strlen(prefixes[i]);
if (!data || !*data || strlen(value) > 12288 || strlen(data) % 4) return 0;
size_t n = strlen(data), padding = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)data[i];
if (c == '=') { if (i < n - 2 || ++padding > 2) return 0; }
else if (padding || !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/')) return 0;
}
return 1;
}
static int icon_member(const char *key, const char *start, const char *end, void *context) {
IconCatalog *catalog = context; char value[12289];
if (!key[0] || strlen(key) >= 128 || catalog->count >= 4096 ||
json_decode_string(start, end, value, sizeof(value)) || !valid_icon(value)) return -1;
for (size_t i = 0; i < catalog->count; i++) if (!strcmp(catalog->keys[i], key)) return -1;
strcpy(catalog->keys[catalog->count++], key);
return 0;
}
static int icon_wrapper(const char *key, const char *start, const char *end, void *context) {
IconCatalog *catalog = context;
if (strcmp(key, "icons")) return 0;
if (catalog->found++) return -1;
catalog->map_start = start; catalog->map_end = end;
return json_visit_object(start, end, icon_member, catalog);
}
static int parse_icons(const char *json, size_t size, IconCatalog *catalog) {
memset(catalog, 0, sizeof(*catalog));
if (size > ICON_CATALOG_LIMIT) return -1;
catalog->keys = calloc(4096, sizeof(*catalog->keys));
if (!catalog->keys) return -1;
int result = json_visit_object(json, json + size, icon_wrapper, catalog) || !catalog->found;
if (result) { free(catalog->keys); catalog->keys = NULL; }
return result ? -1 : 0;
}
static void digest_text(const char *text, char hex[65]) {
SHA256_CTX ctx; unsigned char hash[32];
sha256_init(&ctx); sha256_update(&ctx, (const unsigned char *)text, strlen(text)); sha256_final(&ctx, hash);
for (size_t i = 0; i < 32; i++) snprintf(hex + 2*i, 3, "%02x", hash[i]);
}
static void icon_paths(const char *url, char path[512], char meta[512]) {
char hex[65]; digest_text(url, hex);
snprintf(path, 512, "%s/repo_icons_%s.json", BASE_DATA_DIR, hex);
snprintf(meta, 512, "%s/repo_icons_%s.meta", BASE_DATA_DIR, hex);
}
static int read_icons(const char *path, char **text, size_t *size, IconCatalog *catalog) {
struct stat st;
*text = NULL;
if (stat(path, &st) || !S_ISREG(st.st_mode) || st.st_size < 0 || (size_t)st.st_size > ICON_CATALOG_LIMIT ||
read_file_text(path, text, size)) return -1;
if (parse_icons(*text, *size, catalog)) { free(*text); *text = NULL; return -1; }
return 0;
}
static int resolve_icons_url(const char *base, const char *catalog, char out[1024]) {
char relative[1024]; out[0] = 0;
if (!catalog || json_extract_string(catalog, catalog + strlen(catalog), "icons_url", relative, sizeof(relative)) || !relative[0]) return -1;
CURLU *url = curl_url(); char *resolved = NULL, *scheme = NULL, *user = NULL;
if (!url) return -1;
int ok = !curl_url_set(url, CURLUPART_URL, base, 0) && !curl_url_set(url, CURLUPART_URL, relative, 0) &&
!curl_url_get(url, CURLUPART_SCHEME, &scheme, 0) && (!strcmp(scheme, "http") || !strcmp(scheme, "https")) &&
(strncasecmp(base, "https://", 8) || !strcmp(scheme, "https")) &&
curl_url_get(url, CURLUPART_USER, &user, 0) == CURLUE_NO_USER &&
!curl_url_set(url, CURLUPART_FRAGMENT, NULL, 0) && !curl_url_get(url, CURLUPART_URL, &resolved, 0) && strlen(resolved) < 1024;
if (ok) strcpy(out, resolved);
curl_free(user); curl_free(scheme); curl_free(resolved); curl_url_cleanup(url);
return ok ? 0 : -1;
}
static long meta_time(const char *json, const char *key) {
char value[32], *end;
if (json_extract_string(json, json + strlen(json), key, value, sizeof(value))) return 0;
errno = 0; long number = strtol(value, &end, 10);
return errno || end == value || *end || number < 0 ? 0 : number;
}
static int missing_compare(const void *a, const void *b) { return strcmp(a, b); }
static int missing_icons(IconCatalog *icons, RepoPayload *items, size_t count, char digest[65]) {
char (*missing)[128] = calloc(count ? count : 1, 128);
if (!missing) return -1;
size_t n = 0;
for (size_t i = 0; i < count; i++) {
if (!items[i].icon_key[0]) continue;
size_t k = 0;
while (k < icons->count && strcmp(items[i].icon_key, icons->keys[k])) k++;
if (k == icons->count) strcpy(missing[n++], items[i].icon_key);
}
qsort(missing, n, 128, missing_compare);
SHA256_CTX ctx; unsigned char hash[32]; sha256_init(&ctx);
for (size_t i = 0; i < n; i++)
if (!i || strcmp(missing[i], missing[i-1])) sha256_update(&ctx, (unsigned char *)missing[i], strlen(missing[i]) + 1);
sha256_final(&ctx, hash);
for (size_t i = 0; i < 32; i++) snprintf(digest + 2*i, 3, "%02x", hash[i]);
free(missing); return n ? 1 : 0;
}
static void ensure_icons(const char *source, const char *catalog, RepoPayload *items, size_t count, char revision[65]) {
char url[1024], path[512], meta[512]; revision[0] = 0;
if (resolve_icons_url(source, catalog, url)) return;
icon_paths(source, path, meta);
char *old = NULL, *state = NULL; size_t size = 0, state_size = 0;
IconCatalog icons = {0};
int cached = !read_icons(path, &old, &size, &icons);
read_file_text(meta, &state, &state_size);
const char *metadata = state ? state : "{}";
char old_url[1024] = "", attempt_url[1024] = "", old_missing[65] = "", missing[65] = "";
json_extract_string(metadata, metadata + strlen(metadata), "url", old_url, sizeof(old_url));
json_extract_string(metadata, metadata + strlen(metadata), "attempt_url", attempt_url, sizeof(attempt_url));
json_extract_string(metadata, metadata + strlen(metadata), "missing", old_missing, sizeof(old_missing));
long updated = meta_time(metadata, "updated"), attempted = meta_time(metadata, "attempted"), now = (long)time(NULL);
int unknown = missing_icons(&icons, items, count, missing);
int changed = strcmp(old_url, url) != 0;
int due = !cached || changed || now < updated || now - updated >= ICON_REFRESH_SECONDS || unknown > 0;
int retry = strcmp(attempt_url, url) || strcmp(old_missing, missing) || now < attempted || now - attempted >= ICON_RETRY_SECONDS;
if (unknown >= 0 && due && retry) {
char temporary[544]; snprintf(temporary, sizeof(temporary), "%s.tmp-XXXXXX", path);
int fd = mkstemp(temporary);
if (fd >= 0) {
int ready = !close(fd);
char *fresh = NULL; size_t fresh_size = 0; IconCatalog downloaded = {0};
if (ready && !download_to_file_limited(url, temporary, ICON_CATALOG_LIMIT) &&
!read_icons(temporary, &fresh, &fresh_size, &downloaded) && !rename(temporary, path)) {
updated = now; strcpy(old_url, url);
}
free(downloaded.keys); free(fresh); remove(temporary);
}
char escaped[6 * 1024], cached_url[6 * 1024], output[16384]; JsonListBuilder builder = {output, sizeof(output), 0, 1, 0};
json_escape_checked(&builder, url, escaped, sizeof(escaped));
json_escape_checked(&builder, old_url, cached_url, sizeof(cached_url));
json_append(&builder, "{\"url\":\"%s\",\"attempt_url\":\"%s\",\"updated\":\"%ld\",\"attempted\":\"%ld\",\"missing\":\"%s\"}", cached_url, escaped, updated, now, missing);
if (!builder.error) write_file_text(meta, output, builder.pos);
}
free(icons.keys); free(old); free(state);
IconCatalog current = {0}; char *text = NULL; size_t current_size;
if (!read_icons(path, &text, &current_size, &current)) compute_sha256_file(path, revision);
free(current.keys); free(text);
}
int sources_ensure_fresh(const char *url, int force_refresh) {
pthread_mutex_lock(&repository_cache_mutex);
int result = ensure_catalog_fresh(url, force_refresh);
pthread_mutex_unlock(&repository_cache_mutex);
return result;
}
size_t sources_icons_json(const char *source_id, char *buf, size_t size) {
if (!source_id || !buf || !size) return 0;
buf[0] = 0; size_t result = 0;
pthread_mutex_lock(&repository_cache_mutex);
SourceEntry *sources = calloc(MAX_SOURCES, sizeof(*sources)); int count = 0;
if (sources && !load_sources(sources, &count)) for (int i = 0; i < count; i++) {
if (strcmp(sources[i].id, source_id)) continue;
char path[512], meta[512], *text = NULL; size_t length = 0; IconCatalog icons = {0};
icon_paths(sources[i].url, path, meta);
if (!read_icons(path, &text, &length, &icons) && length < size) { memcpy(buf, text, length); buf[length] = 0; result = length; }
free(icons.keys); free(text); break;
}
free(sources);
pthread_mutex_unlock(&repository_cache_mutex);
return result;
}
static int cache_filename(const char *name) {
size_t prefix, digits;
if (!strncmp(name, "repo_cache_", 11)) { prefix = 11; digits = 8; }
else if (!strncmp(name, "repo_icons_", 11)) { prefix = 11; digits = 64; }
else return 0;
if (strlen(name) < prefix + digits) return 0;
for (size_t i = prefix; i < prefix + digits; i++) if (!isxdigit((unsigned char)name[i])) return 0;
const char *suffix = name + prefix + digits;
if (!strcmp(suffix, ".json") || (digits == 8 && !strcmp(suffix, ".json.ts")) || (digits == 64 && !strcmp(suffix, ".meta"))) return 1;
const char *temporary = NULL;
if (!strncmp(suffix, ".json.tmp-", 10)) temporary = suffix + 10;
if (digits == 8 && !strncmp(suffix, ".json.ts.tmp-", 13)) temporary = suffix + 13;
if (digits == 64 && !strncmp(suffix, ".meta.tmp-", 10)) temporary = suffix + 10;
if (!temporary || strlen(temporary) != 6) return 0;
for (size_t i = 0; i < 6; i++) if (!isalnum((unsigned char)temporary[i])) return 0;
return 1;
}
int sources_clear_cache(void) {
pthread_mutex_lock(&repository_cache_mutex);
DIR *directory = opendir(BASE_DATA_DIR); int result = directory ? 0 : (errno == ENOENT ? 0 : -1);
if (directory) {
struct dirent *entry;
while ((entry = readdir(directory))) if (cache_filename(entry->d_name)) {
if (unlinkat(dirfd(directory), entry->d_name, 0) && errno != ENOENT) result = -1;
}
closedir(directory);
}
pthread_mutex_unlock(&repository_cache_mutex);
return result;
}
static size_t repository_list_locked(char *buf, size_t size, int force_refresh) {
if (!buf || !size) return 0;
SourceEntry *sources = calloc(MAX_SOURCES, sizeof(SourceEntry));
if (!sources) {
@@ -397,7 +603,7 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
json_append(&jb, "{\"sources\":[\n");
for (int si = 0; si < src_count; si++) {
int fetch_ok = (sources_ensure_fresh(sources[si].url, force_refresh) == 0);
int fetch_ok = (ensure_catalog_fresh(sources[si].url, force_refresh) == 0);
char cache_path[512], ts_path[512];
get_cache_paths(sources[si].url, cache_path, sizeof(cache_path), ts_path, sizeof(ts_path));
@@ -411,16 +617,11 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
FILE *tf = fopen(ts_path, "r");
if (tf) { fscanf(tf, "%ld", &last_update); fclose(tf); }
if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"last_update\":%ld,\"error\":%s,\"payloads\":[\n",
id_e, name_e, url_e, last_update, fetch_ok ? "false" : "true") != 0) {
break;
}
if (fetch_ok) {
RepoPayload *items = NULL; size_t count = 0; char icons_revision[65] = "";
int have_catalog = fetch_ok || access(cache_path, F_OK) == 0;
if (have_catalog) {
char *cached = NULL;
size_t cached_size = 0;
RepoPayload *items = NULL;
size_t count = 0;
if (read_file_text(cache_path, &cached, &cached_size) != 0 || !cached) {
free(cached); free(sources);
@@ -428,22 +629,22 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
return 0;
}
int parsed = parse_repository_payloads(cached, &items, &count);
free(cached);
if (parsed != 0) {
free(items); free(sources);
free(cached); free(items); free(sources);
json_builder_fail(&jb);
return 0;
}
size_t icon_capacity = 6 * sizeof(items[0].icon);
char *icon_e = (char *)malloc(icon_capacity);
if (!icon_e) {
if (items) free(items);
free(sources);
json_builder_fail(&jb);
return 0;
}
ensure_icons(sources[si].url, cached, items, count, icons_revision);
free(cached);
}
if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"last_update\":%ld,\"error\":%s,\"icons_revision\":\"%s\",\"payloads\":[\n",
id_e, name_e, url_e, last_update, fetch_ok ? "false" : "true", icons_revision) != 0) {
free(items); break;
}
if (have_catalog) {
int first_payload = 1;
for (size_t pi = 0; pi < count; pi++) {
if (is_pldmgr_name(items[pi].id) ||
@@ -455,7 +656,7 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
char id_pe[6 * 128], name_pe[6 * 256], filename_e[6 * 256], desc_e[6 * 1024];
char ver_e[6 * 64], url_e[6 * 1024], src_id_e[6 * 128], src_name_e[6 * 256];
char last_upd_e[6 * 64], cat_e[6 * 128], csum_e[6 * 65];
char auth_e[6 * 128], det_url_e[6 * 1024], rn_e[6 * 2048];
char auth_e[6 * 128], det_url_e[6 * 1024], rn_e[6 * 2048], icon_e[6 * 128];
char src_pe[6 * 1024], sd_pe[6 * 1024];
json_escape_checked(&jb, items[pi].id, id_pe, sizeof(id_pe));
@@ -474,13 +675,13 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
json_escape_checked(&jb, items[pi].author, auth_e, sizeof(auth_e));
json_escape_checked(&jb, items[pi].details_url, det_url_e, sizeof(det_url_e));
json_escape_checked(&jb, items[pi].release_notes, rn_e, sizeof(rn_e));
json_escape_checked(&jb, items[pi].icon, icon_e, icon_capacity);
json_escape_checked(&jb, items[pi].icon_key, icon_e, sizeof(icon_e));
if (json_append(&jb, "%s {\"id\":\"%s\",\"name\":\"%s\",\"filename\":\"%s\",\"description\":\"%s\","
"\"version\":\"%s\",\"checksum\":\"%s\",\"last_update\":\"%s\",\"url\":\"%s\","
"\"source\":\"%s\",\"source_direct\":\"%s\","
"\"source_id\":\"%s\",\"source_name\":\"%s\",\"category\":\"%s\","
"\"author\":\"%s\",\"details_url\":\"%s\",\"release_notes\":\"%s\",\"icon\":\"%s\"}",
"\"author\":\"%s\",\"details_url\":\"%s\",\"release_notes\":\"%s\",\"icon_key\":\"%s\"}",
first_payload ? "" : ",\n",
id_pe, name_pe, filename_e, desc_e, ver_e, csum_e, last_upd_e, url_e,
src_pe, sd_pe,
@@ -493,7 +694,6 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
json_append(&jb, "\n");
}
free(icon_e);
if (items) free(items);
}
@@ -507,6 +707,13 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
return jb.pos;
}
size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refresh) {
pthread_mutex_lock(&repository_cache_mutex);
size_t result = repository_list_locked(buf, size, force_refresh);
pthread_mutex_unlock(&repository_cache_mutex);
return result;
}
int sources_repo_install(const char *source_url, const char *id,
const char *mode, const char *replace_slot,
char *out_json, size_t out_size) {
+22
View File
@@ -0,0 +1,22 @@
{
"icons": {
"72395f43165d3bc36857721cfce317f405eb5415048dd1bddc925ffb913b9b69": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMzYjgyZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMxZDRlZDgnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlBOPC90ZXh0Pjwvc3ZnPg==",
"580965831f0f659b28555be0253aa3e26de87a637864e876caa040d6f8728c6f": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlo8L3RleHQ+PC9zdmc+",
"b075038cac93501dfe1335af6b520ad52f9dbcf19c127a72b1607f430b60b431": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyM4YjVjZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyM2ZDI4ZDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkdTPC90ZXh0Pjwvc3ZnPg==",
"4ab896aedafca2d80168fdbbe4f36c37dd1b37915a2933cb8df7629646513e00": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPktMPC90ZXh0Pjwvc3ZnPg==",
"ed3c46cfcb6e2af747289769a364a9732ad7d7dca1da7b8413b597c6f13db3d7": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNmNTllMGInLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiNDUzMDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlA8L3RleHQ+PC9zdmc+",
"1296b2bb4c54727c6cbce2cb9369b260da82db620e65eab15695e71f7ff9b1a5": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlYzQ4OTknLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiZTE4NWQnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlM8L3RleHQ+PC9zdmc+",
"bb1c223404f21ab128c87678f2b65a6235f7f64f1e3d1e859825457844b681cf": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPk48L3RleHQ+PC9zdmc+",
"5339f9de791a3de740b492162da35b98f17e4cf81d834738ab77a4c649c910ff": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkxKPC90ZXh0Pjwvc3ZnPg==",
"4eb4e0f1b9ab7561da7aeb15bb653a8c6ab38a67d5e68371781037ca3ecbc50b": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlc8L3RleHQ+PC9zdmc+",
"2c419081ce722485eca2d028b3ec221675f68384ca8ad92ae78c2c0aedfa5680": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkY8L3RleHQ+PC9zdmc+",
"59ce1ae8ba08a91b5d5673b9e43e1b5b5482369b2fedd47988bfd9183d0bd3f2": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPks8L3RleHQ+PC9zdmc+",
"f246ab140d0878fb20a494f3f5b00eb40f8651c5861ed829cb8e3b0c62cf3da5": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlM8L3RleHQ+PC9zdmc+",
"a55ff979f29b0960230e96eaac9b06d557330a03b5adb50c53e4dffe3cdb8b6f": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNmNTllMGInLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiNDUzMDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkU8L3RleHQ+PC9zdmc+",
"41e2aece6151a0f938763537798b6616f655581a655393ced6967bed28230200": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlYzQ4OTknLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiZTE4NWQnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPktUPC90ZXh0Pjwvc3ZnPg==",
"1e3d5422de58bfd7b57f0ce717cd36a61d6b446323e60ddc04d61dfaeb1fcc3e": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMzYjgyZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMxZDRlZDgnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkI8L3RleHQ+PC9zdmc+",
"a99363bacd1c28400d4a3060fa60cc7007b9fc792c30b5fd6698060c71bc2e2f": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkZEPC90ZXh0Pjwvc3ZnPg==",
"2a011b42d44df200aaa5c84ae29a479696017d483c1ac15620283b70bedcc52f": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPks8L3RleHQ+PC9zdmc+",
"58c275e6ed7b9045758459aed219fa3d2eb6c907f4b75cba4000ceb61d9275e5": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkU8L3RleHQ+PC9zdmc+"
}
}
+21 -20
View File
@@ -15,7 +15,7 @@
"author": "OpenSourcereR-dev",
"release_notes": "## v1.3.0\n\n- Test release notes for **ps5debug-NG**\n- Backend truncates at 2000 chars",
"details_url": "details/ps5debug-ng.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMzYjgyZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMxZDRlZDgnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlBOPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "72395f43165d3bc36857721cfce317f405eb5415048dd1bddc925ffb913b9b69"
},
{
"name": "zftpd",
@@ -32,7 +32,7 @@
"author": "seregonwar",
"release_notes": "## v1.5.0\n\n- Test release notes for **zftpd**\n- Backend truncates at 2000 chars",
"details_url": "details/zftpd.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlo8L3RleHQ+PC9zdmc+"
"icon_key": "580965831f0f659b28555be0253aa3e26de87a637864e876caa040d6f8728c6f"
},
{
"name": "garlic-savemgr",
@@ -48,7 +48,7 @@
"author": "earthonion",
"release_notes": "## v1.10\n\n- Test release notes for **garlic-savemgr**\n- Backend truncates at 2000 chars",
"details_url": "details/garlic-savemgr.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyM4YjVjZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyM2ZDI4ZDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkdTPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "b075038cac93501dfe1335af6b520ad52f9dbcf19c127a72b1607f430b60b431"
},
{
"name": "kstuff-lite",
@@ -64,7 +64,7 @@
"author": "EchoStretch",
"release_notes": "## v1.07\n\n- Test release notes for **kstuff-lite**\n- Backend truncates at 2000 chars",
"details_url": "details/kstuff-lite.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPktMPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "4ab896aedafca2d80168fdbbe4f36c37dd1b37915a2933cb8df7629646513e00"
},
{
"name": "pldmgr",
@@ -80,7 +80,7 @@
"author": "itsPLK",
"release_notes": "## v0.3.1\n\n- Test release notes for **pldmgr**\n- Backend truncates at 2000 chars",
"details_url": "details/pldmgr.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNmNTllMGInLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiNDUzMDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlA8L3RleHQ+PC9zdmc+"
"icon_key": "ed3c46cfcb6e2af747289769a364a9732ad7d7dca1da7b8413b597c6f13db3d7"
},
{
"name": "ShadowMountPlus",
@@ -97,7 +97,7 @@
"author": "drakmor",
"release_notes": "## v1.6test15-fix2\n\n- Test release notes for **ShadowMountPlus**\n- Backend truncates at 2000 chars",
"details_url": "details/shadowmountplus.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlYzQ4OTknLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiZTE4NWQnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlM8L3RleHQ+PC9zdmc+"
"icon_key": "1296b2bb4c54727c6cbce2cb9369b260da82db620e65eab15695e71f7ff9b1a5"
},
{
"name": "nanoDNS",
@@ -113,7 +113,7 @@
"author": "drakmor",
"release_notes": "## v0.3\n\n- Test release notes for **nanoDNS**\n- Backend truncates at 2000 chars",
"details_url": "details/nanodns.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPk48L3RleHQ+PC9zdmc+"
"icon_key": "bb1c223404f21ab128c87678f2b65a6235f7f64f1e3d1e859825457844b681cf"
},
{
"name": "Lapy-JB-Daemon",
@@ -129,7 +129,7 @@
"author": "itsPLK",
"release_notes": "## v1.2\n\n- Test release notes for **Lapy-JB-Daemon**\n- Backend truncates at 2000 chars",
"details_url": "details/lapy-jb-daemon.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkxKPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "5339f9de791a3de740b492162da35b98f17e4cf81d834738ab77a4c649c910ff"
},
{
"name": "kstuff-lite-dr",
@@ -145,7 +145,7 @@
"author": "drakmor",
"release_notes": "## v1.2-dr-test1\n\n- Test release notes for **kstuff-lite-dr**\n- Backend truncates at 2000 chars",
"details_url": "details/kstuff-lite-dr.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPktMPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "4ab896aedafca2d80168fdbbe4f36c37dd1b37915a2933cb8df7629646513e00"
},
{
"name": "websrv",
@@ -161,7 +161,7 @@
"author": "ps5-payload-dev",
"release_notes": "## v0.32\n\n- Test release notes for **websrv**\n- Backend truncates at 2000 chars",
"details_url": "details/websrv.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlc8L3RleHQ+PC9zdmc+"
"icon_key": "4eb4e0f1b9ab7561da7aeb15bb653a8c6ab38a67d5e68371781037ca3ecbc50b"
},
{
"name": "ftpsrv",
@@ -177,7 +177,7 @@
"author": "ps5-payload-dev",
"release_notes": "## v0.20\n\n- Test release notes for **ftpsrv**\n- Backend truncates at 2000 chars",
"details_url": "details/ftpsrv.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkY8L3RleHQ+PC9zdmc+"
"icon_key": "2c419081ce722485eca2d028b3ec221675f68384ca8ad92ae78c2c0aedfa5680"
},
{
"name": "klogsrv",
@@ -193,7 +193,7 @@
"author": "ps5-payload-dev",
"release_notes": "## v0.8\n\n- Test release notes for **klogsrv**\n- Backend truncates at 2000 chars",
"details_url": "details/klogsrv.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPks8L3RleHQ+PC9zdmc+"
"icon_key": "59ce1ae8ba08a91b5d5673b9e43e1b5b5482369b2fedd47988bfd9183d0bd3f2"
},
{
"name": "shsrv",
@@ -209,7 +209,7 @@
"author": "ps5-payload-dev",
"release_notes": "## v0.18.2\n\n- Test release notes for **shsrv**\n- Backend truncates at 2000 chars",
"details_url": "details/shsrv.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPlM8L3RleHQ+PC9zdmc+"
"icon_key": "f246ab140d0878fb20a494f3f5b00eb40f8651c5861ed829cb8e3b0c62cf3da5"
},
{
"name": "elfldr",
@@ -225,7 +225,7 @@
"author": "ps5-payload-dev",
"release_notes": "## v0.23\n\n- Test release notes for **elfldr**\n- Backend truncates at 2000 chars",
"details_url": "details/elfldr.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNmNTllMGInLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiNDUzMDknLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkU8L3RleHQ+PC9zdmc+"
"icon_key": "a55ff979f29b0960230e96eaac9b06d557330a03b5adb50c53e4dffe3cdb8b6f"
},
{
"name": "kstuff-toggle",
@@ -242,7 +242,7 @@
"author": "EchoStretch",
"release_notes": "## v0.6\n\n- Test release notes for **kstuff-toggle**\n- Backend truncates at 2000 chars",
"details_url": "details/kstuff-toggle.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlYzQ4OTknLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiZTE4NWQnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPktUPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "41e2aece6151a0f938763537798b6616f655581a655393ced6967bed28230200"
},
{
"name": "BackPork",
@@ -258,7 +258,7 @@
"author": "BestPig",
"release_notes": "## v0.1\n\n- Test release notes for **BackPork**\n- Backend truncates at 2000 chars",
"details_url": "details/backpork.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMzYjgyZjYnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMxZDRlZDgnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkI8L3RleHQ+PC9zdmc+"
"icon_key": "1e3d5422de58bfd7b57f0ce717cd36a61d6b446323e60ddc04d61dfaeb1fcc3e"
},
{
"name": "ftpsrv-drakmor",
@@ -274,7 +274,7 @@
"author": "drakmor",
"release_notes": "## v1.15-ng-stable\n\n- Test release notes for **ftpsrv-drakmor**\n- Backend truncates at 2000 chars",
"details_url": "details/ftpsrv-drakmor.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyMxMGI5ODEnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMwNDc4NTcnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkZEPC90ZXh0Pjwvc3ZnPg=="
"icon_key": "a99363bacd1c28400d4a3060fa60cc7007b9fc792c30b5fd6698060c71bc2e2f"
},
{
"name": "kstuff",
@@ -290,7 +290,7 @@
"author": "EchoStretch",
"release_notes": "## v1.6.7\n\n- Test release notes for **kstuff**\n- Backend truncates at 2000 chars",
"details_url": "details/kstuff.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPks8L3RleHQ+PC9zdmc+"
"icon_key": "2a011b42d44df200aaa5c84ae29a479696017d483c1ac15620283b70bedcc52f"
},
{
"name": "etaHEN",
@@ -306,7 +306,8 @@
"author": "etaHEN",
"release_notes": "## v2.5B\n\n- Test release notes for **etaHEN**\n- Backend truncates at 2000 chars",
"details_url": "details/etahen.md",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMjgnIGhlaWdodD0nMTI4Jz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9J2cnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNlZjQ0NDQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyNiOTFjMWMnLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0nMTI4JyBoZWlnaHQ9JzEyOCcgcng9JzI0JyBmaWxsPSd1cmwoI2cpJy8+PHRleHQgeD0nNjQnIHk9JzgyJyBmb250LWZhbWlseT0nQXJpYWwsc2Fucy1zZXJpZicgZm9udC1zaXplPSc0NCcgZm9udC13ZWlnaHQ9J2JvbGQnIGZpbGw9JyNmZmYnIHRleHQtYW5jaG9yPSdtaWRkbGUnPkU8L3RleHQ+PC9zdmc+"
"icon_key": "58c275e6ed7b9045758459aed219fa3d2eb6c907f4b75cba4000ceb61d9275e5"
}
]
],
"icons_url": "test_icons.json"
}
+3
View File
@@ -127,3 +127,6 @@ void log_stream_init(struct LogSSEConnection *conn, const char *id) {
}
void log_stream_cleanup(void *cls) { free(cls); }
#endif
int sources_clear_cache(void) { test_mutation_calls++; return 0; }
size_t sources_icons_json(const char *id, char *buf, size_t size) { (void)id; return json(buf, size, "{\"icons\":{}}"); }
+3
View File
@@ -228,6 +228,9 @@ static int unit_tests(int ownership_only, int oom_only) {
{"/process_kill", "POST", 400, {{0}}},
{"/process_kill", "POST", 200, {{"pid", "123"}}},
{"/process_kill", "POST", 500, {{"pid", "-1"}}},
{"/repository_icons", "GET", 200, {{"source", "default"}}},
{"/repository_cache_clear", "GET", 405, {{0}}},
{"/repository_cache_clear", "POST", 200, {{0}}},
{"/repository_payloads", "GET", 200, {{0}}},
{"/repository_refresh", "POST", 200, {{0}}},
{"/repository_install", "POST", 400, {{0}}},
+50
View File
@@ -0,0 +1,50 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include "sources.h"
#include "config.h"
#include "json_helpers.h"
#include "pldmgr.h"
void config_read(PldmgrConfig *config) { memset(config, 0, sizeof(*config)); config->multi_sources_enabled = 1; }
int config_write_last_update(long value) { (void)value; return 0; }
int download_to_file_limited(const char *url, const char *destination, size_t limit) {
const char *record = getenv("ICON_TEST_RECORD");
FILE *stream = fopen(record, "a"); if (!stream) return -1;
fprintf(stream, "%s\n", url); fclose(stream);
if (getenv("ICON_TEST_FAIL_CATALOG") && !strcmp(url, REPOSITORY_SOURCE_URL)) return -1;
if (getenv("ICON_TEST_FAIL") && strcmp(url, REPOSITORY_SOURCE_URL)) return -1;
const char *fixture = getenv(!strcmp(url, REPOSITORY_SOURCE_URL) ? "ICON_TEST_CATALOG" : "ICON_TEST_ICONS");
char *text = NULL; size_t size = 0;
if (!fixture || read_file_text(fixture, &text, &size) || size > limit) { free(text); return -1; }
int result = write_file_text(destination, text, size); free(text); return result;
}
static void *cache_worker(void *context) {
const char *operation = context;
char *buffer = malloc(4 * 1024 * 1024);
if (!buffer) return (void *)1;
int error = 0;
for (int i = 0; i < 30; i++) {
if (!strcmp(operation, "list")) error |= !sources_multi_repository_list_json(buffer, 4 * 1024 * 1024, 0);
else if (!strcmp(operation, "clear")) error |= sources_clear_cache() != 0;
else sources_icons_json("default", buffer, 4 * 1024 * 1024);
}
free(buffer); return error ? (void *)1 : NULL;
}
int main(int argc, char **argv) {
if (argc < 2) return 2;
if (!strcmp(argv[1], "concurrent")) {
pthread_t threads[3]; const char *operations[] = {"list", "clear", "icons"};
for (int i = 0; i < 3; i++) if (pthread_create(&threads[i], NULL, cache_worker, (void *)operations[i])) return 2;
int error = 0;
for (int i = 0; i < 3; i++) { void *result; if (pthread_join(threads[i], &result) || result) error = 1; }
return error;
}
if (!strcmp(argv[1], "configure") && argc > 2) return sources_save(argv[2], strlen(argv[2])) ? 1 : 0;
if (!strcmp(argv[1], "clear")) return sources_clear_cache() ? 1 : 0;
char *output = malloc(4 * 1024 * 1024); if (!output) return 2;
size_t size = !strcmp(argv[1], "icons") ? sources_icons_json(argc > 2 ? argv[2] : "default", output, 4 * 1024 * 1024) :
sources_multi_repository_list_json(output, 4 * 1024 * 1024, argc > 2);
if (size) puts(output);
free(output); return size ? 0 : 1;
}
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
objects.append(str(obj))
binary = build / "allocation_tests"
subprocess.run(common + objects + ["src/utils.c", "src/sha256.c", "src/config.c", "tests/allocation_driver.c",
"-Wl,--gc-sections", "-o", str(binary)], cwd=ROOT, check=True)
"-Wl,--gc-sections", "-lcurl", "-o", str(binary)], cwd=ROOT, check=True)
header = (ROOT / "include/pldmgr.h").read_text()
url = re.search(r'#define REPOSITORY_SOURCE_URL\s*\\?\s*"([^"]+)"', header)[1]
cache = storage / ("repo_cache_" + hashlib.sha256(url.rstrip('/').encode()).hexdigest()[:8] + ".json")
+1 -1
View File
@@ -49,7 +49,7 @@ with tempfile.TemporaryDirectory(prefix='pldmgr-config-') as temp:
objects.append(str(obj))
binary = build / 'config_tests'
gc = '-Wl,-dead_strip' if sys.platform == 'darwin' else '-Wl,--gc-sections'
subprocess.run(flags+objects+['tests/config_driver.c', gc, '-o', str(binary)], cwd=ROOT, check=True)
subprocess.run(flags+objects+['tests/config_driver.c', gc, '-lcurl', '-o', str(binary)], cwd=ROOT, check=True)
cases = ['history'] if args.legacy_history else (['scalar'] if args.legacy else (['settings', 'history', 'sources'] if args.thread_sanitize else ['scalar','validation','atomic','settings','history','sources']))
for case in cases:
# Independent clean storage for each scenario.
+1 -1
View File
@@ -213,7 +213,7 @@ def security_tests(port, storage, legacy=False):
return
routes = ["/abort", "/autoload_clear", "/process_kill?pid=123", "/repository_refresh",
"/repository_install?id=test", "/sources_add?url=http://example.invalid",
"/repository_cache_clear", "/repository_install?id=test", "/sources_add?url=http://example.invalid",
"/sources_remove?index=1", "/usb_move_perform?path=/mnt/usb0/test.elf",
"/manage:delete?slot=local/test", "/loadpayload:missing.elf",
"/set_config", "/sources_set", "/manage:upload?filename=policy.elf", "/shutdown"]
+3 -3
View File
@@ -81,7 +81,7 @@ int download_to_file_limited(const char *url, const char *path, size_t limit) {
binary = build / "json_size_tests"
sources = [source(name) for name in ["history_mgr", "log_server", "payload_mgr", "slot", "sources", "process_mgr", "config"]]
subprocess.run(common + [str(helper), str(repository), "src/utils.c", "src/sha256.c", *sources,
str(build / "kernel.c"), "tests/json_size_driver.c", "-o", str(binary)],
str(build / "kernel.c"), "tests/json_size_driver.c", "-lcurl", "-o", str(binary)],
cwd=ROOT, check=True)
failures = []
# Fresh on-disk cache proves actual repository serialization without a
@@ -104,7 +104,7 @@ int download_to_file_limited(const char *url, const char *path, size_t limit) {
count = 30 if mode == "repository" else 300
cache.write_text(json.dumps([{"id": f"item-{i}", "name": f"Item {i}", "filename": "safe.elf",
"url": "https://example.invalid/safe.elf", "description": "\x01" * 1000,
"release_notes": "\x01" * 2000, "icon": icon} for i in range(count)]))
"release_notes": "\x01" * 2000, "icon_key": "shared-icon"} for i in range(count)]))
Path(str(cache) + ".ts").write_text(str(int(time.time())))
result = subprocess.run([str(binary), mode], text=True, capture_output=True)
summaries = [line for line in result.stdout.splitlines() if line.startswith("JSON size checks:")]
@@ -130,7 +130,7 @@ int download_to_file_limited(const char *url, const char *path, size_t limit) {
items = document["sources"][0]["payloads"]
assert len(items) == 30
assert all(item["description"] == "\x01" * 1000 and item["release_notes"] == "\x01" * 2000 and
item["icon"] == icon for item in items)
item["icon_key"] == "shared-icon" and "icon" not in item for item in items)
except (ValueError, AssertionError) as error:
failures.append((mode, "independent decoding/content check failed: " + str(error)))
if failures:
+47 -1
View File
@@ -9,6 +9,9 @@ import { createServer } from '../frontend/node_modules/vite/dist/node/index.js'
const frontend = fileURLToPath(new URL('../frontend/', import.meta.url))
process.chdir(frontend)
const baseline = process.argv.includes('--baseline')
const iconsMode = process.argv.includes('--repository-icons')
let iconRequests = 0, iconRevision = 'r1', failClear = false, brokenIcon = false, clearRequests = 0
const fixtureIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aT1cAAAAASUVORK5CYII='
const previousMarkdown = baseline ? execFileSync('git', ['show', 'c799297:frontend/src/utils/markdown.js'], { encoding: 'utf8' }) : null
const chrome = process.env.CHROME_BIN || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome')
let readmeRequests = 0
@@ -20,9 +23,27 @@ const vite = await createServer({ root: frontend, configFile: join(frontend, 'vi
server.middlewares.use(async (req, res, next) => {
if (req.url === '/__markdown_page') {
res.setHeader('Content-Type', 'text/html')
res.end(await server.transformIndexHtml(req.url, '<!doctype html><html><body><div id="root"></div><script type="module" src="/tests/markdownLocale.browser.jsx"></script></body></html>'))
res.end(await server.transformIndexHtml(req.url, `<!doctype html><html><body><div id="root"></div><script type="module" src="/tests/${iconsMode ? 'repositoryIcons' : 'markdownLocale'}.browser.jsx"></script></body></html>`))
return
}
if (iconsMode) {
const json = (value, status = 200) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)) }
if (req.url.startsWith('/__icon_control')) {
const action = new URL(req.url, 'http://fixture').searchParams.get('action');
if (action === 'new') iconRevision = 'r2';
if (action === 'fail') failClear = true;
if (action === 'restore') failClear = false;
if (action === 'break') { brokenIcon = true; iconRevision = 'broken' }
if (action === 'repair') { brokenIcon = false; iconRevision = 'repaired' }
json({ iconRequests, clearRequests }); return
}
if (req.url === '/repository_payloads' || req.url === '/repository_refresh') {
json({ sources: [{ id: 'default', url: 'https://mirror.invalid/payloads.json', icons_revision: iconRevision,
payloads: [{ id: 'one', name: 'One', icon_key: 'shared' }, ...(iconRevision === 'r2' ? [{id:'two',name:'Two',icon_key:'new'}] : [])] }] }); return
}
if (req.url.startsWith('/repository_icons?')) { iconRequests++; json({ icons: {shared: brokenIcon ? 'data:image/png;base64,aGVsbG8=' : fixtureIcon, new: fixtureIcon} }); return }
if (req.url === '/repository_cache_clear') { clearRequests++; json({ok:!failClear}, failClear ? 500 : 200); return }
}
if (req.url === '/__readme') {
readmeRequests++
res.setHeader('Cache-Control', 'public, max-age=3600')
@@ -84,6 +105,30 @@ try {
return response.result?.value
}
await rpc('Page.navigate', { url: `http://127.0.0.1:${vite.httpServer.address().port}/__markdown_page` }, sessionId)
if (iconsMode) {
await eventually(() => evaluate('window.fixtureReady && document.querySelector("[data-testid=icons] img")?.naturalWidth > 0'), 'cached repository image did not render')
const control = action => evaluate(`fetch('/__icon_control?action=${action}').then(r=>r.json())`)
assert.equal((await control('state')).iconRequests, 1)
await evaluate('window.refreshIcons(true)'); assert.equal((await control('state')).iconRequests, 1)
await control('new'); await evaluate('window.refreshIcons(true)')
await eventually(() => evaluate('document.querySelectorAll("[data-testid=icons] img").length === 2'), 'new icon key did not render')
assert.equal((await control('state')).iconRequests, 2)
await control('fail')
const clearButton = `Array.from(document.querySelectorAll('button')).find(b=>b.textContent.includes('Clear Cache'))`
await evaluate(`${clearButton}.click()`)
await eventually(() => evaluate(`document.body.textContent.includes('Could not clear repository cache.')`), 'cache failure was not shown')
assert.equal(await evaluate('document.querySelectorAll("[data-testid=icons] img").length'), 2)
await control('restore'); await evaluate(`${clearButton}.click()`)
await eventually(() => evaluate(`document.body.textContent.includes('Repository cache cleared.')`), 'cache success was not shown')
assert.equal((await control('state')).iconRequests, 3)
await control('break'); await evaluate('window.refreshIcons(true)')
await eventually(() => evaluate('document.querySelectorAll("[data-testid=icons] img").length === 0'), 'invalid image failed to show fallback')
await control('repair'); await evaluate('window.refreshIcons(true)')
await eventually(() => evaluate('document.querySelector("[data-testid=icons] img")?.naturalWidth > 0'), 'updated icon failed to recover after an image error')
assert.deepEqual(remoteRequests, [])
assert.deepEqual(await evaluate('window.__errors'), [])
console.log('Repository icon browser: separate fetches, revision reuse, new keys, Settings clear success/failure, fallback and image recovery passed')
} else {
try {
await eventually(() => evaluate('window.fixtureReady === true && document.body.textContent.includes("Cached README")'), 'real details panel failed to load README')
} catch (error) {
@@ -197,6 +242,7 @@ try {
assert.deepEqual(await evaluate('window.__errors'), [])
console.log('Markdown/locale browser: real DOM sanitation, malicious URLs/media, unavailable sanitizer, decoded offline emoji sprites, cached README, link confirmation, Dutch selection/fallback and representative locales passed')
}
}
} finally {
ws?.close()
if (child) {
+7 -1
View File
@@ -17,8 +17,14 @@ try {
await wait(50)
}
assert.ok(output.includes('Running at'), output)
const catalog = await (await request('/repository_payloads')).json()
const entries = catalog.sources[0].payloads
assert.ok(entries.some(entry => entry.icon_key))
assert.ok(entries.every(entry => !Object.hasOwn(entry, 'icon')))
const images = await (await request('/repository_icons?source=default')).json()
assert.ok(entries.filter(entry => entry.icon_key).every(entry => images.icons[entry.icon_key]?.startsWith('data:image/')))
const sources = await (await request('/sources_list')).json()
for (const route of ['/abort', '/autoload_clear', '/repository_refresh', '/repository_install?id=test',
for (const route of ['/repository_cache_clear', '/abort', '/autoload_clear', '/repository_refresh', '/repository_install?id=test',
'/sources_add?url=http://evil.invalid', '/sources_remove?index=1', '/manage:delete?slot=local/test',
'/loadpayload:local/test', '/process_kill?pid=123', '/usb_move_perform?path=test', '/shutdown',
'/set_config', '/sources_set', '/manage:upload?filename=test.elf']) {
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Persistent icon caches and real catalog serialization with a recorded transport."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('--thread-sanitize', action='store_true')
args = parser.parse_args()
ROOT = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix='pldmgr-icons-') as temporary:
build = Path(temporary)
storage = build / 'data'
storage.mkdir()
header = (ROOT / 'include/pldmgr.h').read_text().replace('/data/pldmgr', str(storage))
source = 'https://mirror.invalid/catalog/payloads.json'
import re
header = re.sub(r'(#define REPOSITORY_SOURCE_URL\s*\\?\s*)"[^"]+"', lambda m: m[1] + json.dumps(source), header)
(build / 'pldmgr.h').write_text(header)
(build / 'assets_cacert_pem.h').write_text('static const unsigned char assets_cacert_pem[]={0};\nstatic const unsigned int assets_cacert_pem_len=0;\n')
flags = shlex.split(os.environ.get('HOST_CC', 'clang')) + ['-std=c11', '-D_DEFAULT_SOURCE', '-g', '-O1', '-Wall', '-Wextra', '-pthread',
'-ffunction-sections', '-fdata-sections', '-fsanitize=thread' if args.thread_sanitize else '-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-I'+str(build), '-Iinclude']
objects = []
for name in ['sources', 'repository', 'slot', 'json_helpers', 'sha256', 'utils']:
obj = build / (name + '.o')
extra = ['-Ddownload_to_file=icons_unused_download', '-Ddownload_to_file_limited=icons_unused_download_limited'] if name == 'repository' else []
subprocess.run(flags + extra + ['-c', 'src/'+name+'.c', '-o', str(obj)], cwd=ROOT, check=True)
objects.append(str(obj))
binary = build / 'icons-tests'
subprocess.run(flags + objects + ['tests/repository_icons_driver.c', '-lcurl', '-Wl,-dead_strip' if sys.platform == 'darwin' else '-Wl,--gc-sections', '-o', str(binary)], cwd=ROOT, check=True)
catalog, images, record = [build / name for name in ('catalog.json', 'icons.json', 'requests.txt')]
env = os.environ.copy()
env.update(ICON_TEST_CATALOG=str(catalog), ICON_TEST_ICONS=str(images), ICON_TEST_RECORD=str(record), ASAN_OPTIONS='detect_leaks=0:halt_on_error=1' if sys.platform == 'darwin' else 'detect_leaks=1:halt_on_error=1')
image = 'data:image/png;base64,aGVsbG8='
image2 = 'data:image/png;base64,d29ybGQ='
items = [{'id':'one','name':'One','filename':'one.elf','url':'https://mirror.invalid/one.elf','icon_key':'shared'}]
def manifest(url='icons.json'):
catalog.write_text(json.dumps({'name':'Fixture','icons_url':url,'payloads':items}))
def run(*args, fail=False):
current = dict(env)
if fail: current['ICON_TEST_FAIL']='1'
result = subprocess.run([str(binary),*args],cwd=ROOT,env=current,check=True,capture_output=True,text=True)
return json.loads(result.stdout) if result.stdout.strip() else None
def requests(): return record.read_text().splitlines() if record.exists() else []
def icon_requests(): return [url for url in requests() if url != source]
def revision(force=False,fail=False):
listing=run('list', *(['force'] if force else []),fail=fail)
assert 'icon' not in listing['sources'][0]['payloads'][0]
return listing['sources'][0]['icons_revision']
meta = storage / ('repo_icons_'+hashlib.sha256(source.encode()).hexdigest()+'.meta')
def expire():
state=json.loads(meta.read_text());state.update(updated=str(int(time.time())-8*86400),attempted='0');meta.write_text(json.dumps(state))
manifest();images.write_text(json.dumps({'icons':{'shared':image}}))
first=revision();assert len(first)==64 and icon_requests()==['https://mirror.invalid/catalog/icons.json']
assert run('icons')['icons']=={'shared':image}
assert revision()==first and len(icon_requests())==1 # separate processes prove disk persistence
assert revision(force=True)==first and len(icon_requests())==1 # catalog refresh does not force image fetch
items.append({'id':'two','name':'Two','filename':'two.elf','url':'https://mirror.invalid/two.elf','icon_key':'new'})
manifest();images.write_text(json.dumps({'icons':{'shared':image,'new':image2}}))
second=revision(force=True);assert second!=first and len(icon_requests())==2
assert run('icons')['icons']['new']==image2
assert revision()==second and len(icon_requests())==2
images.write_text(json.dumps({'icons':{'shared':image2,'new':image2}}));expire()
third=revision();assert third!=second and len(icon_requests())==3
expire();assert revision(fail=True)==third and len(icon_requests())==4
assert revision(fail=True)==third and len(icon_requests())==4 # failure backoff
items[1]['icon_key']='absent';manifest();assert revision(force=True)==third and len(icon_requests())==5
assert revision()==third and len(icon_requests())==5 # permanently absent keys do not refetch on every list
expire();images.write_text('{"icons":{"shared":"https://bad.invalid/image.png"}}')
assert revision()==third and run('icons')['icons']['shared']==image2
expire();images.write_text('{"icons":{"shared":"data:image/png;base64,aGVsbG8=","shared":"data:image/png;base64,aGVsbG8="}}')
assert revision()==third # duplicate keys cannot replace the good cache
images.write_text(json.dumps({'icons':{'shared':image,'absent':image}}))
for relative, expected in [('nested/../icons.json','https://mirror.invalid/catalog/icons.json'),('/assets/icons.json','https://mirror.invalid/assets/icons.json'),('https://cdn.invalid/icons.json','https://cdn.invalid/icons.json')]:
manifest(relative);revision(force=True);assert icon_requests()[-1]==expected
before=len(icon_requests());manifest('https://new.invalid/icons.json')
kept=revision(force=True,fail=True);assert kept and len(icon_requests())==before+1
assert revision(fail=True)==kept and len(icon_requests())==before+1
state=json.loads(meta.read_text());state['attempted']='0';meta.write_text(json.dumps(state))
assert revision() and len(icon_requests())==before+2 # failed URL changes are retried after backoff
for bad in ['file:///tmp/icons.json','https://user:password@cdn.invalid/icons.json','http://cdn.invalid/icons.json']:
before=len(icon_requests());manifest(bad);assert revision(force=True)=='' and len(icon_requests())==before
catalog.write_text(json.dumps(items));assert revision(force=True)=='' # bare catalogs without icons supported
catalog_cache=storage/('repo_cache_'+hashlib.sha256(source.encode()).hexdigest()[:8]+'.json')
manifest();images.write_text(json.dumps({'icons':{'shared':image,'absent':image}}));revision(force=True)
Path(str(catalog_cache)+'.ts').write_text('0')
outage=dict(env,ICON_TEST_FAIL_CATALOG='1',ICON_TEST_FAIL='1')
cached_listing=json.loads(subprocess.check_output([str(binary),'list'],cwd=ROOT,env=outage,text=True))
assert cached_listing['sources'][0]['error'] is True
assert len(cached_listing['sources'][0]['payloads'])==len(items)
assert cached_listing['sources'][0]['icons_revision']
Path(str(catalog_cache)+'.ts').write_text(str(int(time.time())))
custom='https://another.invalid/catalog/payloads.json'
run('configure', json.dumps({'sources':[{'id':'other','name':'Other','url':custom}]}))
other_cache=storage/('repo_cache_'+hashlib.sha256(custom.encode()).hexdigest()[:8]+'.json')
other_cache.write_text(json.dumps({'icons_url':'icons.json','payloads':items}))
Path(str(other_cache)+'.ts').write_text(str(int(time.time())))
images.write_text(json.dumps({'icons':{'shared':image2,'absent':image2}}))
listing=run('list');assert listing['sources'][1]['icons_revision']
assert run('icons','other')['icons']['shared']==image2
assert run('icons','default')['icons']['shared']==image
assert icon_requests()[-1]=='https://another.invalid/catalog/icons.json'
state=json.loads(meta.read_text());state['attempted']='0';meta.write_text(json.dumps(state))
manifest();images.write_text('x'*(4*1024*1024));retained=run('icons')['icons']
revision(force=True);assert run('icons')['icons']==retained
assert subprocess.run([str(binary),'icons','https://arbitrary.invalid/icons.json'],cwd=ROOT,env=env,capture_output=True).returncode==1
(storage/('repo_icons_'+hashlib.sha256(source.encode()).hexdigest()+'.json.tmp-ABC123')).write_text('partial')
# Only exact cache names are deleted; settings, payloads and unrelated names survive.
preserved = ['favorites.txt','autoload.txt','sources.json','repo_cache_not-a-hash.json']
for name in preserved: (storage/name).write_text('{}' if name=='sources.json' else 'preserve')
(storage/'payloads').mkdir();(storage/'payloads'/'one.elf').write_bytes(b'preserve')
run('clear')
assert not list(storage.glob('repo_icons_*')) and not list(storage.glob('repo_cache_????????.json*'))
assert all((storage/name).exists() for name in preserved) and (storage/'payloads'/'one.elf').read_bytes()==b'preserve'
(storage/'sources.json').unlink()
manifest();images.write_text(json.dumps({'icons':{'shared':image,'absent':image}}));assert revision()
run('concurrent')
print('Icon cache: persistence, weekly refresh, unknown keys, retry backoff, failed/malformed refresh retention, URL resolution, scoped clearing and concurrent list/read/clear passed.')
+1 -1
View File
@@ -35,7 +35,7 @@ with tempfile.TemporaryDirectory(prefix="pldmgr-storage-") as temp:
subprocess.run(flags + extra + ["-c", source, "-o", str(obj)], cwd=ROOT, check=True)
objects.append(str(obj))
binary = build / "storage_tests"
subprocess.run(flags + objects + ["tests/storage_driver.c", "-Wl,--gc-sections", "-o", str(binary)], cwd=ROOT, check=True)
subprocess.run(flags + objects + ["tests/storage_driver.c", "-Wl,--gc-sections", "-lcurl", "-o", str(binary)], cwd=ROOT, check=True)
url = re.search(r'#define REPOSITORY_SOURCE_URL\s*\\?\s*"([^"]+)"', header)[1]
cache = storage / ("repo_cache_" + hashlib.sha256(url.rstrip('/').encode()).hexdigest()[:8] + ".json")
cache.write_text(json.dumps([{"id":"fixture", "name":"Fixture", "filename":"safe.elf", "url":"fixture://payload", "version":"2"}]))