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
This commit is contained in:
najdek committed 2026-06-02 21:59:35 +02:00
1 parent 72e781867a
commit f4c76fbfb4
11 files changed
+1643 -117

No files matched your search

+101 -8
View File
@@ -37,8 +37,31 @@ const remoteRepository = [
checksum: "54df47a48d9c5ee4338ef70ba66093908a4f2845e53468bdd7c080b65d7488c1"
}
];
const communityRepository = [
{
name: "PS5 Hen Test",
filename: "ps5_hen_test_v1.0.elf",
url: "https://example.com/payloads/ps5_hen_test_v1.0.elf",
description: "Community hen test payload — no checksum",
version: "v1.0"
},
{
name: "Debug Tool",
filename: "debug_tool_v2.1.elf",
url: "https://example.com/payloads/debug_tool_v2.1.elf",
description: "Debug utility from community repo",
version: "v2.1"
}
];
let lastRepositoryUpdate = Math.floor(Date.now() / 1000);
let mockSources = [
{ id: 'default', name: 'Official Repository', url: 'https://itsplk.github.io/ps5-payloads-mirror/payloads.json', removable: false },
{ id: 'source_community', name: 'Community Payloads', url: 'https://example.com/community-payloads.json', removable: true }
];
let autoloadStatus = {
remaining: 10,
total: 2,
@@ -90,7 +113,15 @@ app.get('/list_payloads', (req, res) => {
"/data/pldmgr/etaHEN_1.8.elf",
"/data/pldmgr/kstuff.elf",
"/mnt/usb0/pldmgr/linux_loader.elf"
]
],
meta: {
// payloads with no entry here = official / no badge shown
"kstuff.elf": {
source_name: "Community Payloads",
install_source: "repository",
install_source_detail: "https://example.com/community-payloads.json"
}
}
});
});
@@ -103,15 +134,30 @@ app.get('/get_config', (req, res) => {
AUTOLOAD_ENABLED: true,
AUTOLOAD_LIST: "goldhen_v2.4b17.elf,etaHEN_1.8.elf",
LAST_REPOSITORY_UPDATE: lastRepositoryUpdate,
AUTO_INSTALL_APP: true
AUTO_INSTALL_APP: true,
MULTI_SOURCES_ENABLED: true
});
});
app.get('/repository_payloads', (req, res) => {
// Return source-grouped format (multi-source mode always on in mock)
res.json({
payloads: remoteRepository,
last_update: lastRepositoryUpdate,
cache_status: 'ok'
sources: [
{
id: 'default',
name: 'Official Repository',
last_update: lastRepositoryUpdate,
error: false,
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
},
{
id: 'source_community',
name: 'Community Payloads',
last_update: lastRepositoryUpdate,
error: false,
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
}
]
});
});
@@ -119,12 +165,59 @@ app.get('/repository_refresh', (req, res) => {
lastRepositoryUpdate = Math.floor(Date.now() / 1000);
logs.push(`[PLDMGR] Repository manually refreshed`);
res.json({
payloads: remoteRepository,
last_update: lastRepositoryUpdate,
cache_status: 'ok'
sources: [
{
id: 'default',
name: 'Official Repository',
last_update: lastRepositoryUpdate,
error: false,
payloads: remoteRepository.map(p => ({ ...p, source_id: 'default', source_name: 'Official Repository' }))
},
{
id: 'source_community',
name: 'Community Payloads',
last_update: lastRepositoryUpdate,
error: false,
payloads: communityRepository.map(p => ({ ...p, source_id: 'source_community', source_name: 'Community Payloads' }))
}
]
});
});
app.get('/sources_list', (req, res) => {
res.json({ sources: mockSources });
});
app.post('/sources_set', (req, res) => {
const { sources } = req.body;
if (Array.isArray(sources)) {
mockSources = sources;
logs.push(`[PLDMGR] Sources updated: ${sources.length} sources`);
}
res.send('OK');
});
app.get('/sources_add', (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ ok: false, message: 'Missing url' });
// Mock: always succeed with a fake source name derived from the URL
const fakeName = `Community Source (${new URL(url).hostname})`;
const newSource = { id: `source_${Date.now()}`, name: fakeName, url, removable: true };
mockSources.push(newSource);
logs.push(`[PLDMGR] Source added: ${fakeName}`);
res.json({ ok: true, name: fakeName });
});
app.get('/sources_remove', (req, res) => {
const idx = parseInt(req.query.index);
if (isNaN(idx) || idx <= 0 || idx >= mockSources.length) {
return res.status(400).json({ ok: false, message: 'Invalid index or cannot remove default source' });
}
const removed = mockSources.splice(idx, 1);
logs.push(`[PLDMGR] Source removed: ${removed[0]?.name}`);
res.json({ ok: true, message: 'OK' });
});
app.get('/repository_install', (req, res) => {
const { filename } = req.query;
if (!filename) {
+48 -17
View File
@@ -33,6 +33,7 @@ import DonateView from './components/views/DonateView'
import AutoloadOverlay from './components/views/AutoloadOverlay'
import MoveFromUsbView from './components/views/MoveFromUsbView'
import LogViewer from './components/views/LogViewer'
import ManageSourcesView from './components/views/ManageSourcesView'
function App() {
const [view, setView] = useState('dashboard')
@@ -68,6 +69,8 @@ function App() {
const [moveFromUsbPath, setMoveFromUsbPath] = useState(null)
const [storageScrollTarget, setStorageScrollTarget] = useState(null)
const [showLogs, setShowLogs] = useState(false)
// Map filename -> metadata (source_name, etc.)
const [payloadMeta, setPayloadMeta] = useState({})
useEffect(() => {
if (!showLogs) return
@@ -82,6 +85,18 @@ function App() {
const showConfirm = (title, message, onConfirm) => {
setConfirmModal({
show: true,
title,
message,
onConfirm: () => {
setConfirmModal({ show: false })
onConfirm()
}
})
}
const addToast = (message, type = 'success') => {
const id = Date.now()
setToasts(prev => [...prev, { id, message, type }])
@@ -107,7 +122,6 @@ function App() {
setLoadingPayloads(true)
const data = await api('/list_payloads')
if (data?.payloads) {
// Sort payloads: internal first, USB last
const sorted = [...data.payloads].sort((a, b) => {
const aIsUsb = a.startsWith('/mnt/usb')
const bIsUsb = b.startsWith('/mnt/usb')
@@ -116,6 +130,10 @@ function App() {
return a.localeCompare(b)
})
setPayloads(sorted)
// Build metadata map: filename -> meta object
if (data.meta && typeof data.meta === 'object') {
setPayloadMeta(data.meta)
}
setLoadingPayloads(false)
} else if (retryCount < 5) {
setTimeout(() => refreshPayloads(retryCount + 1), 1000)
@@ -158,12 +176,10 @@ function App() {
}
const handleDelete = (fileName) => {
setConfirmModal({
show: true,
title: "Delete Payload",
message: `Are you sure you want to remove ${fileName}?`,
onConfirm: async () => {
setConfirmModal({ show: false })
showConfirm(
"Delete Payload",
`Are you sure you want to remove ${fileName}?`,
async () => {
const res = await fetch(`/manage:delete?filename=${encodeURIComponent(fileName)}`)
if (!res.ok) {
addToast(`Delete failed (${res.status})`, 'error')
@@ -172,7 +188,7 @@ function App() {
refreshPayloads()
addToast(`${fileName} removed`)
}
})
)
}
const handleUpload = async (e) => {
@@ -218,27 +234,28 @@ function App() {
setTimeout(() => setDownloadModal({ show: false }), 800)
}
const handleInstall = async (p, repoUrl) => {
const handleInstall = async (p, sourceId, repoUrl) => {
if (p.isUpdate || p.isInstalled) {
setConfirmModal({
show: true,
title: p.isUpdate ? "Update Payload" : "Reinstall Payload",
message: `A version of ${p.name || p.filename} is already installed. Do you want to replace it with the repository version?`,
onConfirm: () => performInstall(p, repoUrl)
onConfirm: () => performInstall(p, sourceId, repoUrl)
})
} else {
performInstall(p, repoUrl)
performInstall(p, sourceId, repoUrl)
}
}
const performInstall = async (p, repoUrl) => {
const performInstall = async (p, sourceId, repoUrl) => {
setConfirmModal({ show: false })
setDownloadModal({ show: true, name: p.filename, progress: 10 })
try {
setDownloadModal(prev => ({ ...prev, progress: 30 }))
const res = await fetch(
`/repository_install?filename=${encodeURIComponent(p.filename)}&repo_url=${encodeURIComponent(repoUrl || '')}`
)
let url = `/repository_install?filename=${encodeURIComponent(p.filename)}`
if (sourceId) url += `&source_id=${encodeURIComponent(sourceId)}`
if (repoUrl) url += `&repo_url=${encodeURIComponent(repoUrl)}`
const res = await fetch(url)
setDownloadModal(prev => ({ ...prev, progress: 80 }))
const data = await res.json().catch(() => null)
@@ -496,12 +513,13 @@ function App() {
<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>
</div>
) : (
payloads.filter(p => !isSystemPayload(p)).map((p, i) => (
payloads.filter(p => !isSystemPayload(p)).map((p) => (
<PayloadButton
key={p}
path={p}
onClick={() => loadPayload(p)}
isLoading={loading && activeLoadingName === p.split('/').pop().replace(/\.(elf|bin|lua)$/i, '').replace(/_/g, ' ')}
sourceName={config.MULTI_SOURCES_ENABLED ? (payloadMeta[p.split('/').pop()]?.source_name || null) : null}
/>
))
)}
@@ -512,6 +530,7 @@ function App() {
{view === 'storage' && (
<StorageHub
payloads={payloads}
payloadMeta={payloadMeta}
onInstall={handleInstall}
onDelete={handleDelete}
onUpload={handleUpload}
@@ -558,8 +577,20 @@ function App() {
setLogs={setLogs}
showLogs={showLogs}
setShowLogs={setShowLogs}
onNavigate={(v) => setView(v)}
/>
)}{view === 'donate' && <DonateView />}
)}
{view === 'sources' && (
<ManageSourcesView
onBack={() => setView('settings')}
ip={ip}
addToast={addToast}
showConfirm={showConfirm}
/>
)}
{view === 'donate' && <DonateView />}
</main>
</div>
+10 -2
View File
@@ -1,8 +1,8 @@
import React from 'react'
import { Loader2 } from 'lucide-react'
import { Loader2, Globe } from 'lucide-react'
import PayloadName from './PayloadName'
const PayloadButton = ({ path, onClick, isLoading }) => {
const PayloadButton = ({ path, onClick, isLoading, sourceName }) => {
return (
<button
onClick={onClick}
@@ -18,6 +18,14 @@ const PayloadButton = ({ path, onClick, isLoading }) => {
{path}
</div>
)}
{sourceName && !path.startsWith('/mnt/usb') && (
<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">
{sourceName}
</span>
</div>
)}
{/* Glow effect */}
<div className="absolute inset-0 bg-ps-blue/0 group-hover:bg-ps-blue/5 transition-colors z-0 pointer-events-none" />
</button>
@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react'
import {
ArrowLeft, Plus, Trash2, ChevronUp, ChevronDown,
Lock, Globe, Loader2, AlertTriangle
} from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { cn, isPS5 } from '../../utils/helpers'
const ManageSourcesView = ({ onBack, ip, addToast, showConfirm }) => {
const [sources, setSources] = useState([])
const [loading, setLoading] = useState(true)
const [newUrl, setNewUrl] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
useEffect(() => {
fetch('/sources_list')
.then(r => r.json())
.then(d => {
if (d?.sources) setSources(d.sources)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const saveSources = async (updated) => {
try {
const res = await fetch('/sources_set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources: updated })
})
if (res.ok) {
addToast('Sources saved')
} else {
addToast('Failed to save sources', 'error')
}
} catch {
addToast('Failed to save sources', 'error')
}
}
const move = (idx, dir) => {
if (idx + dir < 1 || idx + dir >= sources.length) return
const updated = [...sources]
;[updated[idx], updated[idx + dir]] = [updated[idx + dir], updated[idx]]
setSources(updated)
saveSources(updated)
}
const remove = (idx) => {
if (idx === 0) return
const src = sources[idx]
showConfirm(
'Remove Source',
`Remove "${src.name}" from your sources?`,
() => {
const updated = sources.filter((_, i) => i !== idx)
setSources(updated)
saveSources(updated)
}
)
}
const handleAdd = async (e) => {
e.preventDefault()
setAddError('')
if (!newUrl.trim()) return
setAdding(true)
try {
const res = await fetch(`/sources_add?url=${encodeURIComponent(newUrl.trim())}`)
const data = await res.json()
if (data.ok) {
// Reload sources list
const listRes = await fetch('/sources_list')
const listData = await listRes.json()
if (listData?.sources) setSources(listData.sources)
setNewUrl('')
setShowAddForm(false)
addToast(`"${data.name}" added`)
} else {
setAddError(data.message || 'Failed to add source')
}
} catch {
setAddError('Request failed. Check the URL and try again.')
}
setAdding(false)
}
/* ---- PS5 Mode: QR + URL only ---- */
if (isPS5) {
return (
<div className="max-w-3xl mx-auto space-y-12 pb-20">
<div className="flex items-center space-x-6">
<button
onClick={onBack}
className="p-4 rounded-2xl bg-white/5 hover:bg-white/10 transition-all border border-white/10"
>
<ArrowLeft className="w-7 h-7" />
</button>
<h2 className="text-4xl font-extrabold text-white tracking-tight">
Manage <span className="text-ps-blue">Sources</span>
</h2>
</div>
<div className="glass-card p-10 rounded-ps-3xl border border-white/10 flex flex-col items-center gap-8">
<div className="bg-white p-6 rounded-3xl">
<QRCodeSVG value={`http://${ip}:8084`} size={180} level="M" />
</div>
<code className="text-white font-mono text-xl font-black opacity-90 italic tracking-tight uppercase">
{ip}:8084
</code>
<p className="text-zinc-400 text-center text-lg leading-relaxed max-w-md">
Open this address on your phone or PC to manage payload sources.
</p>
</div>
</div>
)
}
/* ---- Desktop Mode: full CRUD ---- */
return (
<div className="max-w-3xl mx-auto space-y-10 pb-20">
{/* Header */}
<div className="flex items-center space-x-6">
<button
onClick={onBack}
className="p-4 rounded-2xl bg-white/5 hover:bg-white/10 transition-all border border-white/10"
>
<ArrowLeft className="w-7 h-7" />
</button>
<h2 className="text-4xl font-extrabold text-white tracking-tight">
Payload <span className="text-ps-blue">Sources</span>
</h2>
</div>
{/* Sources list */}
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="w-10 h-10 text-ps-blue animate-spin" />
</div>
) : (
<div className="space-y-3">
{sources.map((src, idx) => (
<div
key={src.id}
className={cn(
'group flex items-center gap-4 p-5 glass-card rounded-2xl border transition-all',
src.removable
? 'border-white/10 hover:border-ps-blue/30'
: 'border-white/5 bg-white/[0.015]'
)}
>
{/* Priority index */}
<div className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center text-sm font-black shrink-0',
idx === 0 ? 'bg-ps-blue/20 text-ps-blue' : 'bg-white/5 text-zinc-500'
)}>
{idx + 1}
</div>
{/* Icon */}
<div className="p-2 bg-white/5 rounded-xl shrink-0">
{src.removable
? <Globe className="w-5 h-5 text-zinc-400 group-hover:text-ps-blue transition-colors" />
: <Lock className="w-5 h-5 text-ps-blue" />
}
</div>
{/* Name + URL */}
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-base leading-tight">{src.name}</p>
<p className="text-xs text-zinc-500 truncate mt-0.5 font-mono">{src.url}</p>
</div>
{/* Controls */}
<div className="flex items-center space-x-2 shrink-0">
{src.removable && (
<>
<button
onClick={() => move(idx, -1)}
disabled={idx <= 1}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
title="Move up"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => move(idx, 1)}
disabled={idx === sources.length - 1}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
title="Move down"
>
<ChevronDown className="w-4 h-4" />
</button>
{/* Remove button */}
<button
onClick={() => remove(idx)}
className="p-2 rounded-xl bg-red-950/20 text-red-500 border border-red-500/10 hover:bg-red-500 hover:text-white transition-all"
title="Remove source"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{!src.removable && (
<span className="px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest text-ps-blue border border-ps-blue/20 bg-ps-blue/5">
Default
</span>
)}
</div>
</div>
))}
</div>
)}
{/* Add Source */}
{!showAddForm ? (
<button
onClick={() => { setShowAddForm(true); setAddError('') }}
className="w-full flex items-center justify-center space-x-3 py-5 border-2 border-dashed border-white/10 rounded-2xl text-zinc-500 hover:text-ps-blue hover:border-ps-blue/30 transition-all font-bold"
>
<Plus className="w-5 h-5" />
<span>Add Source</span>
</button>
) : (
<form onSubmit={handleAdd} className="p-6 glass-card rounded-2xl border border-white/10 space-y-4">
<p className="font-bold text-white text-lg">Add a New Source</p>
<p className="text-sm text-zinc-500">
Paste the URL to a JSON file. The source name will be read automatically from the JSON.
</p>
<div className="flex gap-3">
<input
type="url"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
placeholder="https://example.com/payloads.json"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-5 py-3 text-white text-sm placeholder-zinc-600 focus:outline-none focus:border-ps-blue/50 transition-colors"
autoFocus
disabled={adding}
/>
<button
type="submit"
disabled={adding || !newUrl.trim()}
className="flex items-center space-x-2 px-6 py-3 bg-ps-blue hover:bg-ps-blue/80 disabled:opacity-50 text-white rounded-xl font-bold transition-all"
>
{adding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
<span>{adding ? 'Validating...' : 'Add'}</span>
</button>
<button
type="button"
onClick={() => { setShowAddForm(false); setNewUrl(''); setAddError('') }}
disabled={adding}
className="px-5 py-3 bg-white/5 hover:bg-white/10 text-white rounded-xl font-bold transition-all"
>
Cancel
</button>
</div>
{addError && (
<div className="flex items-center space-x-3 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{addError}</span>
</div>
)}
</form>
)}
</div>
)
}
export default ManageSourcesView
+52 -2
View File
@@ -1,11 +1,13 @@
import React, { useState } from 'react'
import { Zap, Terminal, X, ChevronRight } from 'lucide-react'
import { Zap, Terminal, X, ChevronRight, Globe } from 'lucide-react'
import { cn } from '../../utils/helpers'
const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, setShowLogs }) => {
const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, setShowLogs, onNavigate }) => {
const autoOpen = config.AUTO_BROWSER_OPEN !== false
const autoInstall = config.AUTO_INSTALL_APP !== false
const autoloadDelay = config.AUTOLOAD_DELAY || 5
const multiSources = config.MULTI_SOURCES_ENABLED === true
const SettingRow = ({ title, description, children, icon: Icon }) => (
<div className="flex items-center justify-between p-8 bg-white/[0.03] rounded-3xl border border-white/10 hover:border-ps-blue/30 transition-all group h-full">
@@ -138,6 +140,53 @@ const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, se
</div>
</section>
{/* Multi-Source */}
<section className="space-y-8">
<h3 className="label-caps !text-ps-blue !opacity-100 flex items-center space-x-4 text-xl tracking-[0.2em]">
<Globe className="w-6 h-6" />
<span>Payload Sources</span>
</h3>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SettingRow
title="Multiple Payload Sources"
description="Enable third-party payload repositories. Payloads from multiple sources are grouped by catalog in the Manage tab."
icon={Globe}
>
<button
onClick={() => onSaveConfig({ MULTI_SOURCES_ENABLED: !multiSources })}
className={cn(
"w-20 h-10 rounded-full transition-all relative p-1.5",
multiSources ? "bg-ps-blue" : "bg-white/10"
)}
>
<div className={cn(
"w-7 h-7 bg-white rounded-full transition-all",
multiSources ? "translate-x-10" : "translate-x-0"
)} />
</button>
</SettingRow>
{multiSources && (
<button
onClick={() => onNavigate('sources')}
className="group flex items-center justify-between p-8 bg-white/[0.03] rounded-3xl border border-white/10 hover:border-ps-blue/50 hover:bg-ps-blue/5 transition-all text-left"
>
<div className="flex items-center space-x-6">
<div className="p-4 bg-white/5 rounded-2xl group-hover:bg-ps-blue/10 transition-colors">
<Globe className="w-6 h-6 text-zinc-500 group-hover:text-ps-blue transition-colors" />
</div>
<div className="space-y-1">
<p className="font-bold text-white uppercase text-lg tracking-tight">Manage Sources</p>
<p className="text-sm text-zinc-500 max-w-md">Add, remove, or reorder your payload repositories.</p>
</div>
</div>
<ChevronRight className="w-8 h-8 text-zinc-700 group-hover:text-ps-blue group-hover:translate-x-2 transition-all" />
</button>
)}
</div>
</section>
{/* Diagnostics */}
<section className="space-y-8">
<h3 className="label-caps !text-ps-blue !opacity-100 flex items-center space-x-4 text-xl tracking-[0.2em]">
@@ -162,6 +211,7 @@ const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, se
</button>
</section>
</div>
)
}
+183 -63
View File
@@ -1,15 +1,16 @@
import React, { useState, useEffect, useMemo } from 'react'
import { CloudDownload, Upload, Package, Database, RefreshCw, Trash2, Loader2, AlertTriangle, HardDrive, Usb } from 'lucide-react'
import { CloudDownload, Upload, Package, Database, RefreshCw, Trash2, Loader2, AlertTriangle, HardDrive, Usb, ChevronDown, Globe } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { cn, isPS5, isSystemPayload } from '../../utils/helpers'
import PayloadName from '../ui/PayloadName'
const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb, config, ip, scrollTarget, onClearScrollTarget }) => {
const [remotePayloads, setRemotePayloads] = useState([])
const [repoUrl, setRepoUrl] = useState('')
const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onImportFromUsb, config, ip, scrollTarget, onClearScrollTarget }) => {
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 [lastUpdate, setLastUpdate] = useState(0)
const [expandedSource, setExpandedSource] = useState(null) // id of expanded catalog
const fetchRemote = async (force = false) => {
setLoading(true)
@@ -19,17 +20,18 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
const res = await fetch(endpoint)
if (!res.ok) throw new Error()
const data = await res.json()
setRepoData(data)
if (!Array.isArray(data?.payloads)) throw new Error()
setRemotePayloads(data.payloads)
setRepoUrl(data.repo_url || '')
const lastUpd = Number(data.last_update || 0)
setLastUpdate(lastUpd)
// 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)
}
// Auto-update if older than 24h
if (!force) {
// Legacy single-source: auto-refresh if older than 24h
if (!force && data?.last_update) {
const now = Math.floor(Date.now() / 1000)
if (now - lastUpd > 24 * 60 * 60) {
if (now - Number(data.last_update) > 24 * 60 * 60) {
await fetchRemote(true)
return
}
@@ -41,17 +43,13 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
}
}
useEffect(() => {
fetchRemote()
}, [])
useEffect(() => { fetchRemote() }, [])
useEffect(() => {
if (scrollTarget) {
const timer = setTimeout(() => {
const element = document.getElementById(scrollTarget);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
if (element) element.scrollIntoView({ behavior: 'smooth' });
if (onClearScrollTarget) onClearScrollTarget();
}, 300);
return () => clearTimeout(timer);
@@ -69,23 +67,44 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
return clean.replace(/[_-]ps[45]$/i, '');
}
const remoteStatus = useMemo(() => {
if (!Array.isArray(remotePayloads)) return []
return remotePayloads.map(p => {
/* ---- Derive remote status for a flat list of payloads ---- */
const enrichPayloads = (list) =>
list.map(p => {
const isInstalled = p.filename ? localFilenames.includes(p.filename) : false
const baseName = getBaseName(p.filename)
const installedVersion = localFilenames.find(f => getBaseName(f) === baseName)
const isUpdate = !isInstalled && !!installedVersion
return { ...p, isInstalled, isUpdate, installedFilename: installedVersion }
}).sort((a, b) => {
if (a.isUpdate && !b.isUpdate) return -1
if (!a.isUpdate && b.isUpdate) return 1
return 0
})
}, [remotePayloads, localFilenames])
const cloudItems = remoteStatus.filter(p => !p.isInstalled || p.isUpdate);
/* ---- Source-grouped data (multi-source mode) ---- */
const enrichedSources = useMemo(() => {
if (!repoData?.sources) return []
return repoData.sources.map(src => ({
...src,
payloads: enrichPayloads(src.payloads || [])
}))
}, [repoData, localFilenames])
/* ---- Flat list (legacy single-source mode) ---- */
const remotePayloads = useMemo(() => {
if (!repoData?.payloads) return []
return enrichPayloads(repoData.payloads)
}, [repoData, localFilenames])
const legacyRepoUrl = repoData?.repo_url || ''
const legacyLastUpdate = Number(repoData?.last_update || 0)
/* ---- Source badge helper: look up source name from metadata ---- */
const getSourceBadge = (fileName) => {
if (!multiSources || !payloadMeta) return null
const meta = payloadMeta[fileName]
return meta?.source_name || meta?.install_source_detail || null
}
return (
<div className="space-y-12">
@@ -122,12 +141,17 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">Library Empty</p>
</div>
) : (
internalPayloads.map((path, i) => {
internalPayloads.map((path) => {
const fileName = path.split('/').pop()
const remoteMatch = remoteStatus.find(rp => rp.filename === fileName || rp.installedFilename === fileName)
const sourceBadge = getSourceBadge(fileName)
// Find update in all sources (multi or legacy)
const allRemote = multiSources
? enrichedSources.flatMap(s => s.payloads)
: remotePayloads
const remoteMatch = allRemote.find(rp => rp.filename === fileName || rp.installedFilename === fileName)
return (
<div key={path} className={cn(
"group flex justify-between p-4 md:p-6 glass-card rounded-ps-2xl border-white/10 hover:border-ps-blue/30 gap-4",
"group flex justify-between p-4 md:p-6 glass-card rounded-ps-2xl border-white/10 hover:border-ps-blue/30 gap-4 relative overflow-hidden",
isPS5 ? "flex-row items-center" : "flex-col md:flex-row md:items-center"
)}>
<div className="flex items-center space-x-4 md:space-x-6 min-w-0">
@@ -136,12 +160,21 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
</div>
<div className="min-w-0 flex-1">
<PayloadName path={fileName} className="text-xl md:text-2xl text-white" stacked />
{/* Source badge — floats in bottom-right, doesn't expand the row */}
{sourceBadge && (
<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-[160px] select-none">
{sourceBadge}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-3 md:space-x-4 ml-auto md:ml-0">
{remoteMatch?.isUpdate && (
<button
onClick={() => onInstall(remoteMatch, repoUrl)}
onClick={() => onInstall(remoteMatch, remoteMatch.source_id, legacyRepoUrl)}
className="flex items-center space-x-2 md:space-x-3 px-4 md:px-6 py-2 md:py-3 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" />
@@ -174,13 +207,8 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
<RefreshCw className={cn("w-5 h-5", loading && "animate-spin")} />
</button>
</div>
{lastUpdate > 0 && (
<p className="px-2 text-xs uppercase tracking-widest text-zinc-500">
Last Sync: {new Date(lastUpdate * 1000).toLocaleString()}
</p>
)}
{loading && remotePayloads.length === 0 ? (
{loading && !repoData ? (
<div className="py-24 glass-panel rounded-ps-3xl border-white/5 flex flex-col items-center justify-center space-y-6">
<Loader2 className="w-16 h-16 text-ps-blue animate-spin" />
<p className="label-caps">Syncing with Repository...</p>
@@ -194,39 +222,131 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
</div>
<button onClick={() => fetchRemote(true)} className="px-8 py-3 bg-white/5 border border-white/10 hover:bg-white/10 text-white rounded-xl font-bold uppercase text-xs transition-all">Retry Connection</button>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{cloudItems.length === 0 ? (
<div className="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]">
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">Repository Up to Date</p>
</div>
) : (
cloudItems.map((p, i) => (
<div key={p.filename} className={cn(
"glass-card p-6 md:p-8 rounded-ps-3xl flex flex-col md:flex-row justify-between gap-4 md:gap-8 border-white/10 hover:border-ps-blue/20 transition-all bg-white/[0.01]",
isPS5 ? "flex-row items-center" : "items-start md:items-center"
)}>
<div className="space-y-2 md:space-y-3 min-w-0">
<div className="flex items-center space-x-4">
<PayloadName path={p.filename} className="text-xl md:text-2xl text-white" stacked />
</div>
<p className="text-sm md:text-lg text-zinc-400 font-medium max-w-3xl leading-relaxed">{p.description}</p>
</div>
) : multiSources && enrichedSources.length > 0 ? (
/* ===== MULTI-SOURCE: accordion catalogs ===== */
<div className="space-y-4">
{enrichedSources.map(src => {
const availablePayloads = src.payloads.filter(p => !p.isInstalled || p.isUpdate)
const isExpanded = expandedSource === src.id
return (
<div key={src.id} className="glass-card rounded-ps-3xl border border-white/10 overflow-hidden">
{/* Catalog header */}
<button
onClick={() => onInstall(p, repoUrl)}
className={cn(
"flex items-center justify-center space-x-3 md:space-x-4 px-6 md:px-8 py-3 md:py-5 rounded-2xl font-bold text-lg md:text-xl 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"
)}
onClick={() => setExpandedSource(isExpanded ? null : src.id)}
className="w-full flex items-center justify-between p-6 md:p-8 hover:bg-white/5 transition-colors"
>
<CloudDownload className="w-5 h-5 md:w-7 md:h-7" />
<span>{p.isUpdate ? "Update" : "Install"}</span>
<div className="flex items-center space-x-4">
<div className="p-2.5 bg-ps-blue/10 rounded-xl">
<Globe className="w-5 h-5 text-ps-blue" />
</div>
<div className="text-left">
<p className="font-bold text-white text-lg">{src.name}</p>
{src.error && (
<p className="text-xs text-red-400 flex items-center space-x-1 mt-0.5">
<AlertTriangle className="w-3 h-3" />
<span>Fetch failed</span>
</p>
)}
{!src.error && src.last_update > 0 && (
<p className="text-xs text-zinc-600 mt-0.5">
Updated {new Date(src.last_update * 1000).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-3">
<span className="px-3 py-1 rounded-full bg-white/5 text-zinc-500 text-xs font-bold">
{availablePayloads.length} available
</span>
<ChevronDown className={cn("w-5 h-5 text-zinc-500 transition-transform", isExpanded && "rotate-180")} />
</div>
</button>
{/* Catalog payload list */}
{isExpanded && (
<div className="border-t border-white/5 divide-y divide-white/5">
{availablePayloads.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
<p className="text-sm font-bold uppercase tracking-widest italic">All payloads installed</p>
</div>
) : (
availablePayloads.map(p => (
<div
key={p.filename}
className={cn(
"flex flex-col md:flex-row justify-between gap-4 md:gap-8 p-6 md:p-8 hover:bg-white/[0.02] transition-colors",
isPS5 ? "flex-row items-center" : "items-start md:items-center"
)}
>
<div className="space-y-2 min-w-0">
<PayloadName path={p.filename} className="text-xl md:text-2xl text-white" stacked />
{p.description && (
<p className="text-sm md:text-base text-zinc-400 font-medium leading-relaxed">{p.description}</p>
)}
</div>
<button
onClick={() => onInstall(p, p.source_id, src.url)}
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 ? "Update" : "Install"}</span>
</button>
</div>
))
)}
</div>
)}
</div>
))
)
})}
</div>
) : (
/* ===== SINGLE-SOURCE: flat list (legacy / multi-source disabled) ===== */
<div className="grid grid-cols-1 gap-4">
{legacyLastUpdate > 0 && (
<p className="px-2 text-xs uppercase tracking-widest text-zinc-500">
Last Sync: {new Date(legacyLastUpdate * 1000).toLocaleString()}
</p>
)}
{(() => {
const cloudItems = remotePayloads.filter(p => !p.isInstalled || p.isUpdate)
return cloudItems.length === 0 ? (
<div className="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]">
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">Repository Up to Date</p>
</div>
) : (
cloudItems.map(p => (
<div key={p.filename} className={cn(
"glass-card p-6 md:p-8 rounded-ps-3xl flex flex-col md:flex-row justify-between gap-4 md:gap-8 border-white/10 hover:border-ps-blue/20 transition-all bg-white/[0.01]",
isPS5 ? "flex-row items-center" : "items-start md:items-center"
)}>
<div className="space-y-2 md:space-y-3 min-w-0">
<div className="flex items-center space-x-4">
<PayloadName path={p.filename} className="text-xl md:text-2xl text-white" stacked />
</div>
<p className="text-sm md:text-lg text-zinc-400 font-medium max-w-3xl leading-relaxed">{p.description}</p>
</div>
<button
onClick={() => onInstall(p, null, legacyRepoUrl)}
className={cn(
"flex items-center justify-center space-x-3 md:space-x-4 px-6 md:px-8 py-3 md:py-5 rounded-2xl font-bold text-lg md:text-xl 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-7 md:h-7" />
<span>{p.isUpdate ? "Update" : "Install"}</span>
</button>
</div>
))
)
})()}
</div>
)}
</section>
+8 -1
View File
@@ -38,7 +38,14 @@ export default defineConfig({
target: 'http://127.0.0.1:8081',
changeOrigin: true,
sse: true
}
},
'/sources_list': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/sources_set': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/sources_add': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/sources_remove': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/repository_payloads': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/repository_refresh': { target: 'http://127.0.0.1:8081', changeOrigin: true },
'/repository_install': { target: 'http://127.0.0.1:8081', changeOrigin: true }
}
}
})
+10
View File
@@ -26,4 +26,14 @@ int payload_mgr_usb_move(const char *usb_path, int overwrite, char *out_json, si
int payload_mgr_check_self_update(char *out_path, size_t out_size);
/* Sources Management */
int payload_mgr_sources_list_json(char *buf, size_t size);
int payload_mgr_sources_save(const char *json, size_t len);
int payload_mgr_sources_add(const char *url, char *msg_buf, size_t msg_size);
int payload_mgr_sources_remove(int index, char *msg_buf, size_t msg_size);
/* Multi-Source Repository */
size_t payload_mgr_multi_repository_list_json(char *buf, size_t size, int force_refresh);
int payload_mgr_multi_repository_install(const char *filename, const char *source_id, const char *repo_url, char *msg, size_t msg_size);
#endif
+6
View File
@@ -26,6 +26,10 @@
#define ROUTE_REPO_INSTALL "/repository_install"
#define ROUTE_REPO_PUSH "/repository_push"
#define ROUTE_REPO_INSTALL_PUSH "/repository_install_push"
#define ROUTE_SOURCES_LIST "/sources_list"
#define ROUTE_SOURCES_SET "/sources_set"
#define ROUTE_SOURCES_ADD "/sources_add"
#define ROUTE_SOURCES_REMOVE "/sources_remove"
#define ROUTE_USB_MOVE_CHECK "/usb_move_check"
#define ROUTE_USB_MOVE_PERFORM "/usb_move_perform"
#define ROUTE_CACHE_MANIFEST "/cache.appcache"
@@ -50,6 +54,8 @@ int pldmgr_read_config_bool(const char *key, int default_val);
/* Paths */
#define BASE_DATA_DIR "/data/pldmgr"
#define SOURCES_CONFIG_PATH "/data/pldmgr/sources.json"
#define MAX_SOURCES 9
/* Scan Locations (Internal + 8 USB ports) */
static const char *SCAN_DIRS[] = {
+164 -20
View File
@@ -272,7 +272,8 @@ struct UploadStatus {
static void read_next_config_values(int *enabled, long *repo_update,
int *browser_open, int *auto_delay,
int *kill_disc_player, int *scan_usb, int *auto_install_app) {
int *kill_disc_player, int *scan_usb, int *auto_install_app,
int *multi_sources) {
FILE *f = fopen(PLDMGR_CONFIG_PATH, "r");
char line[256];
@@ -283,6 +284,7 @@ static void read_next_config_values(int *enabled, long *repo_update,
*kill_disc_player = 1; /* Default on */
*scan_usb = 0; /* Default off */
*auto_install_app = 1; /* Default on */
*multi_sources = 0; /* Default off */
if (!f) {
return;
@@ -303,6 +305,8 @@ static void read_next_config_values(int *enabled, long *repo_update,
*scan_usb = atoi(line + 18);
} else if (strncmp(line, "AUTO_INSTALL_APP=", 17) == 0) {
*auto_install_app = atoi(line + 17);
} else if (strncmp(line, "MULTI_SOURCES_ENABLED=", 22) == 0) {
*multi_sources = atoi(line + 22);
}
}
fclose(f);
@@ -310,7 +314,8 @@ static void read_next_config_values(int *enabled, long *repo_update,
static int write_next_config_values(int enabled, long repo_update,
int browser_open, int auto_delay,
int kill_disc_player, int scan_usb, int auto_install_app) {
int kill_disc_player, int scan_usb, int auto_install_app,
int multi_sources) {
FILE *f;
mkdir(BASE_DATA_DIR, 0777);
@@ -326,6 +331,7 @@ static int write_next_config_values(int enabled, long repo_update,
fprintf(f, "KILL_DISC_PLAYER_ON_STARTUP=%d\n", kill_disc_player ? 1 : 0);
fprintf(f, "SCAN_USB_PAYLOADS=%d\n", scan_usb ? 1 : 0);
fprintf(f, "AUTO_INSTALL_APP=%d\n", auto_install_app ? 1 : 0);
fprintf(f, "MULTI_SOURCES_ENABLED=%d\n", multi_sources ? 1 : 0);
fclose(f);
return 0;
}
@@ -407,6 +413,15 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
return MHD_YES;
}
if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) {
struct PostStatus *status = malloc(sizeof(struct PostStatus));
status->data = NULL;
status->size = 0;
status->error = 0;
*con_cls = status;
return MHD_YES;
}
if (strncmp(url, ROUTE_REPO_INSTALL_PUSH,
strlen(ROUTE_REPO_INSTALL_PUSH)) == 0 &&
strcmp(method, "POST") == 0) {
@@ -495,9 +510,9 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
if (enabled != -1) {
int ex_en, ex_br, ex_del, ex_kill, ex_usb, ex_install;
int ex_en, ex_br, ex_del, ex_kill, ex_usb, ex_install, ex_multi;
long ex_repo;
read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install);
read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install, &ex_multi);
/* Update individual fields from JSON if present */
int browser_open = ex_br;
@@ -573,8 +588,23 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
}
int multi_sources = ex_multi;
char *multi_pos = strstr(status->data, "\"MULTI_SOURCES_ENABLED\"");
if (multi_pos) {
char *val = strchr(multi_pos, ':');
if (val) {
val++;
while (*val == ' ')
val++;
if (strncmp(val, "true", 4) == 0)
multi_sources = 1;
else if (strncmp(val, "false", 5) == 0)
multi_sources = 0;
}
}
if (write_next_config_values(enabled, ex_repo, browser_open,
auto_delay, kill_disc, scan_usb, auto_install) == 0) {
auto_delay, kill_disc, scan_usb, auto_install, multi_sources) == 0) {
pldmgr_log("[PLDMGR] Saved config to %s\n", PLDMGR_CONFIG_PATH);
}
if (enabled == 0)
@@ -675,6 +705,41 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
}
/* Handle POST data for /sources_set */
if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) {
struct PostStatus *status = (struct PostStatus *)*con_cls;
if (*upload_data_size != 0) {
char *nd = realloc(status->data, status->size + *upload_data_size + 1);
if (!nd) {
status->error = 1;
} else {
status->data = nd;
memcpy(status->data + status->size, upload_data, *upload_data_size);
status->size += *upload_data_size;
status->data[status->size] = '\0';
}
*upload_data_size = 0;
return MHD_YES;
} else {
int ok = -1;
if (status->data && !status->error)
ok = payload_mgr_sources_save(status->data, status->size);
if (status->data)
free(status->data);
free(status);
*con_cls = NULL;
const char *msg = ok == 0 ? MSG_OK : "Failed to save sources";
struct MHD_Response *resp_ss = MHD_create_response_from_buffer(
strlen(msg), (void *)msg, MHD_RESPMEM_MUST_COPY);
add_cors_headers(resp_ss);
enum MHD_Result ret_ss = MHD_queue_response(
conn, ok == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp_ss);
MHD_destroy_response(resp_ss);
return ret_ss;
}
}
/* Chunked data arrival */
if (strncmp(url, ROUTE_UPLOAD, strlen(ROUTE_UPLOAD)) == 0) {
struct UploadStatus *status = (struct UploadStatus *)*con_cls;
@@ -911,8 +976,14 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
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);
size_t len = payload_mgr_repository_list_json(resp_buf,
RESPONSE_BUFFER_SIZE, 0);
int multi_en = 0, _a, _b, _c, _d, _e, _f;
long _g;
read_next_config_values(&_a, &_g, &_b, &_c, &_d, &_e, &_f, &multi_en);
size_t len;
if (multi_en)
len = payload_mgr_multi_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 0);
else
len = payload_mgr_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 0);
resp = MHD_create_response_from_buffer(len, (void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
MHD_add_response_header(resp, "Content-Type", "application/json");
@@ -921,14 +992,21 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
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);
size_t len = payload_mgr_repository_list_json(resp_buf,
RESPONSE_BUFFER_SIZE, 1);
resp = MHD_create_response_from_buffer(len, (void *)resp_buf,
int multi_en2 = 0, _a2, _b2, _c2, _d2, _e2, _f2;
long _g2;
read_next_config_values(&_a2, &_g2, &_b2, &_c2, &_d2, &_e2, &_f2, &multi_en2);
size_t len2;
if (multi_en2)
len2 = payload_mgr_multi_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 1);
else
len2 = payload_mgr_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 1);
resp = MHD_create_response_from_buffer(len2, (void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
MHD_add_response_header(resp, "Content-Type", "application/json");
} else if (strcmp(url, ROUTE_REPO_INSTALL) == 0) {
const char *filename = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename");
const char *repo_url = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "repo_url");
const char *source_id = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "source_id");
if (!filename) {
const char *err = "{\"ok\":false,\"message\":\"Missing filename\"}";
resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
@@ -945,8 +1023,16 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
char msg_buf[1024] = "";
const char *detail = (repo_url && repo_url[0]) ? repo_url : REPOSITORY_SOURCE_URL;
int rc = payload_mgr_repository_install_download(filename, detail, msg_buf, sizeof(msg_buf));
int rc;
/* Use multi-source install if source_id is provided */
if (source_id && source_id[0]) {
rc = payload_mgr_multi_repository_install(filename, source_id,
repo_url ? repo_url : "",
msg_buf, sizeof(msg_buf));
} else {
const char *detail = (repo_url && repo_url[0]) ? repo_url : REPOSITORY_SOURCE_URL;
rc = payload_mgr_repository_install_download(filename, detail, msg_buf, sizeof(msg_buf));
}
if (msg_buf[0] == '\0') {
snprintf(msg_buf, sizeof(msg_buf), rc == 0 ? "Installed" : "Install failed");
}
@@ -958,6 +1044,62 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp);
} else if (strcmp(url, ROUTE_SOURCES_LIST) == 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);
payload_mgr_sources_list_json(resp_buf, RESPONSE_BUFFER_SIZE);
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");
} else if (strcmp(url, ROUTE_SOURCES_ADD) == 0) {
const char *src_url = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "url");
if (!src_url || !src_url[0]) {
const char *err = "{\"ok\":false,\"message\":\"Missing url parameter\"}";
resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, MHD_HTTP_BAD_REQUEST, resp);
}
char msg_buf_src[512] = "";
int rc_src = payload_mgr_sources_add(src_url, msg_buf_src, sizeof(msg_buf_src));
char json_resp_src[1024];
if (rc_src == 0) {
char name_escaped[512];
pldmgr_json_escape(msg_buf_src, name_escaped, sizeof(name_escaped));
snprintf(json_resp_src, sizeof(json_resp_src),
"{\"ok\":true,\"name\":\"%s\"}", name_escaped);
} else {
char msg_escaped[512];
pldmgr_json_escape(msg_buf_src, msg_escaped, sizeof(msg_escaped));
snprintf(json_resp_src, sizeof(json_resp_src),
"{\"ok\":false,\"message\":\"%s\"}", msg_escaped);
}
resp = MHD_create_response_from_buffer(strlen(json_resp_src), (void *)json_resp_src, MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, rc_src == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_REQUEST, resp);
} else if (strcmp(url, ROUTE_SOURCES_REMOVE) == 0) {
const char *idx_str = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "index");
if (!idx_str) {
const char *err = "{\"ok\":false,\"message\":\"Missing index parameter\"}";
resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, MHD_HTTP_BAD_REQUEST, resp);
}
int idx = atoi(idx_str);
char msg_rm[256] = "";
int rc_rm = payload_mgr_sources_remove(idx, msg_rm, sizeof(msg_rm));
char json_rm[512];
char msg_rm_e[256];
pldmgr_json_escape(msg_rm, msg_rm_e, sizeof(msg_rm_e));
snprintf(json_rm, sizeof(json_rm), "{\"ok\":%s,\"message\":\"%s\"}",
rc_rm == 0 ? "true" : "false", msg_rm_e);
resp = MHD_create_response_from_buffer(strlen(json_rm), (void *)json_rm, MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, rc_rm == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_REQUEST, resp);
} else if (strncmp(url, ROUTE_LOAD_PAYLOAD, strlen(ROUTE_LOAD_PAYLOAD)) ==
0) {
const char *path = url + strlen(ROUTE_LOAD_PAYLOAD);
@@ -1080,10 +1222,10 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
fclose(f);
}
int config_enabled, config_browser, config_delay, config_kill, config_usb, config_install;
int config_enabled, config_browser, config_delay, config_kill, config_usb, config_install, config_multi;
long config_repo;
read_next_config_values(&config_enabled, &config_repo, &config_browser,
&config_delay, &config_kill, &config_usb, &config_install);
&config_delay, &config_kill, &config_usb, &config_install, &config_multi);
char current_escaped[256];
char list_escaped[8192];
@@ -1124,10 +1266,10 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
} else if (strcmp(url, ROUTE_GET_CONFIG) == 0) {
int enabled = 0, browser_open = 1, auto_delay = 5, kill_disc = 1, scan_usb = 0, auto_install = 1;
int enabled = 0, browser_open = 1, auto_delay = 5, kill_disc = 1, scan_usb = 0, auto_install = 1, multi_src = 0;
long last_repo_update = 0;
read_next_config_values(&enabled, &last_repo_update, &browser_open,
&auto_delay, &kill_disc, &scan_usb, &auto_install);
&auto_delay, &kill_disc, &scan_usb, &auto_install, &multi_src);
/* Get list */
char list_buf[4096] = {0};
@@ -1157,11 +1299,13 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
"{\"AUTOLOAD_ENABLED\":%s,\"AUTOLOAD_LIST\":\"%s\",\"LAST_"
"REPOSITORY_UPDATE\":%ld,"
"\"AUTO_BROWSER_OPEN\":%s,\"AUTOLOAD_DELAY\":%d,\"KILL_DISC_"
"PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s}",
"PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s,"
"\"MULTI_SOURCES_ENABLED\":%s}",
enabled ? "true" : "false", list_escaped, last_repo_update,
browser_open ? "true" : "false", auto_delay,
kill_disc ? "true" : "false", scan_usb ? "true" : "false",
auto_install ? "true" : "false");
auto_install ? "true" : "false",
multi_src ? "true" : "false");
resp = MHD_create_response_from_buffer(strlen(resp_buf),
(void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
@@ -1241,9 +1385,9 @@ int main(int argc, char *argv[]) {
}
/* Start Autoload Sequence (if config exists) */
int ex_en, ex_br = 1, ex_del = 5, ex_kill = 1, ex_usb = 0, ex_install = 1;
int ex_en, ex_br = 1, ex_del = 5, ex_kill = 1, ex_usb = 0, ex_install = 1, ex_multi = 0;
long ex_repo = 0;
read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install);
read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install, &ex_multi);
/* Install app if requested */
if (ex_install) {
+790 -4
View File
@@ -32,6 +32,7 @@ typedef struct RepoPayload {
char last_update[64];
char version[64];
char checksum[65];
char source_name[256]; /* human-readable name of the source that installed this payload */
} RepoPayload;
typedef struct JsonListBuilder {
@@ -624,7 +625,7 @@ static int is_allowed_usb_path(const char *path) {
static int write_payload_details_json(const RepoPayload *item, const char *details_path, const char *install_source, const char *install_source_detail) {
char name[256], filename[384], url[1400], source[1400], source_direct[1400];
char description[1400], last_update[128], version[128], checksum[128];
char downloaded_at[64], i_src[256], i_detail[1400];
char downloaded_at[64], i_src[256], i_detail[1400], i_src_name[256];
char json_buf[8192];
time_t now = time(NULL);
struct tm tmv;
@@ -644,6 +645,10 @@ static int is_allowed_usb_path(const char *path) {
pldmgr_json_escape(item->checksum, checksum, sizeof(checksum));
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));
/* source_name: the human-readable name of the source that supplied this
* payload. Stored in item->source_direct when set by multi-source
* install; falls back to empty string for default/uploaded payloads. */
pldmgr_json_escape(item->source_name[0] ? item->source_name : "", i_src_name, sizeof(i_src_name));
snprintf(json_buf, sizeof(json_buf),
"{\n"
@@ -658,10 +663,12 @@ static int is_allowed_usb_path(const char *path) {
" \"checksum\": \"%s\",\n"
" \"downloaded_at\": \"%s\",\n"
" \"install_source\": \"%s\",\n"
" \"install_source_detail\": \"%s\"\n"
" \"install_source_detail\": \"%s\",\n"
" \"source_name\": \"%s\"\n"
"}\n",
name, filename, url, source, source_direct, description,
last_update, version, checksum, downloaded_at, i_src, i_detail);
last_update, version, checksum, downloaded_at, i_src, i_detail,
i_src_name);
FILE *f = fopen(details_path, "w");
if (!f) return -1;
@@ -874,7 +881,150 @@ static int is_allowed_usb_path(const char *path) {
}
}
json_append(&jb, "]}");
if (json_append(&jb, "],") != 0 ||
json_append(&jb, "\"meta\":{") != 0) {
return 0;
}
/* Emit meta: walk PAYLOADS_STORAGE_DIR/<folder>/*.elf.json sidecars.
* Each sidecar is named <filename.elf>.json. Strip ".json" to get
* the key. Only emit entries where source_name is non-empty. */
int meta_first = 1;
DIR *top = opendir(PAYLOADS_STORAGE_DIR);
if (top) {
struct dirent *sub;
while ((sub = readdir(top)) != NULL) {
if (sub->d_name[0] == '.') continue;
char subdir[512];
snprintf(subdir, sizeof(subdir), "%s/%s",
PAYLOADS_STORAGE_DIR, sub->d_name);
DIR *d = opendir(subdir);
if (!d) continue;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
const char *n = de->d_name;
size_t nl = strlen(n);
/* Must end with ".json" and the part before must end
* with .elf / .bin / .lua e.g. "kstuff.elf.json" */
if (nl < 9) continue;
if (strcasecmp(n + nl - 5, ".json") != 0) continue;
/* base_len = length of name without ".json" */
size_t base_len = nl - 5;
/* Check last 4 chars of base for payload extension */
if (base_len < 4) continue;
char last4[5];
memcpy(last4, n + base_len - 4, 4);
last4[4] = '\0';
if (strcasecmp(last4, ".elf") != 0 &&
strcasecmp(last4, ".bin") != 0 &&
strcasecmp(last4, ".lua") != 0) continue;
/* Read sidecar */
char sidecar[700];
snprintf(sidecar, sizeof(sidecar), "%s/%s", subdir, n);
char *sc_json = NULL;
size_t sc_size = 0;
if (read_file_text(sidecar, &sc_json, &sc_size) != 0 || !sc_json)
continue;
/* Extract "source_name": "..." */
char sn_val[256] = "";
const char *sn_key = strstr(sc_json, "\"source_name\"");
if (sn_key) {
const char *colon = strchr(sn_key + 13, ':');
if (colon) {
const char *q = colon + 1;
while (*q == ' ' || *q == '\t') q++;
if (*q == '"') {
q++;
size_t pos = 0;
while (*q && *q != '"' && pos < sizeof(sn_val) - 1)
sn_val[pos++] = *q++;
sn_val[pos] = '\0';
}
}
}
/* Fallback for pre-source_name sidecars: if install_source is
* "repository" and install_source_detail has a URL, use its
* hostname so that old third-party installs still get a badge. */
if (!sn_val[0]) {
char i_src[64] = "";
const char *is_key = strstr(sc_json, "\"install_source\"");
if (is_key) {
const char *colon = strchr(is_key + 16, ':');
if (colon) {
const char *q = colon + 1;
while (*q == ' ' || *q == '\t') q++;
if (*q == '"') {
q++;
size_t pos = 0;
while (*q && *q != '"' && pos < sizeof(i_src) - 1)
i_src[pos++] = *q++;
i_src[pos] = '\0';
}
}
}
if (strcmp(i_src, "repository") == 0) {
char detail_url[512] = "";
const char *id_key = strstr(sc_json, "\"install_source_detail\"");
if (id_key) {
const char *colon = strchr(id_key + 23, ':');
if (colon) {
/* Skip "http://" or "https://" if next char is not '"' */
const char *q = colon + 1;
while (*q == ' ' || *q == '\t') q++;
if (*q == '"') {
q++;
size_t pos = 0;
while (*q && *q != '"' && pos < sizeof(detail_url) - 1)
detail_url[pos++] = *q++;
detail_url[pos] = '\0';
}
}
}
/* Only badge if it's NOT the official repository */
if (detail_url[0] &&
strstr(detail_url, "itsplk.github.io") == NULL) {
/* Extract hostname from URL */
const char *host = strstr(detail_url, "://");
host = host ? host + 3 : detail_url;
size_t pos = 0;
while (*host && *host != '/' && *host != ':' &&
pos < sizeof(sn_val) - 1)
sn_val[pos++] = *host++;
sn_val[pos] = '\0';
}
}
}
free(sc_json);
if (!sn_val[0]) continue; /* official source — no badge */
/* key = filename without trailing ".json" */
char key[300];
if (base_len >= sizeof(key)) base_len = sizeof(key) - 1;
memcpy(key, n, base_len);
key[base_len] = '\0';
char fn_esc[350], sn_esc[300];
pldmgr_json_escape(key, fn_esc, sizeof(fn_esc));
pldmgr_json_escape(sn_val, sn_esc, sizeof(sn_esc));
char entry[750];
snprintf(entry, sizeof(entry),
"%s\"%s\":{\"source_name\":\"%s\"}",
meta_first ? "" : ",", fn_esc, sn_esc);
if (json_append(&jb, entry) != 0) {
closedir(d);
closedir(top);
goto meta_done;
}
meta_first = 0;
}
closedir(d);
}
closedir(top);
}
meta_done:
json_append(&jb, "}}");
return jb.pos;
}
@@ -1522,3 +1672,639 @@ static int is_allowed_usb_path(const char *path) {
closedir(dir);
return -1;
}
/* =========================================================
* SOURCE MANAGEMENT
* ========================================================= */
typedef struct {
char id[64];
char name[256];
char url[1024];
int removable;
} SourceEntry;
/*
* Build the sources array from SOURCES_CONFIG_PATH.
* If the file is missing or corrupt, returns a default-only list.
*/
static int load_sources(SourceEntry *out, int *out_count) {
char *json = NULL;
size_t size = 0;
int count = 0;
memset(out, 0, sizeof(SourceEntry) * MAX_SOURCES);
/* Always start with the default source at index 0 */
strncpy(out[0].id, "default", sizeof(out[0].id) - 1);
strncpy(out[0].name, "Official Repository", sizeof(out[0].name) - 1);
strncpy(out[0].url, REPOSITORY_SOURCE_URL, sizeof(out[0].url) - 1);
out[0].removable = 0;
count = 1;
if (read_file_text(SOURCES_CONFIG_PATH, &json, &size) != 0 || !json || size == 0) {
if (json) free(json);
*out_count = count;
return 0;
}
/* Find "sources": [...] array */
const char *arr_start = strstr(json, "\"sources\"");
if (!arr_start) { free(json); *out_count = count; return 0; }
arr_start = strchr(arr_start, '[');
if (!arr_start) { free(json); *out_count = count; return 0; }
const char *p = arr_start + 1;
while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) {
const char *end = strchr(p, '}');
if (!end) break;
char id[64] = "", name[256] = "", url[1024] = "";
char removable_str[8] = "";
json_extract_string(p, end, "id", id, sizeof(id));
json_extract_string(p, end, "name", name, sizeof(name));
json_extract_string(p, end, "url", url, sizeof(url));
json_extract_string(p, end, "removable", removable_str, sizeof(removable_str));
/* Skip the default entry stored in file — we already added it */
if (strcmp(id, "default") == 0) { p = end + 1; continue; }
if (name[0] && url[0] && id[0]) {
strncpy(out[count].id, id, sizeof(out[count].id) - 1);
strncpy(out[count].name, name, sizeof(out[count].name) - 1);
strncpy(out[count].url, url, sizeof(out[count].url) - 1);
out[count].removable = (strcmp(removable_str, "false") != 0);
count++;
}
p = end + 1;
}
free(json);
*out_count = count;
return 0;
}
static int save_sources(SourceEntry *sources, int count) {
char buf[16384];
JsonListBuilder jb = { buf, sizeof(buf), 0, 1 };
ensure_dir_recursive(BASE_DATA_DIR);
json_append(&jb, "{\"sources\":[\n");
for (int i = 0; i < count; i++) {
char id_e[128], name_e[512], url_e[2048];
pldmgr_json_escape(sources[i].id, id_e, sizeof(id_e));
pldmgr_json_escape(sources[i].name, name_e, sizeof(name_e));
pldmgr_json_escape(sources[i].url, url_e, sizeof(url_e));
if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"removable\":%s}%s\n",
id_e, name_e, url_e,
sources[i].removable ? "true" : "false",
(i < count - 1) ? "," : "") != 0) {
break;
}
}
json_append(&jb, "]}\n");
return write_file_text(SOURCES_CONFIG_PATH, buf, jb.pos);
}
int payload_mgr_sources_list_json(char *buf, size_t size) {
SourceEntry sources[MAX_SOURCES] = {0};
int count = 0;
load_sources(sources, &count);
JsonListBuilder jb = { buf, size, 0, 1 };
buf[0] = '\0';
json_append(&jb, "{\"sources\":[\n");
for (int i = 0; i < count; i++) {
char id_e[128], name_e[512], url_e[2048];
pldmgr_json_escape(sources[i].id, id_e, sizeof(id_e));
pldmgr_json_escape(sources[i].name, name_e, sizeof(name_e));
pldmgr_json_escape(sources[i].url, url_e, sizeof(url_e));
if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"removable\":%s}%s\n",
id_e, name_e, url_e,
sources[i].removable ? "true" : "false",
(i < count - 1) ? "," : "") != 0) {
break;
}
}
json_append(&jb, "]}\n");
return 0;
}
int payload_mgr_sources_save(const char *json, size_t len) {
SourceEntry sources[MAX_SOURCES] = {0};
int count = 0;
if (!json || len == 0) return -1;
/* Always put default at slot 0 */
strncpy(sources[0].id, "default", sizeof(sources[0].id) - 1);
strncpy(sources[0].name, "Official Repository", sizeof(sources[0].name) - 1);
strncpy(sources[0].url, REPOSITORY_SOURCE_URL, sizeof(sources[0].url) - 1);
sources[0].removable = 0;
count = 1;
const char *arr_start = strstr(json, "\"sources\"");
if (!arr_start) return -1;
arr_start = strchr(arr_start, '[');
if (!arr_start) return -1;
const char *p = arr_start + 1;
while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) {
const char *end = strchr(p, '}');
if (!end) break;
char id[64] = "", name[256] = "", url[1024] = "";
json_extract_string(p, end, "id", id, sizeof(id));
json_extract_string(p, end, "name", name, sizeof(name));
json_extract_string(p, end, "url", url, sizeof(url));
if (strcmp(id, "default") == 0) { p = end + 1; continue; }
if (!name[0] || !url[0] || !id[0]) { p = end + 1; continue; }
strncpy(sources[count].id, id, sizeof(sources[count].id) - 1);
strncpy(sources[count].name, name, sizeof(sources[count].name) - 1);
strncpy(sources[count].url, url, sizeof(sources[count].url) - 1);
sources[count].removable = 1;
count++;
p = end + 1;
}
return save_sources(sources, count);
}
/*
* Download the JSON at url into a temp buffer, validate it has a "name" field
* and at least one payload, then append it to the sources list.
*/
int payload_mgr_sources_add(const char *url, char *msg_buf, size_t msg_size) {
char tmp_path[512];
char *json = NULL;
size_t json_size = 0;
RepoPayload *items = NULL;
size_t count = 0;
char source_name[256] = "";
if (!url || !url[0]) {
snprintf(msg_buf, msg_size, "URL is empty");
return -1;
}
ensure_dir_recursive(BASE_DATA_DIR);
snprintf(tmp_path, sizeof(tmp_path), "%s/source_validate.tmp", BASE_DATA_DIR);
if (download_to_file(url, tmp_path) != 0) {
snprintf(msg_buf, msg_size, "URL unreachable or download failed");
remove(tmp_path);
return -1;
}
if (read_file_text(tmp_path, &json, &json_size) != 0 || !json || json_size == 0) {
snprintf(msg_buf, msg_size, "Failed to read downloaded JSON");
remove(tmp_path);
if (json) free(json);
return -1;
}
remove(tmp_path);
/* Read top-level "name" field.
* Only search BEFORE the "payloads" key to avoid picking up individual
* payload items' own "name" fields.
* If there is no "payloads" key the JSON is a raw array — no top-level
* name is possible, so skip the search entirely. */
{
const char *payloads_key = strstr(json, "\"payloads\"");
if (payloads_key) {
/* Only scan the header section before "payloads" */
size_t search_len = (size_t)(payloads_key - json);
const char *name_key = NULL;
for (size_t i = 0; i + 6 <= search_len; i++) {
if (memcmp(json + i, "\"name\"", 6) == 0) {
name_key = json + i;
break;
}
}
if (name_key) {
const char *colon = strchr(name_key + 6, ':');
if (colon && (size_t)(colon - json) < search_len) {
const char *q = colon + 1;
while (*q == ' ' || *q == '\t') q++;
if (*q == '"') {
q++;
size_t pos = 0;
while (*q && *q != '"' && pos < sizeof(source_name) - 1)
source_name[pos++] = *q++;
source_name[pos] = '\0';
}
}
}
}
/* If still empty, fall back to the hostname portion of the URL */
if (!source_name[0]) {
/* Skip scheme: "http://" or "https://" */
const char *host = strstr(url, "://");
host = host ? host + 3 : url;
size_t pos = 0;
while (*host && *host != '/' && *host != ':' &&
pos < sizeof(source_name) - 1)
source_name[pos++] = *host++;
source_name[pos] = '\0';
}
}
/* The JSON may be in two formats:
* 1. { "name": "...", "payloads": [...] } <-- new format with name
* 2. { "payloads": [...] } <-- legacy format (array at root or under "payloads")
* parse_repository_payloads handles both since it just looks for '{' objects */
if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) {
free(json);
if (items) free(items);
snprintf(msg_buf, msg_size, "No valid payloads found in source JSON");
return -1;
}
free(items);
free(json);
/* Load current sources and append */
SourceEntry sources[MAX_SOURCES] = {0};
int src_count = 0;
load_sources(sources, &src_count);
if (src_count >= MAX_SOURCES) {
snprintf(msg_buf, msg_size, "Maximum number of sources (%d) reached", MAX_SOURCES);
return -1;
}
/* Check for duplicate URL */
for (int i = 0; i < src_count; i++) {
if (strcmp(sources[i].url, url) == 0) {
snprintf(msg_buf, msg_size, "Source already added");
return -1;
}
}
/* Assign a unique ID */
char new_id[64];
snprintf(new_id, sizeof(new_id), "source_%d", (int)time(NULL));
strncpy(sources[src_count].id, new_id, sizeof(sources[src_count].id) - 1);
strncpy(sources[src_count].name, source_name, sizeof(sources[src_count].name) - 1);
strncpy(sources[src_count].url, url, sizeof(sources[src_count].url) - 1);
sources[src_count].removable = 1;
src_count++;
if (save_sources(sources, src_count) != 0) {
snprintf(msg_buf, msg_size, "Failed to save sources");
return -1;
}
/* Return the source name in msg_buf for the caller to forward to the UI */
snprintf(msg_buf, msg_size, "%s", source_name);
return 0;
}
int payload_mgr_sources_remove(int index, char *msg_buf, size_t msg_size) {
if (index <= 0) {
snprintf(msg_buf, msg_size, "Cannot remove the default source");
return -1;
}
SourceEntry sources[MAX_SOURCES] = {0};
int count = 0;
load_sources(sources, &count);
if (index >= count) {
snprintf(msg_buf, msg_size, "Invalid source index");
return -1;
}
/* Shift entries down */
for (int i = index; i < count - 1; i++) {
sources[i] = sources[i + 1];
}
count--;
if (save_sources(sources, count) != 0) {
snprintf(msg_buf, msg_size, "Failed to save sources");
return -1;
}
snprintf(msg_buf, msg_size, "OK");
return 0;
}
/* =========================================================
* MULTI-SOURCE REPOSITORY
* ========================================================= */
/*
* Get the cache file path for a given source index.
* Index 0 (default) uses the original REPOSITORY_CACHE_PATH.
*/
static void get_source_cache_path(int index, char *out, size_t out_size) {
if (index == 0) {
strncpy(out, REPOSITORY_CACHE_PATH, out_size - 1);
out[out_size - 1] = '\0';
} else {
snprintf(out, out_size, "%s.src%d.json", REPOSITORY_CACHE_PATH, index);
}
}
/*
* Ensure the cache for a specific source URL is fresh.
* Returns 0 on success, -1 on failure.
*/
static int ensure_source_fresh(const char *url, const char *cache_path, int force_refresh) {
long last_update = 0;
time_t now = time(NULL);
char tmp_path[512];
char *json = NULL;
size_t json_size = 0;
RepoPayload *items = NULL;
size_t count = 0;
ensure_dir_recursive(BASE_DATA_DIR);
/* Read last update time from the cache file's companion .ts file */
char ts_path[512];
snprintf(ts_path, sizeof(ts_path), "%s.ts", cache_path);
FILE *tf = fopen(ts_path, "r");
if (tf) {
fscanf(tf, "%ld", &last_update);
fclose(tf);
}
if (!force_refresh && access(cache_path, F_OK) == 0) {
long delta = (long)now - last_update;
if (delta >= 0 && delta < REPOSITORY_REFRESH_INTERVAL_SEC) {
return 0;
}
}
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", cache_path);
if (download_to_file(url, tmp_path) != 0) {
return -1;
}
if (read_file_text(tmp_path, &json, &json_size) != 0 || json_size == 0) {
if (json) free(json);
remove(tmp_path);
return -1;
}
/* Validate: must have at least 1 payload */
if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) {
free(items);
free(json);
remove(tmp_path);
return -1;
}
free(items);
free(json);
if (rename(tmp_path, cache_path) != 0) {
remove(tmp_path);
return -1;
}
/* Write timestamp */
tf = fopen(ts_path, "w");
if (tf) {
fprintf(tf, "%ld", (long)now);
fclose(tf);
}
return 0;
}
/*
* Build JSON grouped by source:
* {
* "sources": [
* { "id": "default", "name": "Official Repository", "payload_count": 5,
* "last_update": 123456, "error": false,
* "payloads": [ {...}, ... ] },
* ...
* ]
* }
*/
size_t payload_mgr_multi_repository_list_json(char *buf, size_t size, int force_refresh) {
SourceEntry sources[MAX_SOURCES] = {0};
int src_count = 0;
load_sources(sources, &src_count);
JsonListBuilder jb = { buf, size, 0, 1 };
buf[0] = '\0';
json_append(&jb, "{\"sources\":[\n");
for (int si = 0; si < src_count; si++) {
char cache_path[512];
get_source_cache_path(si, cache_path, sizeof(cache_path));
int fetch_ok = (ensure_source_fresh(sources[si].url, cache_path, force_refresh) == 0);
char id_e[128], name_e[512];
pldmgr_json_escape(sources[si].id, id_e, sizeof(id_e));
pldmgr_json_escape(sources[si].name, name_e, sizeof(name_e));
/* Read last_update timestamp */
long last_update = 0;
char ts_path[512];
snprintf(ts_path, sizeof(ts_path), "%s.ts", cache_path);
FILE *tf = fopen(ts_path, "r");
if (tf) { fscanf(tf, "%ld", &last_update); fclose(tf); }
if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"last_update\":%ld,\"error\":%s,\"payloads\":[\n",
id_e, name_e, last_update, fetch_ok ? "false" : "true") != 0) {
break;
}
if (fetch_ok) {
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) {
parse_repository_payloads(cached, &items, &count);
free(cached);
}
for (size_t pi = 0; pi < count; pi++) {
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];
pldmgr_json_escape(items[pi].name, name_pe, sizeof(name_pe));
pldmgr_json_escape(items[pi].filename, filename_e, sizeof(filename_e));
pldmgr_json_escape(items[pi].description, desc_e, sizeof(desc_e));
pldmgr_json_escape(items[pi].version, ver_e, sizeof(ver_e));
pldmgr_json_escape(items[pi].url, url_e, sizeof(url_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));
if (json_append(&jb, " {\"name\":\"%s\",\"filename\":\"%s\",\"description\":\"%s\","
"\"version\":\"%s\",\"url\":\"%s\","
"\"source_id\":\"%s\",\"source_name\":\"%s\"}%s\n",
name_pe, filename_e, desc_e, ver_e, url_e,
src_id_e, src_name_e,
(pi < count - 1) ? "," : "") != 0) {
break;
}
}
if (items) free(items);
}
if (json_append(&jb, " ]}%s\n", (si < src_count - 1) ? "," : "") != 0) {
break;
}
}
json_append(&jb, "]}\n");
return jb.pos;
}
int payload_mgr_multi_repository_install(const char *filename, const char *source_id, const char *repo_url, char *msg, size_t msg_size) {
SourceEntry sources[MAX_SOURCES] = {0};
int src_count = 0;
load_sources(sources, &src_count);
/* Find the matching source */
int si = -1;
for (int i = 0; i < src_count; i++) {
if (strcmp(sources[i].id, source_id) == 0) { si = i; break; }
}
if (si < 0) {
/* Fall back to matching by URL */
for (int i = 0; i < src_count; i++) {
if (repo_url && strcmp(sources[i].url, repo_url) == 0) { si = i; break; }
}
}
if (si < 0) {
/* Last resort: use default */
si = 0;
}
char cache_path[512];
get_source_cache_path(si, cache_path, sizeof(cache_path));
/* Ensure cache is fresh */
if (access(cache_path, F_OK) != 0) {
if (ensure_source_fresh(sources[si].url, cache_path, 1) != 0) {
snprintf(msg, msg_size, "Failed to fetch source repository");
return -1;
}
}
/* Load cached payloads for this source */
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) {
snprintf(msg, msg_size, "Repository cache unavailable");
return -1;
}
parse_repository_payloads(cached, &items, &count);
free(cached);
/* Find the requested payload */
int found = -1;
for (size_t i = 0; i < count; i++) {
if (strcmp(items[i].filename, filename) == 0) { found = (int)i; break; }
}
if (found < 0) {
if (items) free(items);
snprintf(msg, msg_size, "Payload not found in source");
return -1;
}
/* Download */
ensure_dir_recursive(PAYLOADS_STORAGE_DIR);
char tmp_path[512];
snprintf(tmp_path, sizeof(tmp_path), "%s/%s.part", PAYLOADS_STORAGE_DIR, items[found].filename);
if (download_to_file(items[found].url, tmp_path) != 0) {
if (items) free(items);
remove(tmp_path);
snprintf(msg, msg_size, "Download failed");
return -1;
}
/* Commit — reuse existing logic with the source URL as the detail */
const char *detail = (repo_url && repo_url[0]) ? repo_url : sources[si].url;
/* Use install_commit directly with the loaded item data */
char folder_name[128];
char payload_dir[512];
char final_path[640];
char details_path[700];
pldmgr_utils_get_payload_folder_name(items[found].filename, folder_name, sizeof(folder_name));
snprintf(payload_dir, sizeof(payload_dir), "%s/%s", PAYLOADS_STORAGE_DIR, folder_name);
if (ensure_dir_recursive(payload_dir) != 0) {
if (items) free(items);
remove(tmp_path);
snprintf(msg, msg_size, "Failed to create payload directory");
return -1;
}
snprintf(final_path, sizeof(final_path), "%s/%s", payload_dir, items[found].filename);
snprintf(details_path, sizeof(details_path), "%s/%s.json", payload_dir, items[found].filename);
/* Checksum verification (optional — skip if checksum absent) */
if (strlen(items[found].checksum) == 64) {
char calculated[65];
if (compute_sha256_file(tmp_path, calculated) != 0) {
if (items) free(items);
remove(tmp_path);
snprintf(msg, msg_size, "Checksum computation failed");
return -1;
}
if (strcasecmp(calculated, items[found].checksum) != 0) {
pldmgr_log("[PLDMGR] !!! Checksum mismatch for %s\n", items[found].filename);
if (items) free(items);
remove(tmp_path);
snprintf(msg, msg_size, "Checksum mismatch");
return -1;
}
}
remove_regular_files_in_dir(payload_dir, items[found].filename);
if (rename(tmp_path, final_path) != 0) {
if (items) free(items);
remove(tmp_path);
snprintf(msg, msg_size, "Failed to finalize payload file");
return -1;
}
/* Attach source_id and source_name to the item so metadata captures them */
strncpy(items[found].source, sources[si].id, sizeof(items[found].source) - 1);
strncpy(items[found].source_direct, sources[si].name, sizeof(items[found].source_direct) - 1);
strncpy(items[found].source_name, sources[si].name, sizeof(items[found].source_name) - 1);
if (write_payload_details_json(&items[found], details_path, "repository", detail) != 0) {
if (items) free(items);
snprintf(msg, msg_size, "Failed to write payload metadata");
return -1;
}
pldmgr_log("[PLDMGR] Multi-source install complete: %s (source: %s)\n",
items[found].filename, sources[si].name);
snprintf(msg, msg_size, "Installed %s", items[found].filename);
if (items) free(items);
return 0;
}