feat(repository): overhaul payload repository to app store experience

This commit completely redesigns how users browse, view, and install payloads.

Backend C Changes:
- Updated C structures to support multiple concurrent payload sources (config.h, repository.h).
- Modified repository.c and config.c to parse and retain multi-source JSON payloads.
- Allowed the HTTP server to serve the dynamically merged JSON sources.

Frontend React Changes:
- Converted StorageHub.jsx into a grid-based "App Shop" catalog layout.
- Replaced the modal payload view with a seamless full-page PayloadDetailPanel.jsx component for extended markdown reading and details.
- Added RepositoryContext.jsx to unify repository fetching across views.
- Styled payload cards with a dynamic marquee animation for overflowing text names (PayloadName.jsx).
- Allowed external icons, with a fallback placeholder for payloads without one.
- Polished padding, margins, and transition effects for spatial controller navigation.
- Removed duplicate versioning strings in payload titles and tweaked dashboard header text.
This commit is contained in:
najdek committed 2026-08-10 15:56:38 +02:00
1 parent 218f291d0e
commit cb3fbf4716
19 files changed
+713 -255

No files matched your search

+30
View File
@@ -9,9 +9,11 @@
"version": "0.1.1",
"dependencies": {
"clsx": "^2.1.1",
"dompurify": "^3.4.13",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"marked": "^18.0.9",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -1422,6 +1424,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -2476,6 +2485,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -4861,6 +4879,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/matcher-collection": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/matcher-collection/-/matcher-collection-2.0.1.tgz",
+2
View File
@@ -18,9 +18,11 @@
},
"dependencies": {
"clsx": "^2.1.1",
"dompurify": "^3.4.13",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"marked": "^18.0.9",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+80 -20
View File
@@ -11,12 +11,16 @@ import {
Menu,
Terminal,
X,
Star
Star,
Trash2
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import './App.css'
// Contexts
import { RepositoryProvider } from './contexts/RepositoryContext'
// Utilities
import { cn, isPS5, isSystemPayload } from './utils/helpers'
@@ -42,6 +46,7 @@ function App() {
const { t } = useTranslation();
const [view, setView] = useState('dashboard')
const [isFavoriteEditMode, setIsFavoriteEditMode] = useState(false)
const [isManageMode, setIsManageMode] = useState(false)
const mainRef = useRef(null)
useEffect(() => {
@@ -115,6 +120,20 @@ function App() {
}, [showLogs])
const [isOffline, setIsOffline] = useState(false)
const [updateStatus, setUpdateStatus] = useState(null)
useEffect(() => {
const checkUpdates = async () => {
try {
const data = await api('/update_check')
if (data && data.status !== 'none') {
setUpdateStatus(data)
}
} catch (e) {}
}
checkUpdates()
}, [])
@@ -426,12 +445,11 @@ function App() {
}
return (
<div className={cn(
"min-h-screen min-h-[100dvh] ps5-bg text-zinc-100 font-ps5 flex",
isPS5 ? "flex-row overflow-hidden" : "flex-col md:flex-row md:overflow-hidden"
)}>
<RepositoryProvider config={config}>
<div className={cn(
"min-h-screen min-h-[100dvh] ps5-bg text-zinc-100 font-ps5 flex",
isPS5 ? "flex-row overflow-hidden" : "flex-col md:flex-row md:overflow-hidden"
)}>
{/* Toast Container */}
<div className="fixed top-0 right-0 p-8 z-[2000] space-y-4 pointer-events-none">
{toasts.map(t => (
@@ -536,6 +554,25 @@ function App() {
"flex flex-col relative",
isPS5 ? "h-screen flex-1 min-h-0" : "md:h-screen md:flex-1 md:min-h-0"
)}>
{updateStatus && (
<div className="bg-ps-blue/20 border-b border-ps-blue/50 p-4 flex items-center justify-between z-[50]">
<div className="flex items-center gap-3">
<CloudDownload className="w-5 h-5 text-ps-blue" />
<span className="text-zinc-200">
<strong className="text-white">Update Available!</strong>
{updateStatus.status === 'ready-to-apply'
? " A new version is ready to be applied. Launch it from the dashboard."
: " A new version is available in the repository. Please download it."}
</span>
</div>
<button
onClick={() => setUpdateStatus(null)}
className="p-2 hover:bg-white/10 rounded-full transition-colors text-zinc-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
)}
<main ref={mainRef} className={cn(
"custom-scrollbar max-w-[1800px] mx-auto w-full flex flex-col",
isPS5 ? "pt-16 px-16 pb-12 flex-1 overflow-y-auto" : "pt-6 px-6 pb-36 md:pt-16 md:px-16 md:pb-12 md:flex-1 md:overflow-y-auto"
@@ -557,13 +594,20 @@ function App() {
<PayloadButton
key={p}
path={p}
onClick={() => isFavoriteEditMode ? toggleFavorite(p) : loadPayload(p)}
onClick={() => {
if (isManageMode) handleDelete(p.split('/').pop())
else if (isFavoriteEditMode) toggleFavorite(p)
else loadPayload(p)
}}
isLoading={loading && activeLoadingName === p.split('/').pop().replace(/\.(elf|bin)$/i, '').replace(/_/g, ' ')}
sourceName={config.MULTI_SOURCES_ENABLED ? (payloadMeta[p.split('/').pop()]?.source_name || null) : null}
version={payloadMeta[p.split('/').pop()]?.version || null}
icon={payloadMeta[p.split('/').pop()]?.icon || null}
author={payloadMeta[p.split('/').pop()]?.author || null}
isFavorite={favoritePayloads.includes(p)}
isLaunched={launchHistory.includes(p)}
isEditMode={isFavoriteEditMode}
isManageMode={isManageMode}
onMoveFavorite={moveFavorite}
canMoveLeft={activeFavorites.indexOf(p) > 0}
canMoveRight={activeFavorites.indexOf(p) < activeFavorites.length - 1}
@@ -572,29 +616,44 @@ function App() {
return (
<div className={cn(
"space-y-8 md:space-y-12 transition-all",
isFavoriteEditMode && "-m-4 md:-m-6 p-4 md:p-6 border-2 border-yellow-400/50 bg-yellow-400/5 rounded-ps-2xl"
isFavoriteEditMode ? "-m-4 md:-m-6 p-4 md:p-6 border-2 border-yellow-400/50 bg-yellow-400/5 rounded-ps-2xl" :
isManageMode ? "-m-4 md:-m-6 p-4 md:p-6 border-2 border-red-500/50 bg-red-500/5 rounded-ps-2xl" : ""
)}>
<div className="flex items-center justify-between">
<h2 className={cn(
"text-4xl font-extrabold tracking-tight",
isFavoriteEditMode ? "text-yellow-400" : "text-white"
isFavoriteEditMode ? "text-yellow-400" : isManageMode ? "text-red-500" : "text-white"
)}>
{isFavoriteEditMode ? (
t("app.dashboard.editFavorites", "Edit Favorites")
) : isManageMode ? (
t("app.dashboard.removePayload", "Remove Payload")
) : (
<>{t("app.dashboard.title_1", "Launch")} <span className="text-ps-blue">{t("app.dashboard.title_2", "Payload")}</span></>
)}
</h2>
<button
onClick={() => setIsFavoriteEditMode(!isFavoriteEditMode)}
className={cn(
"p-3 rounded-full transition-colors flex items-center justify-center",
isFavoriteEditMode ? "bg-yellow-400/20 text-yellow-400" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
)}
title={t("app.dashboard.editFavorites", "Edit Favorites")}
>
<Star className={cn("w-6 h-6", isFavoriteEditMode && "fill-yellow-400")} />
</button>
<div className="flex items-center space-x-3">
<button
onClick={() => { setIsManageMode(!isManageMode); setIsFavoriteEditMode(false); }}
className={cn(
"p-3 rounded-full transition-colors flex items-center justify-center",
isManageMode ? "bg-red-600/20 text-red-500" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
)}
title={t("app.dashboard.removePayload", "Remove Payload")}
>
<Trash2 className={cn("w-6 h-6", isManageMode && "text-red-500")} />
</button>
<button
onClick={() => { setIsFavoriteEditMode(!isFavoriteEditMode); setIsManageMode(false); }}
className={cn(
"p-3 rounded-full transition-colors flex items-center justify-center",
isFavoriteEditMode ? "bg-yellow-400/20 text-yellow-400" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
)}
title={t("app.dashboard.editFavorites", "Edit Favorites")}
>
<Star className={cn("w-6 h-6", isFavoriteEditMode && "fill-yellow-400")} />
</button>
</div>
</div>
{loadingPayloads ? (
<div className={gridCols}>
@@ -750,6 +809,7 @@ function App() {
</div>
)}
</div>
</RepositoryProvider>
)
}
+32 -8
View File
@@ -1,19 +1,38 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, Globe, Star, ChevronLeft, ChevronRight, CheckCircle2 } from 'lucide-react'
import { Loader2, Globe, Star, ChevronLeft, ChevronRight, CheckCircle2, Trash2 } from 'lucide-react'
import PayloadName from './PayloadName'
import PayloadIcon from './PayloadIcon'
import { cn } from '../../utils/helpers'
const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavorite, isLaunched, isEditMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
const PayloadButton = ({ path, onClick, isLoading, sourceName, version, icon, author, isFavorite, isLaunched, isEditMode, isManageMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
const { t } = useTranslation()
const isUsb = path.startsWith('/mnt/usb')
return (
<button
onClick={onClick}
disabled={isLoading}
className="group glass-card p-6 rounded-ps-xl flex flex-col border border-white/5 hover:border-ps-blue hover:bg-ps-blue/5 transition-all text-left relative"
disabled={isLoading || (isManageMode && isUsb)}
className={cn(
"group glass-card pl-4 pr-3 py-4 rounded-ps-xl flex flex-col border transition-all text-left relative",
isManageMode ? "border-red-500/20 hover:border-red-500 hover:bg-red-500/10" : "border-white/5 hover:border-ps-blue hover:bg-ps-blue/5",
isManageMode && isUsb && "opacity-50 grayscale cursor-not-allowed"
)}
>
<div className="flex items-start justify-between w-full z-10">
<PayloadName path={path} version={version} className="text-white text-xl" stacked />
<div className="flex items-center gap-4 min-w-0 pr-2">
<PayloadIcon icon={icon} name={path.split('/').pop()} className="w-12 h-12 text-xl shadow-md" />
<div className="min-w-0 flex-1">
<PayloadName path={path} hideVersion={true} className="text-white text-xl" stacked={false} />
<div className="flex items-center gap-2 mt-0.5">
{version && <span className="text-[10px] text-zinc-400 font-bold bg-white/10 px-1.5 py-0.5 rounded uppercase shrink-0">{version}</span>}
{author && (
<div className="text-[10px] text-zinc-500 font-medium truncate">
{author}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isEditMode && (
<div
@@ -25,7 +44,12 @@ const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavori
<Star className={cn("w-5 h-5", isFavorite && "fill-yellow-400")} />
</div>
)}
{isLaunched && !isEditMode && !isLoading && (
{isManageMode && !isUsb && (
<div className="p-1 rounded-lg text-red-500 transition-all opacity-50 group-hover:opacity-100">
<Trash2 className="w-5 h-5" />
</div>
)}
{isLaunched && !isEditMode && !isManageMode && !isLoading && (
<div className="group/tooltip relative p-1 text-green-400">
<CheckCircle2 className="w-5 h-5" />
<div className="absolute bottom-full right-0 mb-2 px-2 py-1 bg-ps-black border border-white/10 shadow-lg text-xs font-bold text-white rounded opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
@@ -36,12 +60,12 @@ const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavori
{isLoading && <Loader2 className="w-6 h-6 animate-spin text-ps-blue" />}
</div>
</div>
{path.startsWith('/mnt/usb') && (
{isUsb && (
<div className="mt-3 text-[10px] text-zinc-500 font-medium truncate opacity-60 group-hover:opacity-100 transition-opacity z-10 select-none">
{path}
</div>
)}
{sourceName && !path.startsWith('/mnt/usb') && !(isEditMode && isFavorite) && (
{sourceName && !isUsb && !(isEditMode && isFavorite) && !isManageMode && (
<div className="absolute bottom-2 right-3 flex items-center gap-1 z-10 pointer-events-none">
<Globe className="w-3 h-3 text-zinc-500 shrink-0" />
<span className="text-[11px] text-zinc-400 font-medium truncate max-w-[120px] select-none">
@@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '../../utils/helpers';
import PayloadName from './PayloadName';
import PayloadIcon from './PayloadIcon';
const PayloadCard = ({ payload, onClick, isUpdate, isInstalled }) => {
return (
<button
onClick={onClick}
className={cn(
"group flex flex-col p-4 md:p-6 bg-white/[0.02] hover:bg-white/[0.05] border border-white/5 hover:border-ps-blue/30 rounded-3xl transition-all text-left relative",
(isInstalled && !isUpdate) && "opacity-60"
)}
>
<div className="flex justify-center mb-4 w-full">
<PayloadIcon icon={payload.icon} name={payload.filename} className="w-20 h-20 md:w-28 md:h-28 shadow-2xl" />
</div>
<div className="flex flex-col flex-1 min-w-0 w-full text-center">
<PayloadName path={payload.filename} hideVersion={true} className="text-lg md:text-xl text-white font-extrabold truncate w-full flex justify-center" />
<div className="flex items-center justify-center gap-2 mt-1 w-full">
{payload.version && <span className="text-[10px] text-zinc-400 font-bold bg-white/10 px-1.5 py-0.5 rounded uppercase shrink-0">{payload.version}</span>}
{payload.author && (
<div className="text-zinc-500 font-medium text-[11px] md:text-xs truncate">
{payload.author}
</div>
)}
</div>
</div>
{/* Badges */}
<div className="absolute top-4 right-4 flex flex-col gap-2">
{isUpdate && (
<span className="px-2 py-1 rounded-lg bg-emerald-500/20 text-emerald-400 font-bold text-[10px] uppercase tracking-widest border border-emerald-500/20">
Update
</span>
)}
</div>
</button>
);
};
export default PayloadCard;
@@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react';
import { X, CloudDownload, Loader2, ArrowLeft } from 'lucide-react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { cn } from '../../utils/helpers';
import PayloadIcon from './PayloadIcon';
import PayloadName from './PayloadName';
const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate, srcUrl, srcId }) => {
const [detailsHtml, setDetailsHtml] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!payload?.details_url) return;
// Resolve full URL if relative
let url = payload.details_url;
if (!url.startsWith('http')) {
// If srcUrl is the repo json, resolve relative to that directory
if (srcUrl) {
const base = srcUrl.substring(0, srcUrl.lastIndexOf('/') + 1);
url = base + url;
}
}
const fetchDetails = async () => {
setLoading(true);
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch details');
const text = await res.text();
const html = DOMPurify.sanitize(marked.parse(text));
setDetailsHtml(html);
} catch (err) {
setDetailsHtml('<p class="text-red-400">Failed to load details.</p>');
} finally {
setLoading(false);
}
};
fetchDetails();
}, [payload, srcUrl]);
if (!payload) return null;
return (
<div className="flex flex-col w-full min-h-full animate-in fade-in duration-300">
{/* View Header with Back Button */}
<div className="flex items-center justify-between mb-8">
<button
onClick={onClose}
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-4 py-2 rounded-xl"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-bold text-sm uppercase tracking-wider">Back to Repository</span>
</button>
</div>
{/* Header section */}
<div className="flex flex-col md:flex-row items-start gap-6 border-b border-white/10 pb-8 shrink-0">
<div className="flex justify-between w-full md:w-auto">
<PayloadIcon icon={payload.icon} name={payload.filename} className="w-24 h-24 text-4xl shadow-xl" />
</div>
<div className="flex-1 min-w-0 w-full">
<div className="flex justify-between items-start">
<PayloadName path={payload.filename} version={payload.version} className="text-2xl md:text-3xl text-white font-extrabold truncate w-full" />
</div>
<div className="text-zinc-400 font-medium text-sm mt-1 mb-4 flex items-center gap-2">
{payload.author && <span className="text-ps-blue">{payload.author}</span>}
{payload.author && <span>&bull;</span>}
<span className="opacity-70 truncate">{payload.filename}</span>
</div>
<button
onClick={() => onInstall(payload, srcId, srcUrl)}
className={cn(
"w-full flex items-center justify-center space-x-3 px-6 py-4 rounded-2xl font-bold text-lg transition-all active:scale-95",
isUpdate ? "bg-emerald-600 hover:bg-emerald-500 text-white" :
isInstalled ? "bg-white/10 text-white/50 cursor-not-allowed" :
"bg-ps-blue hover:bg-ps-blue/80 text-white"
)}
disabled={isInstalled && !isUpdate}
>
<CloudDownload className="w-5 h-5" />
<span>
{isUpdate ? "Update" : isInstalled ? "Installed" : "Install"}
</span>
</button>
</div>
</div>
<div className="flex-1 p-6 md:p-8">
{payload.description && (
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-3">Description</h3>
<p className="text-zinc-300 leading-relaxed text-base">{payload.description}</p>
</div>
)}
{payload.release_notes && (
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-3">What's New</h3>
<div className="text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap bg-white/5 p-4 rounded-2xl border border-white/5">
{payload.release_notes}
</div>
</div>
)}
{payload.details_url && (
<div className="mt-8 pt-8 border-t border-white/10">
<h3 className="text-lg font-bold text-white mb-4">About</h3>
{loading ? (
<div className="flex items-center gap-3 text-zinc-500">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Loading details...</span>
</div>
) : detailsHtml ? (
<div
className="prose prose-invert max-w-none prose-a:text-ps-blue hover:prose-a:text-ps-blue/80 prose-headings:text-white prose-p:text-zinc-300"
dangerouslySetInnerHTML={{ __html: detailsHtml }}
/>
) : null}
</div>
)}
</div>
</div>
);
};
export default PayloadDetailPanel;
@@ -0,0 +1,50 @@
import React from 'react';
import { cn } from '../../utils/helpers';
import { Package } from 'lucide-react';
const COLORS = [
['#3b82f6', '#1d4ed8'], // blue
['#10b981', '#047857'], // emerald
['#8b5cf6', '#6d28d9'], // violet
['#f59e0b', '#b45309'], // amber
['#ef4444', '#b91c1c'], // red
['#ec4899', '#be185d'], // pink
['#14b8a6', '#0f766e'], // teal
];
const stringToHash = (str) => {
let hash = 0;
if (!str) return 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash);
};
const PayloadIcon = ({ icon, name, className }) => {
if (icon) {
return (
<div className={cn("rounded-2xl overflow-hidden shadow-lg border border-white/10 shrink-0 bg-white/5", className)}>
<img src={icon} alt={name || 'Payload'} className="w-full h-full object-cover" />
</div>
);
}
// Fallback
const hash = stringToHash(name || 'Payload');
const [colorStart, colorEnd] = COLORS[hash % COLORS.length];
return (
<div
className={cn("rounded-2xl flex flex-col items-center justify-center shadow-lg border border-white/10 shrink-0 text-white font-bold relative overflow-hidden", className)}
style={{
background: `linear-gradient(135deg, ${colorStart}, ${colorEnd})`
}}
>
<div className="absolute inset-0 bg-black/20" />
<Package className="w-1/2 h-1/2 opacity-80 z-10 drop-shadow-md" />
</div>
);
};
export default PayloadIcon;
+48 -4
View File
@@ -1,24 +1,68 @@
import React from 'react'
import React, { useRef, useState, useEffect } from 'react'
import { Zap, Usb } from 'lucide-react'
import { cn, parsePayloadName } from '../../utils/helpers'
import { useTranslation } from 'react-i18next'
const PayloadName = ({ path, version: repoVersion, className, versionClassName, stacked = false, hideIcon = false, lastUpdate = null }) => {
const PayloadName = ({ path, version: repoVersion, className, versionClassName, stacked = false, hideIcon = false, lastUpdate = null, hideVersion = false }) => {
const { t } = useTranslation();
let { displayName, version, isDelay, delaySeconds } = parsePayloadName(path);
const isUsb = path?.startsWith('/mnt/usb');
if (repoVersion) version = repoVersion;
if (hideVersion) version = null;
if (isDelay) {
displayName = t("autoload.delay_item", "Delay ({{seconds}}s)", { seconds: delaySeconds || 1 });
}
const containerRef = useRef(null);
const textRef = useRef(null);
const [scrollAmount, setScrollAmount] = useState(0);
useEffect(() => {
const checkOverflow = () => {
if (containerRef.current && textRef.current) {
const overflow = textRef.current.scrollWidth - containerRef.current.clientWidth;
setScrollAmount(overflow > 0 ? overflow : 0);
}
};
// Check initially and on resize
checkOverflow();
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}, [displayName]);
return (
<div className={cn("flex min-w-0 flex-1 w-full", stacked ? "flex-col items-stretch" : "items-center space-x-3", className)}>
<div className="flex items-center space-x-2 min-w-0">
<div className="flex items-center space-x-2 min-w-0 flex-1">
{isDelay && !hideIcon && <Zap className="w-4 h-4 text-ps-blue shrink-0" />}
{isUsb && !hideIcon && <Usb className="w-5 h-5 text-ps-blue shrink-0 mr-1" />}
<span className="font-bold truncate shrink leading-tight">{displayName}</span>
<div
className="overflow-hidden min-w-0 flex-1 relative"
ref={containerRef}
>
{/* Static truncated text with ellipsis */}
<div
className={cn(
"truncate font-bold text-left leading-tight transition-opacity",
scrollAmount > 0 ? "group-hover:opacity-0 group-focus-within:opacity-0" : ""
)}
>
{displayName}
</div>
{/* Scrolling text overlay (only visible on hover/focus if overflowing) */}
<div
ref={textRef}
className={cn(
"absolute top-0 left-0 font-bold text-left leading-tight whitespace-nowrap inline-block transition-opacity",
scrollAmount > 0 ? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 animate-marquee" : "opacity-0 pointer-events-none"
)}
style={{ '--scroll-amount': `${scrollAmount}px` }}
>
{displayName}
</div>
</div>
</div>
{(version || lastUpdate) && (
<div className={cn("flex items-center", stacked ? "mt-1" : "")}>
+89 -208
View File
@@ -1,129 +1,50 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useLayoutEffect } from 'react'
import { CloudDownload, Upload, Package, Database, RefreshCw, Trash2, Loader2, AlertTriangle, HardDrive, Usb, ChevronDown, Globe, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { QRCodeSVG } from 'qrcode.react'
import { cn, isPS5, isIOS, parsePayloadName } from '../../utils/helpers'
import PayloadName from '../ui/PayloadName'
const PayloadItem = ({ p, multiSources, isPS5, onInstall, srcId, srcUrl }) => {
const { t } = useTranslation()
return (
<div
className={cn(
"flex flex-col md:flex-row justify-between gap-4 md:gap-8 p-6 md:p-8 transition-all",
multiSources
? "hover:bg-white/[0.03]"
: "glass-card rounded-ps-3xl border border-white/10 hover:border-ps-blue/20 bg-white/[0.01]",
isPS5 ? "flex-row items-center" : "items-start md:items-center"
)}
>
<div className="space-y-2 min-w-0">
<PayloadName path={p.filename} version={p.version} className="text-xl md:text-2xl text-white" stacked lastUpdate={p.last_update} />
{p.description && (
<p className="text-sm md:text-base text-zinc-400 font-medium leading-relaxed">{p.description}</p>
)}
</div>
<button
onClick={() => onInstall(p, srcId === 'legacy-repo' ? null : srcId, srcUrl)}
className={cn(
"flex items-center justify-center space-x-3 px-6 md:px-8 py-3 md:py-5 rounded-2xl font-bold text-lg transition-all shrink-0 transform active:scale-95",
isPS5 ? "w-auto px-12" : "w-full md:w-auto",
p.isUpdate
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: "bg-ps-blue hover:bg-ps-blue/80 text-white"
)}
>
<CloudDownload className="w-5 h-5 md:w-6 md:h-6" />
<span>{p.isUpdate ? t("storage_hub.update_btn", "Update") : t("storage_hub.install_btn", "Install")}</span>
</button>
</div>
)
}
const CategoryGroup = ({ category, payloads, multiSources, isPS5, onInstall, srcId, srcUrl, defaultExpanded = false }) => {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<div className="border-b border-white/5 last:border-0">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between p-4 md:p-6 hover:bg-white/5 transition-colors"
>
<span className="font-bold text-white/80 uppercase tracking-widest text-sm">{category} ({payloads.length})</span>
<ChevronDown className={cn("w-5 h-5 text-zinc-500 transition-transform", expanded && "rotate-180")} />
</button>
{expanded && (
<div className={cn(
multiSources ? "divide-y divide-white/5 bg-black/20" : "grid grid-cols-1 gap-4 p-4",
"border-t border-white/5"
)}>
{payloads.map(p => (
<PayloadItem
key={p.filename}
p={p}
multiSources={multiSources}
isPS5={isPS5}
onInstall={onInstall}
srcId={srcId}
srcUrl={srcUrl}
/>
))}
</div>
)}
</div>
)
}
import { useRepository } from '../../contexts/RepositoryContext'
import PayloadCard from '../ui/PayloadCard'
import PayloadDetailPanel from '../ui/PayloadDetailPanel'
const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onImportFromUsb, config, ip, scrollTarget, onClearScrollTarget }) => {
const { t, i18n } = useTranslation()
const multiSources = config?.MULTI_SOURCES_ENABLED === true
const [repoData, setRepoData] = useState(null) // null = not loaded yet
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const { repoData, loading, error, fetchRemote } = useRepository()
const [expandedSource, setExpandedSource] = useState(null) // id of expanded catalog
const [search, setSearch] = useState('')
const [sortMode, setSortMode] = useState(() => {
return localStorage.getItem('repoSortMode') || 'category'
return localStorage.getItem('repoSortMode') || 'name'
})
const [selectedPayload, setSelectedPayload] = useState(null)
const [savedScroll, setSavedScroll] = useState(0)
const handlePayloadSelect = (payloadData) => {
const mainEl = document.querySelector('main')
if (mainEl) setSavedScroll(mainEl.scrollTop)
setSelectedPayload(payloadData)
}
useLayoutEffect(() => {
const mainEl = document.querySelector('main')
if (!mainEl) return
if (selectedPayload) {
mainEl.scrollTop = 0
} else {
mainEl.scrollTop = savedScroll
}
}, [selectedPayload, savedScroll])
const handleSortChange = (mode) => {
setSortMode(mode)
localStorage.setItem('repoSortMode', mode)
}
const fetchRemote = async (force = false) => {
setLoading(true)
setError(false)
try {
const endpoint = force ? '/repository_refresh' : '/repository_payloads'
const res = await fetch(endpoint)
if (!res.ok) throw new Error()
const data = await res.json()
setRepoData(data)
// For multi-source: auto-expand first source with payloads
if (data?.sources?.length > 0 && expandedSource === null) {
const first = data.sources.find(s => s.payloads?.length > 0)
if (first) setExpandedSource(first.id)
}
// Legacy single-source: auto-refresh if older than 24h
if (!force && data?.last_update) {
// eslint-disable-next-line react-hooks/purity
const now = Math.floor(Date.now() / 1000)
if (now - Number(data.last_update) > 24 * 60 * 60) {
await fetchRemote(true)
return
}
}
} catch {
setError(true)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchRemote() }, [])
useEffect(() => {
if (scrollTarget) {
@@ -166,23 +87,23 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
/* ---- Source-grouped data ---- */
const enrichedSources = useMemo(() => {
if (multiSources && repoData?.sources) {
if (repoData?.sources) {
return repoData.sources.map(src => ({
...src,
id: src.id || src.url,
payloads: enrichPayloads(src.payloads || [])
}))
} else if (!multiSources && repoData?.payloads) {
} else if (repoData?.payloads) {
return [{
id: 'legacy-repo',
name: t("storage_hub.default_repository", "Default Repository"),
name: repoData.name || t("storage_hub.default_repository", "Default Repository"),
url: repoData.repo_url || '',
last_update: repoData.last_update || 0,
payloads: enrichPayloads(repoData.payloads)
}]
}
return []
}, [repoData, multiSources, localFilenames, t])
}, [repoData, localFilenames, t])
const legacyRepoUrl = repoData?.repo_url || ''
@@ -193,8 +114,25 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
return meta?.source_name || meta?.install_source_detail || null
}
if (selectedPayload) {
return (
<PayloadDetailPanel
payload={selectedPayload.payload}
srcId={selectedPayload.srcId}
srcUrl={selectedPayload.srcUrl}
isInstalled={selectedPayload.isInstalled}
isUpdate={selectedPayload.isUpdate}
onClose={() => setSelectedPayload(null)}
onInstall={(p, sid, surl) => {
onInstall(p, sid, surl)
setSelectedPayload(null)
}}
/>
)
}
return (
<div className="space-y-12">
<div className="space-y-12 animate-in fade-in duration-300">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-8">
<h2 className="text-4xl font-extrabold text-white tracking-tight">
{t("storage_hub.title_1", "Payload")} <span className="text-ps-blue">{t("storage_hub.title_2", "Management")}</span>
@@ -209,79 +147,6 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
)}
</div>
{/* Installed Payloads Section */}
<section className="space-y-6">
<div className="flex items-center justify-between px-2">
<h3 className="label-caps !text-white flex items-center space-x-4 text-lg">
<Database className="w-6 h-6 text-ps-blue" />
<span>{t("storage_hub.installed_title", "Installed Payloads")}</span>
</h3>
<span className="bg-white/5 px-4 py-1 rounded-full text-zinc-500 font-bold text-xs">
{t("storage_hub.files_count", "{{count}} File", { count: internalPayloads.length })}
</span>
</div>
<div className={cn("grid gap-4", isPS5 ? "grid-cols-2" : "grid-cols-1 lg:grid-cols-2")}>
{internalPayloads.length === 0 ? (
<div className="col-span-full py-20 border-2 border-dashed border-white/5 rounded-ps-3xl flex flex-col items-center justify-center space-y-4 bg-white/[0.01]">
<Package className="w-16 h-16 text-white/5" />
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">{t("storage_hub.library_empty", "Library Empty")}</p>
</div>
) : (
internalPayloads.map((path) => {
const fileName = path.split('/').pop()
const sourceBadge = getSourceBadge(fileName)
// Find update in all sources (multi or legacy)
const allRemote = enrichedSources.flatMap(s => s.payloads)
const remoteMatch = allRemote.find(rp => rp.filename === fileName || rp.installedFilename === fileName)
const remoteVersion = remoteMatch?.version || (remoteMatch?.filename ? parsePayloadName(remoteMatch.filename).version : null)
return (
<div key={path} className="group flex flex-col justify-center p-4 md:p-6 glass-card rounded-ps-2xl border-white/10 hover:border-ps-blue/30 gap-3 md:gap-4 relative overflow-hidden">
<div className="flex flex-row items-center justify-between w-full gap-4">
<div className="flex items-center space-x-4 md:space-x-6 min-w-0 flex-1">
<div className="p-3 md:p-4 bg-white/5 rounded-2xl group-hover:bg-ps-blue/10 transition-colors shrink-0">
<Package className="w-6 h-6 md:w-8 md:h-8 text-zinc-400 group-hover:text-ps-blue transition-colors" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<PayloadName path={fileName} className="text-xl md:text-2xl text-white" stacked />
{sourceBadge && (
<div className="flex items-center gap-1 text-zinc-500 text-[11px] select-none font-medium">
<Globe className="w-3.5 h-3.5" />
<span>{sourceBadge}</span>
</div>
)}
</div>
</div>
<div className="flex items-center shrink-0">
<button
onClick={() => onDelete(fileName)}
className="p-3 md:p-4 rounded-xl bg-red-950/20 text-red-500 border border-red-500/10 hover:bg-red-500 hover:text-white transition-all"
title={t("storage_hub.remove_payload", "Remove Payload")}
>
<Trash2 className="w-5 h-5 md:w-6 md:h-6" />
</button>
</div>
</div>
{remoteMatch?.isUpdate && (
<button
onClick={() => onInstall(remoteMatch, remoteMatch.source_id, legacyRepoUrl)}
className="w-full flex items-center justify-center space-x-2 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold text-xs md:text-sm transition-all"
>
<RefreshCw className="w-4 h-4 md:w-5 md:h-5" />
<span>
{remoteVersion
? t("storage_hub.update_to_version", "Update (to v{{version}})", { version: remoteVersion.replace(/^v/i, '') })
: t("storage_hub.update_btn", "Update")}
</span>
</button>
)}
</div>
)
})
)}
</div>
</section>
{/* Cloud Repository Section */}
<section id="cloud-repository" className="space-y-6">
<div className="flex items-center justify-between px-2">
@@ -439,10 +304,10 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
</button>
)}
{/* Catalog payload list */}
{/* Catalog payload grid */}
{isExpanded && (
<div className={cn(
multiSources ? "border-t border-white/5 divide-y divide-white/5" : "grid grid-cols-1 gap-4"
multiSources ? "border-t border-white/5 p-4 md:p-6" : ""
)}>
{src.payloads.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
@@ -454,30 +319,45 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
</div>
) : hasMultipleCategories ? (
categories.map(cat => (
<CategoryGroup
key={cat}
category={cat}
payloads={groupedPayloads[cat]}
multiSources={multiSources}
isPS5={isPS5}
onInstall={onInstall}
srcId={src.id}
srcUrl={src.url}
defaultExpanded={availablePayloads.length <= 10}
/>
<div key={cat} className="space-y-4 mb-8 last:mb-0">
<h4 className="text-white/80 font-bold uppercase tracking-widest text-sm px-2">{cat}</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{groupedPayloads[cat].map(p => (
<PayloadCard
key={p.filename}
payload={p}
isUpdate={p.isUpdate}
isInstalled={p.isInstalled}
onClick={() => handlePayloadSelect({
payload: p,
srcId: src.id === 'legacy-repo' ? null : src.id,
srcUrl: src.url,
isInstalled: p.isInstalled,
isUpdate: p.isUpdate
})}
/>
))}
</div>
</div>
))
) : (
availablePayloads.map(p => (
<PayloadItem
key={p.filename}
p={p}
multiSources={multiSources}
isPS5={isPS5}
onInstall={onInstall}
srcId={src.id}
srcUrl={src.url}
/>
))
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{availablePayloads.map(p => (
<PayloadCard
key={p.filename}
payload={p}
isUpdate={p.isUpdate}
isInstalled={p.isInstalled}
onClick={() => handlePayloadSelect({
payload: p,
srcId: src.id === 'legacy-repo' ? null : src.id,
srcUrl: src.url,
isInstalled: p.isInstalled,
isUpdate: p.isUpdate
})}
/>
))}
</div>
)}
</div>
)}
@@ -578,6 +458,7 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const RepositoryContext = createContext();
export const useRepository = () => useContext(RepositoryContext);
export const RepositoryProvider = ({ children, config }) => {
const [repoData, setRepoData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
// Safely check if config exists before checking MULTI_SOURCES_ENABLED
const multiSources = config?.MULTI_SOURCES_ENABLED === true;
const fetchRemote = useCallback(async (force = false) => {
setLoading(true);
setError(false);
try {
const endpoint = force ? '/repository_refresh' : '/repository_payloads';
const res = await fetch(endpoint);
if (!res.ok) throw new Error();
const data = await res.json();
setRepoData(data);
} catch (err) {
console.error(err);
setError(true);
setRepoData([]);
} finally {
setLoading(false);
}
}, [multiSources]);
// Fetch only once when config is loaded
useEffect(() => {
if (config && Object.keys(config).length > 0) {
if (repoData === null) {
fetchRemote();
}
}
}, [config, fetchRemote, repoData]);
return (
<RepositoryContext.Provider value={{ repoData, loading, error, fetchRemote }}>
{children}
</RepositoryContext.Provider>
);
};
+12
View File
@@ -241,3 +241,15 @@ h1, h2, h3, h4 {
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
@keyframes marquee-alternate {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(calc(-1 * var(--scroll-amount, 0px))); }
}
button:hover .animate-marquee,
button:focus .animate-marquee,
.group:hover .animate-marquee,
.group:focus-within .animate-marquee {
animation: marquee-alternate 4s ease-in-out infinite alternate;
}
+2 -1
View File
@@ -13,6 +13,7 @@ typedef struct PldmgrConfig {
int scan_usb_payloads; /* Default: 0 (off) */
int auto_install_app; /* Default: 1 (on) */
int multi_sources_enabled; /* Default: 0 (off) */
int show_update_notifications; /* Default: 1 (on) */
} PldmgrConfig;
/* Read all config values from PLDMGR_CONFIG_PATH.
@@ -30,7 +31,7 @@ int config_read_bool(const char *key, int default_val);
/* Apply a JSON config update sent from the frontend /set_config route.
* Handles AUTOLOAD_ENABLED, AUTO_BROWSER_OPEN, AUTOLOAD_DELAY,
* KILL_DISC_PLAYER_ON_STARTUP, SCAN_USB_PAYLOADS, AUTO_INSTALL_APP,
* MULTI_SOURCES_ENABLED, and AUTOLOAD_LIST.
* MULTI_SOURCES_ENABLED, SHOW_UPDATE_NOTIFICATIONS, and AUTOLOAD_LIST.
* Returns 0 on success, -1 on error. */
int config_handle_set_json(const char *json_data);
+1
View File
@@ -26,6 +26,7 @@
#define ROUTE_REPO_INSTALL "/repository_install"
#define ROUTE_REPO_PUSH "/repository_push"
#define ROUTE_REPO_INSTALL_PUSH "/repository_install_push"
#define ROUTE_UPDATE_CHECK "/update_check"
#define ROUTE_SOURCES_LIST "/sources_list"
#define ROUTE_SOURCES_SET "/sources_set"
#define ROUTE_SOURCES_ADD "/sources_add"
+8
View File
@@ -16,6 +16,10 @@ typedef struct RepoPayload {
char checksum[65];
char source_name[256];
char category[128];
char icon[16384];
char release_notes[2048];
char author[128];
char details_url[1024];
} RepoPayload;
/* Download a URL to a local file. Returns 0 on success, -1 on failure. */
@@ -67,4 +71,8 @@ int get_elf_pldmgr_version(const char *path, char *out_version, size_t out_size)
* If found, writes the path to out_path and returns 0. */
int repository_check_self_update(char *out_path, size_t out_size);
/* Check if a newer version of the payload manager is available in the repo cache.
* Returns 0 if an update is available, -1 otherwise. */
int repository_check_repo_update(void);
#endif
+5
View File
@@ -22,6 +22,7 @@ void config_read(PldmgrConfig *cfg) {
cfg->scan_usb_payloads = 0;
cfg->auto_install_app = 1;
cfg->multi_sources_enabled = 0;
cfg->show_update_notifications = 1;
f = fopen(PLDMGR_CONFIG_PATH, "r");
if (!f)
@@ -44,6 +45,8 @@ void config_read(PldmgrConfig *cfg) {
cfg->auto_install_app = atoi(line + 17);
else if (strncmp(line, "MULTI_SOURCES_ENABLED=", 22) == 0)
cfg->multi_sources_enabled = atoi(line + 22);
else if (strncmp(line, "SHOW_UPDATE_NOTIFICATIONS=", 26) == 0)
cfg->show_update_notifications = atoi(line + 26);
}
fclose(f);
}
@@ -64,6 +67,7 @@ int config_write(const PldmgrConfig *cfg) {
fprintf(f, "SCAN_USB_PAYLOADS=%d\n", cfg->scan_usb_payloads ? 1 : 0);
fprintf(f, "AUTO_INSTALL_APP=%d\n", cfg->auto_install_app ? 1 : 0);
fprintf(f, "MULTI_SOURCES_ENABLED=%d\n", cfg->multi_sources_enabled ? 1 : 0);
fprintf(f, "SHOW_UPDATE_NOTIFICATIONS=%d\n", cfg->show_update_notifications ? 1 : 0);
fclose(f);
return 0;
}
@@ -204,6 +208,7 @@ int config_handle_set_json(const char *json_data) {
cfg.scan_usb_payloads = extract_json_bool(json_data, "SCAN_USB_PAYLOADS", cfg.scan_usb_payloads);
cfg.auto_install_app = extract_json_bool(json_data, "AUTO_INSTALL_APP", cfg.auto_install_app);
cfg.multi_sources_enabled = extract_json_bool(json_data, "MULTI_SOURCES_ENABLED", cfg.multi_sources_enabled);
cfg.show_update_notifications = extract_json_bool(json_data, "SHOW_UPDATE_NOTIFICATIONS", cfg.show_update_notifications);
if (config_write(&cfg) == 0) {
pldmgr_log("[PLDMGR] Saved config to %s\n", PLDMGR_CONFIG_PATH);
+31 -3
View File
@@ -29,7 +29,7 @@
#include "assets_favicon_svg.h"
#include "assets_icon_png.h"
#define RESPONSE_BUFFER_SIZE 1048576
#define RESPONSE_BUFFER_SIZE 4194304
#define CORS_ORIGIN "*"
/* Shared flag — set from main() shutdown route, read by main loop */
@@ -576,6 +576,33 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn,
MHD_add_response_header(resp, "Cache-Control", "max-age=604800");
/* ── API routes ────────────────────────────────────────── */
} else if (strcmp(url, ROUTE_UPDATE_CHECK) == 0) {
char *resp_buf;
struct MHD_Response *oom_resp = alloc_response_buffer(&resp_buf);
if (oom_resp)
return MHD_queue_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, oom_resp);
char update_path[512] = {0};
PldmgrConfig cfg;
config_read(&cfg);
if (!cfg.show_update_notifications) {
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"none\"}");
} else if (repository_check_self_update(update_path, sizeof(update_path)) == 0) {
char escaped_path[1024];
pldmgr_json_escape(update_path, escaped_path, sizeof(escaped_path));
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"ready-to-apply\",\"path\":\"%s\"}", escaped_path);
} else if (repository_check_repo_update() == 0) {
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"update-available\"}");
} else {
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"none\"}");
}
resp = MHD_create_response_from_buffer(strlen(resp_buf), (void *)resp_buf, MHD_RESPMEM_MUST_FREE);
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, MHD_HTTP_OK, resp);
} else if (strcmp(url, ROUTE_CHECK) == 0) {
const char *filename = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename");
if (!filename) {
@@ -959,12 +986,13 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn,
"REPOSITORY_UPDATE\":%ld,"
"\"AUTO_BROWSER_OPEN\":%s,\"AUTOLOAD_DELAY\":%d,\"KILL_DISC_"
"PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s,"
"\"MULTI_SOURCES_ENABLED\":%s}",
"\"MULTI_SOURCES_ENABLED\":%s,\"SHOW_UPDATE_NOTIFICATIONS\":%s}",
cfg.autoload_enabled ? "true" : "false", list_escaped, cfg.last_repository_update,
cfg.auto_browser_open ? "true" : "false", cfg.autoload_delay,
cfg.kill_disc_player ? "true" : "false", cfg.scan_usb_payloads ? "true" : "false",
cfg.auto_install_app ? "true" : "false",
cfg.multi_sources_enabled ? "true" : "false");
cfg.multi_sources_enabled ? "true" : "false",
cfg.show_update_notifications ? "true" : "false");
resp = MHD_create_response_from_buffer(strlen(resp_buf),
(void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
+8 -2
View File
@@ -242,6 +242,7 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
char d_checksum[65] = "", d_downloaded[64] = "";
char d_install_src[128] = "", d_install_detail[1024] = "";
char d_source_name[256] = "";
char d_author[128] = "", d_details_url[1024] = "";
const char *end = details_json + details_size;
json_extract_string(details_json, end, "name", d_name, sizeof(d_name));
@@ -256,9 +257,12 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
json_extract_string(details_json, end, "install_source", d_install_src, sizeof(d_install_src));
json_extract_string(details_json, end, "install_source_detail", d_install_detail, sizeof(d_install_detail));
json_extract_string(details_json, end, "source_name", d_source_name, sizeof(d_source_name));
json_extract_string(details_json, end, "author", d_author, sizeof(d_author));
json_extract_string(details_json, end, "details_url", d_details_url, sizeof(d_details_url));
char filename_escaped[512], ne[512], de[2048], ve[128], ue[2048], se[2048], sde[2048];
char lue[128], ce[128], dae[128], ise[256], ide[2048], sne[512];
char ae[256], due[2048];
pldmgr_json_escape(payloads[i].filename, filename_escaped, sizeof(filename_escaped));
pldmgr_json_escape(d_name, ne, sizeof(ne));
pldmgr_json_escape(d_desc, de, sizeof(de));
@@ -272,16 +276,18 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
pldmgr_json_escape(d_install_src, ise, sizeof(ise));
pldmgr_json_escape(d_install_detail, ide, sizeof(ide));
pldmgr_json_escape(d_source_name, sne, sizeof(sne));
pldmgr_json_escape(d_author, ae, sizeof(ae));
pldmgr_json_escape(d_details_url, due, sizeof(due));
json_append(&jb, "%s\"%s\":{"
"\"display_name\":\"%s\",\"description\":\"%s\",\"version\":\"%s\","
"\"url\":\"%s\",\"source\":\"%s\",\"source_direct\":\"%s\","
"\"last_update\":\"%s\",\"checksum\":\"%s\",\"downloaded_at\":\"%s\","
"\"install_source\":\"%s\",\"install_source_detail\":\"%s\","
"\"source_name\":\"%s\"}",
"\"source_name\":\"%s\",\"author\":\"%s\",\"details_url\":\"%s\"}",
meta_first ? "" : ",",
filename_escaped,
ne, de, ve, ue, se, sde, lue, ce, dae, ise, ide, sne);
ne, de, ve, ue, se, sde, lue, ce, dae, ise, ide, sne, ae, due);
meta_first = 0;
free(details_json);
+82 -7
View File
@@ -120,12 +120,38 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
*out_count = 0;
while ((p = strchr(p, '{')) != NULL) {
const char *end = strchr(p, '}');
const char *end = p + 1;
int depth = 1;
int in_string = 0;
int escaped = 0;
while (*end && depth > 0) {
if (in_string) {
if (escaped) {
escaped = 0;
} else if (*end == '\\') {
escaped = 1;
} else if (*end == '"') {
in_string = 0;
}
} else {
if (*end == '"') {
in_string = 1;
} else if (*end == '{') {
depth++;
} else if (*end == '}') {
depth--;
}
}
if (depth > 0) end++;
}
if (!*end && depth > 0)
break; /* Malformed JSON, no matching brace */
RepoPayload item;
RepoPayload *tmp;
if (!end)
break;
memset(&item, 0, sizeof(item));
if (json_extract_string(p, end, "name", item.name, sizeof(item.name)) != 0 ||
json_extract_string(p, end, "filename", item.filename, sizeof(item.filename)) != 0 ||
@@ -141,6 +167,19 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
json_extract_string(p, end, "version", item.version, sizeof(item.version));
json_extract_string(p, end, "checksum", item.checksum, sizeof(item.checksum));
json_extract_string(p, end, "author", item.author, sizeof(item.author));
json_extract_string(p, end, "details_url", item.details_url, sizeof(item.details_url));
json_extract_string(p, end, "release_notes", item.release_notes, sizeof(item.release_notes));
if (strlen(item.release_notes) == sizeof(item.release_notes) - 1) {
strcpy(item.release_notes + sizeof(item.release_notes) - 4, "...");
}
json_extract_string(p, end, "icon", item.icon, sizeof(item.icon));
if (strlen(item.icon) == sizeof(item.icon) - 1) {
item.icon[0] = '\0'; /* Drop if > 16KB */
}
if (json_extract_string(p, end, "category", item.category, sizeof(item.category)) != 0 || strlen(item.category) == 0) {
strncpy(item.category, "Uncategorized", sizeof(item.category) - 1);
}
@@ -184,7 +223,8 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
char name[256], filename[384], url[1400], source[1400], source_direct[1400];
char description[1400], last_update[128], version[128], checksum[128], category[256];
char downloaded_at[64], i_src[256], i_detail[1400], i_src_name[256];
char json_buf[8192];
char icon[16384], release_notes[4096], author[256], details_url[1400];
char json_buf[40960];
time_t now = time(NULL);
struct tm tmv;
@@ -205,6 +245,10 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
pldmgr_json_escape(install_source ? install_source : "unknown", i_src, sizeof(i_src));
pldmgr_json_escape(install_source_detail ? install_source_detail : "", i_detail, sizeof(i_detail));
pldmgr_json_escape(item->source_name[0] ? item->source_name : "", i_src_name, sizeof(i_src_name));
pldmgr_json_escape(item->icon, icon, sizeof(icon));
pldmgr_json_escape(item->release_notes, release_notes, sizeof(release_notes));
pldmgr_json_escape(item->author, author, sizeof(author));
pldmgr_json_escape(item->details_url, details_url, sizeof(details_url));
snprintf(json_buf, sizeof(json_buf),
"{\n"
@@ -221,11 +265,15 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
" \"downloaded_at\": \"%s\",\n"
" \"install_source\": \"%s\",\n"
" \"install_source_detail\": \"%s\",\n"
" \"source_name\": \"%s\"\n"
" \"source_name\": \"%s\",\n"
" \"icon\": \"%s\",\n"
" \"release_notes\": \"%s\",\n"
" \"author\": \"%s\",\n"
" \"details_url\": \"%s\"\n"
"}\n",
name, filename, url, source, source_direct, description,
last_update, version, checksum, category, downloaded_at, i_src, i_detail,
i_src_name);
i_src_name, icon, release_notes, author, details_url);
FILE *f = fopen(details_path, "w");
if (!f) return -1;
@@ -627,6 +675,10 @@ static int compare_versions(const char *v1, const char *v2) {
const char *p1 = v1;
const char *p2 = v2;
/* Skip leading non-numeric prefixes (e.g., 'v') */
while (*p1 && (*p1 < '0' || *p1 > '9')) p1++;
while (*p2 && (*p2 < '0' || *p2 > '9')) p2++;
int maj1 = parse_version_part(&p1);
int maj2 = parse_version_part(&p2);
if (maj1 != maj2) return maj1 - maj2;
@@ -702,3 +754,26 @@ int repository_check_self_update(char *out_path, size_t out_size) {
closedir(dir);
return -1;
}
int repository_check_repo_update(void) {
RepoPayload *items = NULL;
size_t count = 0;
if (load_cached_repository(&items, &count) != 0) {
return -1;
}
int found = 0;
for (size_t i = 0; i < count; i++) {
if (strcasestr(items[i].filename, "pldmgr") || strcasestr(items[i].filename, "payload-manager")) {
if (items[i].version[0] != '\0') {
if (compare_versions(items[i].version, MENU_VERSION) > 0) {
found = 1;
break;
}
}
}
}
free(items);
return found ? 0 : -1;
}
+8 -2
View File
@@ -472,6 +472,7 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
char name_pe[256], filename_e[512], desc_e[2048];
char ver_e[128], url_e[2048], src_id_e[128], src_name_e[512];
char last_upd_e[128], cat_e[256];
char auth_e[256], det_url_e[2048], rn_e[4096], icon_e[16384];
pldmgr_json_escape(items[pi].name, name_pe, sizeof(name_pe));
pldmgr_json_escape(items[pi].filename, filename_e, sizeof(filename_e));
@@ -482,12 +483,17 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
pldmgr_json_escape(items[pi].category, cat_e, sizeof(cat_e));
pldmgr_json_escape(sources[si].id, src_id_e, sizeof(src_id_e));
pldmgr_json_escape(sources[si].name, src_name_e, sizeof(src_name_e));
pldmgr_json_escape(items[pi].author, auth_e, sizeof(auth_e));
pldmgr_json_escape(items[pi].details_url, det_url_e, sizeof(det_url_e));
pldmgr_json_escape(items[pi].release_notes,rn_e, sizeof(rn_e));
pldmgr_json_escape(items[pi].icon, icon_e, sizeof(icon_e));
if (json_append(&jb, " {\"name\":\"%s\",\"filename\":\"%s\",\"description\":\"%s\","
"\"version\":\"%s\",\"last_update\":\"%s\",\"url\":\"%s\","
"\"source_id\":\"%s\",\"source_name\":\"%s\",\"category\":\"%s\"}%s\n",
"\"source_id\":\"%s\",\"source_name\":\"%s\",\"category\":\"%s\","
"\"author\":\"%s\",\"details_url\":\"%s\",\"release_notes\":\"%s\",\"icon\":\"%s\"}%s\n",
name_pe, filename_e, desc_e, ver_e, last_upd_e, url_e,
src_id_e, src_name_e, cat_e,
src_id_e, src_name_e, cat_e, auth_e, det_url_e, rn_e, icon_e,
(pi < count - 1) ? "," : "") != 0) {
break;
}