Skip to content

Commit f4c76fb

Browse files
committed
feat: add multi-source payload repository support
- Add MULTI_SOURCES_ENABLED config flag with toggle in Settings - New "Manage Sources" view: add/remove/reorder third-party payload sources via URL; default source is locked and non-removable - Source JSON format: top-level `name` field sets display name; falls back to hostname extraction if absent; checksums are optional - PS5 mode shows QR code + URL for Manage Sources; desktop shows full list UI - Source removal uses shared confirm modal; reorder auto-saves with toast - Backend (payload_mgr.c): - CRUD for sources list persisted to /data/pldmgr/sources.json - Multi-source repository list/install routes; each source has its own independent payload catalog - Bounded name parsing: only searches before "payloads" key to avoid capturing payload item names; domain fallback when name absent - list_payloads response now includes `meta` map: walks PAYLOADS_STORAGE_DIR subdirectories for .elf/.bin/.lua sidecar files and emits source_name per payload - Fallback for pre-feature sidecars: extracts hostname from install_source_detail, skipping the official repository URL - Frontend: source badge (Globe icon + name) shown in bottom-right corner of each payload card on Dashboard and Management tab when MULTI_SOURCES_ENABLED is on and payload has a non-default source - StorageHub: accordion-style per-source catalogs when multi-source enabled - showConfirm helper in App.jsx auto-closes modal before executing callback
1 parent 72e7818 commit f4c76fb

11 files changed

Lines changed: 1643 additions & 117 deletions

File tree

frontend/mock-server.js

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,31 @@ const remoteRepository = [
3737
checksum: "54df47a48d9c5ee4338ef70ba66093908a4f2845e53468bdd7c080b65d7488c1"
3838
}
3939
];
40+
41+
const communityRepository = [
42+
{
43+
name: "PS5 Hen Test",
44+
filename: "ps5_hen_test_v1.0.elf",
45+
url: "https://example.com/payloads/ps5_hen_test_v1.0.elf",
46+
description: "Community hen test payload — no checksum",
47+
version: "v1.0"
48+
},
49+
{
50+
name: "Debug Tool",
51+
filename: "debug_tool_v2.1.elf",
52+
url: "https://example.com/payloads/debug_tool_v2.1.elf",
53+
description: "Debug utility from community repo",
54+
version: "v2.1"
55+
}
56+
];
57+
4058
let lastRepositoryUpdate = Math.floor(Date.now() / 1000);
4159

60+
let mockSources = [
61+
{ id: 'default', name: 'Official Repository', url: 'https://itsplk.github.io/ps5-payloads-mirror/payloads.json', removable: false },
62+
{ id: 'source_community', name: 'Community Payloads', url: 'https://example.com/community-payloads.json', removable: true }
63+
];
64+
4265
let autoloadStatus = {
4366
remaining: 10,
4467
total: 2,
@@ -90,7 +113,15 @@ app.get('/list_payloads', (req, res) => {
90113
"/data/pldmgr/etaHEN_1.8.elf",
91114
"/data/pldmgr/kstuff.elf",
92115
"/mnt/usb0/pldmgr/linux_loader.elf"
93-
]
116+
],
117+
meta: {
118+
// payloads with no entry here = official / no badge shown
119+
"kstuff.elf": {
120+
source_name: "Community Payloads",
121+
install_source: "repository",
122+
install_source_detail: "https://example.com/community-payloads.json"
123+
}
124+
}
94125
});
95126
});
96127

@@ -103,28 +134,90 @@ app.get('/get_config', (req, res) => {
103134
AUTOLOAD_ENABLED: true,
104135
AUTOLOAD_LIST: "goldhen_v2.4b17.elf,etaHEN_1.8.elf",
105136
LAST_REPOSITORY_UPDATE: lastRepositoryUpdate,
106-
AUTO_INSTALL_APP: true
137+
AUTO_INSTALL_APP: true,
138+
MULTI_SOURCES_ENABLED: true
107139
});
108140
});
109141

110142
app.get('/repository_payloads', (req, res) => {
143+
// Return source-grouped format (multi-source mode always on in mock)
111144
res.json({
112-
payloads: remoteRepository,
113-
last_update: lastRepositoryUpdate,
114-
cache_status: 'ok'
145+
sources: [
146+
{
147+
id: 'default',
148+
name: 'Official Repository',
149+
last_update: lastRepositoryUpdate,
150+
error: false,
151+
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
152+
},
153+
{
154+
id: 'source_community',
155+
name: 'Community Payloads',
156+
last_update: lastRepositoryUpdate,
157+
error: false,
158+
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
159+
}
160+
]
115161
});
116162
});
117163

118164
app.get('/repository_refresh', (req, res) => {
119165
lastRepositoryUpdate = Math.floor(Date.now() / 1000);
120166
logs.push(`[PLDMGR] Repository manually refreshed`);
121167
res.json({
122-
payloads: remoteRepository,
123-
last_update: lastRepositoryUpdate,
124-
cache_status: 'ok'
168+
sources: [
169+
{
170+
id: 'default',
171+
name: 'Official Repository',
172+
last_update: lastRepositoryUpdate,
173+
error: false,
174+
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
175+
},
176+
{
177+
id: 'source_community',
178+
name: 'Community Payloads',
179+
last_update: lastRepositoryUpdate,
180+
error: false,
181+
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
182+
}
183+
]
125184
});
126185
});
127186

