mirror of
https://github.com/itsPLK/ps5-payload-manager.git
synced 2026-10-19 02:19:53 -05:00
feat: cache repository icon catalogs separately
This commit is contained in:
1 parent
a5a1760070
commit
9e6c5918a7
50 files changed
+931
-100
No files matched your search
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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' }))
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "امسح الكتالوجات والأيقونات المحملة ثم أعد تنزيلها. تبقى الحمولات المثبتة والإعدادات محفوظة."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "فهرستها و نمادهای دریافتشده را پاک و دوباره دریافت میکند. پیلودهای نصبشده و تنظیمات حفظ میشوند."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "ダウンロード済みのカタログとアイコンを削除し、再取得します。インストール済みのペイロードと設定は保持されます。"
|
||||
}
|
||||
@@ -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": "다운로드한 목록과 아이콘을 지우고 다시 다운로드합니다. 설치된 페이로드와 설정은 유지됩니다."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Удаляет загруженные каталоги и значки, затем загружает их заново. Установленные пейлоады и настройки сохраняются."
|
||||
}
|
||||
@@ -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": "ล้างรายการและไอคอนที่ดาวน์โหลด แล้วดาวน์โหลดใหม่ เพย์โหลดที่ติดตั้งและการตั้งค่าจะยังคงอยู่"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Видаляє завантажені каталоги та значки, потім завантажує їх знову. Встановлені пейлоади й налаштування зберігаються."
|
||||
}
|
||||
@@ -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": "清除已下载的目录和图标,然后重新下载。已安装的载荷和设置将保留。"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, ¤t_size, ¤t)) 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) {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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\":{}}"); }
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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.')
|
||||
@@ -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"}]))
|
||||
|
||||
Reference in new issue
Block a user