187+
app.get('/sources_list', (req, res) => {
188+
res.json({ sources: mockSources });
189+
});
190+
191+
app.post('/sources_set', (req, res) => {
192+
const { sources } = req.body;
193+
if (Array.isArray(sources)) {
194+
mockSources = sources;
195+
logs.push(`[PLDMGR] Sources updated: ${sources.length} sources`);
196+
}
197+
res.send('OK');
198+
});
199+
200+
app.get('/sources_add', (req, res) => {
201+
const { url } = req.query;
202+
if (!url) return res.status(400).json({ ok: false, message: 'Missing url' });
203+
// Mock: always succeed with a fake source name derived from the URL
204+
const fakeName = `Community Source (${new URL(url).hostname})`;
205+
const newSource = { id: `source_${Date.now()}`, name: fakeName, url, removable: true };
206+
mockSources.push(newSource);
207+
logs.push(`[PLDMGR] Source added: ${fakeName}`);
208+
res.json({ ok: true, name: fakeName });
209+
});
210+
211+
app.get('/sources_remove', (req, res) => {
212+
const idx = parseInt(req.query.index);
213+
if (isNaN(idx) || idx <= 0 || idx >= mockSources.length) {
214+
return res.status(400).json({ ok: false, message: 'Invalid index or cannot remove default source' });
215+
}
216+
const removed = mockSources.splice(idx, 1);
217+
logs.push(`[PLDMGR] Source removed: ${removed[0]?.name}`);
218+
res.json({ ok: true, message: 'OK' });
219+
});
220+
128221
app.get('/repository_install', (req, res) => {
129222
const { filename } = req.query;
130223
if (!filename) {

frontend/src/App.jsx

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import DonateView from './components/views/DonateView'
3333
import AutoloadOverlay from './components/views/AutoloadOverlay'
3434
import MoveFromUsbView from './components/views/MoveFromUsbView'
3535
import LogViewer from './components/views/LogViewer'
36+
import ManageSourcesView from './components/views/ManageSourcesView'
3637

3738
function App() {
3839
const [view, setView] = useState('dashboard')
@@ -68,6 +69,8 @@ function App() {
6869
const [moveFromUsbPath, setMoveFromUsbPath] = useState(null)
6970
const [storageScrollTarget, setStorageScrollTarget] = useState(null)
7071
const [showLogs, setShowLogs] = useState(false)
72+
// Map filename -> metadata (source_name, etc.)
73+
const [payloadMeta, setPayloadMeta] = useState({})
7174

7275
useEffect(() => {
7376
if (!showLogs) return
@@ -82,6 +85,18 @@ function App() {
8285

8386

8487

88+
const showConfirm = (title, message, onConfirm) => {
89+
setConfirmModal({
90+
show: true,
91+
title,
92+
message,
93+
onConfirm: () => {
94+
setConfirmModal({ show: false })
95+
onConfirm()
96+
}
97+
})
98+
}
99+
85100
const addToast = (message, type = 'success') => {
86101
const id = Date.now()
87102
setToasts(prev => [...prev, { id, message, type }])
@@ -107,7 +122,6 @@ function App() {
107122
setLoadingPayloads(true)
108123
const data = await api('/list_payloads')
109124
if (data?.payloads) {
110-
// Sort payloads: internal first, USB last
111125
const sorted = [...data.payloads].sort((a, b) => {
112126
const aIsUsb = a.startsWith('/mnt/usb')
113127
const bIsUsb = b.startsWith('/mnt/usb')
@@ -116,6 +130,10 @@ function App() {
116130
return a.localeCompare(b)
117131
})
118132
setPayloads(sorted)
133+
// Build metadata map: filename -> meta object
134+
if (data.meta && typeof data.meta === 'object') {
135+
setPayloadMeta(data.meta)
136+
}
119137
setLoadingPayloads(false)
120138
} else if (retryCount < 5) {
121139
setTimeout(() => refreshPayloads(retryCount + 1), 1000)
@@ -158,12 +176,10 @@ function App() {
158176
}
159177

160178
const handleDelete = (fileName) => {
161-
setConfirmModal({
162-
show: true,
163-
title: "Delete Payload",
164-
message: `Are you sure you want to remove ${fileName}?`,
165-
onConfirm: async () => {
166-
setConfirmModal({ show: false })
179+
showConfirm(
180+
"Delete Payload",
181+
`Are you sure you want to remove ${fileName}?`,
182+
async () => {
167183
const res = await fetch(`/manage:delete?filename=${encodeURIComponent(fileName)}`)
168184
if (!res.ok) {
169185
addToast(`Delete failed (${res.status})`, 'error')
@@ -172,7 +188,7 @@ function App() {
172188
refreshPayloads()
173189
addToast(`${fileName} removed`)
174190
}
175-
})
191+
)
176192
}
177193

178194
const handleUpload = async (e) => {
@@ -218,27 +234,28 @@ function App() {
218234
setTimeout(() => setDownloadModal({ show: false }), 800)
219235
}
220236

221-
const handleInstall = async (p, repoUrl) => {
237+
const handleInstall = async (p, sourceId, repoUrl) => {
222238
if (p.isUpdate || p.isInstalled) {
223239
setConfirmModal({
224240
show: true,
225241
title: p.isUpdate ? "Update Payload" : "Reinstall Payload",
226242
message: `A version of ${p.name || p.filename} is already installed. Do you want to replace it with the repository version?`,
227-
onConfirm: () => performInstall(p, repoUrl)
243+
onConfirm: () => performInstall(p, sourceId, repoUrl)
228244
})
229245
} else {
230-
performInstall(p, repoUrl)
246+
performInstall(p, sourceId, repoUrl)
231247
}
232248
}
233249

234-
const performInstall = async (p, repoUrl) => {
250+
const performInstall = async (p, sourceId, repoUrl) => {
235251
setConfirmModal({ show: false })
236252
setDownloadModal({ show: true, name: p.filename, progress: 10 })
237253
try {
238254
setDownloadModal(prev => ({ ...prev, progress: 30 }))
239-
const res = await fetch(
240-
`/repository_install?filename=${encodeURIComponent(p.filename)}&repo_url=${encodeURIComponent(repoUrl || '')}`
241-
)
255+
let url = `/repository_install?filename=${encodeURIComponent(p.filename)}`
256+
if (sourceId) url += `&source_id=${encodeURIComponent(sourceId)}`
257+
if (repoUrl) url += `&repo_url=${encodeURIComponent(repoUrl)}`
258+
const res = await fetch(url)
242259
setDownloadModal(prev => ({ ...prev, progress: 80 }))
243260

244261
const data = await res.json().catch(() => null)
@@ -496,12 +513,13 @@ function App() {
496513
<button onClick={() => { setStorageScrollTarget('cloud-repository'); setView('storage'); }} className="px-8 py-3 bg-ps-blue text-white rounded-xl font-bold tracking-tight">Open Repository</button>
497514
</div>
498515
) : (
499-
payloads.filter(p => !isSystemPayload(p)).map((p, i) => (
516+
payloads.filter(p => !isSystemPayload(p)).map((p) => (
500517
<PayloadButton
501518
key={p}
502519
path={p}
503520
onClick={() => loadPayload(p)}
504521
isLoading={loading && activeLoadingName === p.split('/').pop().replace(/\.(elf|bin|lua)$/i, '').replace(/_/g, ' ')}
522+
sourceName={config.MULTI_SOURCES_ENABLED ? (payloadMeta[p.split('/').pop()]?.source_name || null) : null}
505523
/>
506524
))
507525
)}
@@ -512,6 +530,7 @@ function App() {
512530
{view === 'storage' && (
513531
<StorageHub
514532
payloads={payloads}
533+
payloadMeta={payloadMeta}
515534
onInstall={handleInstall}
516535
onDelete={handleDelete}
517536
onUpload={handleUpload}
@@ -558,8 +577,20 @@ function App() {
558577
setLogs={setLogs}
559578
showLogs={showLogs}
560579
setShowLogs={setShowLogs}
580+
onNavigate={(v) => setView(v)}
581+
/>
582+
)}
583+
584+
{view === 'sources' && (
585+
<ManageSourcesView
586+
onBack={() => setView('settings')}
587+
ip={ip}
588+
addToast={addToast}
589+
showConfirm={showConfirm}
561590
/>
562-
)}{view === 'donate' && <DonateView />}
591+
)}
592+
593+
{view === 'donate' && <DonateView />}
563594
</main>
564595
</div>
565596

frontend/src/components/ui/PayloadButton.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from 'react'
2-
import { Loader2 } from 'lucide-react'
2+
import { Loader2, Globe } from 'lucide-react'
33
import PayloadName from './PayloadName'
44

5-
const PayloadButton = ({ path, onClick, isLoading }) => {
5+
const PayloadButton = ({ path, onClick, isLoading, sourceName }) => {
66
return (
77
<button
88
onClick={onClick}
@@ -18,6 +18,14 @@ const PayloadButton = ({ path, onClick, isLoading }) => {
1818
{path}
1919
</div>
2020
)}
21+
{sourceName && !path.startsWith('/mnt/usb') && (
22+
<div className="absolute bottom-2 right-3 flex items-center gap-1 z-10 pointer-events-none">
23+
<Globe className="w-3 h-3 text-zinc-500 shrink-0" />
24+
<span className="text-[11px] text-zinc-400 font-medium truncate max-w-[120px] select-none">
25+
{sourceName}
26+
</span>
27+
</div>
28+
)}
2129
{/* Glow effect */}
2230
<div className="absolute inset-0 bg-ps-blue/0 group-hover:bg-ps-blue/5 transition-colors z-0 pointer-events-none" />
2331
</button>

0 commit comments

Comments
 (0)