diff --git a/frontend/mock-server.js b/frontend/mock-server.js index d07d18c..13b4764 100644 --- a/frontend/mock-server.js +++ b/frontend/mock-server.js @@ -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) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 222f2a6..43c843d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { ) : ( - payloads.filter(p => !isSystemPayload(p)).map((p, i) => ( + payloads.filter(p => !isSystemPayload(p)).map((p) => ( 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' && ( setView(v)} /> - )}{view === 'donate' && } + )} + + {view === 'sources' && ( + setView('settings')} + ip={ip} + addToast={addToast} + showConfirm={showConfirm} + /> + )} + + {view === 'donate' && } diff --git a/frontend/src/components/ui/PayloadButton.jsx b/frontend/src/components/ui/PayloadButton.jsx index 62f57a5..e82369f 100644 --- a/frontend/src/components/ui/PayloadButton.jsx +++ b/frontend/src/components/ui/PayloadButton.jsx @@ -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 ( diff --git a/frontend/src/components/views/ManageSourcesView.jsx b/frontend/src/components/views/ManageSourcesView.jsx new file mode 100644 index 0000000..1aab7b6 --- /dev/null +++ b/frontend/src/components/views/ManageSourcesView.jsx @@ -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 ( +
+
+ +

+ Manage Sources +

+
+ +
+
+ +
+ + {ip}:8084 + +

+ Open this address on your phone or PC to manage payload sources. +

+
+
+ ) + } + + /* ---- Desktop Mode: full CRUD ---- */ + return ( +
+ {/* Header */} +
+ +

+ Payload Sources +

+
+ {/* Sources list */} + {loading ? ( +
+ +
+ ) : ( +
+ {sources.map((src, idx) => ( +
+ {/* Priority index */} +
+ {idx + 1} +
+ + {/* Icon */} +
+ {src.removable + ? + : + } +
+ + {/* Name + URL */} +
+

{src.name}

+

{src.url}

+
+ + {/* Controls */} +
+ {src.removable && ( + <> + + + {/* Remove button */} + + + )} + {!src.removable && ( + + Default + + )} +
+
+ ))} +
+ )} + + {/* Add Source */} + {!showAddForm ? ( + + ) : ( +
+

Add a New Source

+

+ Paste the URL to a JSON file. The source name will be read automatically from the JSON. +

+
+ 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} + /> + + +
+ {addError && ( +
+ + {addError} +
+ )} +
+ )} +
+ ) +} + +export default ManageSourcesView diff --git a/frontend/src/components/views/SettingsView.jsx b/frontend/src/components/views/SettingsView.jsx index 6498bc5..cf4f7ae 100644 --- a/frontend/src/components/views/SettingsView.jsx +++ b/frontend/src/components/views/SettingsView.jsx @@ -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 }) => (
@@ -138,6 +140,53 @@ const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, se
+ {/* Multi-Source */} +
+

+ + Payload Sources +

+ +
+ + + + + {multiSources && ( + + )} +
+
+ {/* Diagnostics */}

@@ -162,6 +211,7 @@ const SettingsView = ({ config, onSaveConfig, isPS5, logs, setLogs, showLogs, se

+ ) } diff --git a/frontend/src/components/views/StorageHub.jsx b/frontend/src/components/views/StorageHub.jsx index a5da5dc..6db3572 100644 --- a/frontend/src/components/views/StorageHub.jsx +++ b/frontend/src/components/views/StorageHub.jsx @@ -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 (
@@ -122,12 +141,17 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,

Library Empty

) : ( - 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 (
@@ -136,12 +160,21 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
+ {/* Source badge — floats in bottom-right, doesn't expand the row */} + {sourceBadge && ( +
+ + + {sourceBadge} + +
+ )}
{remoteMatch?.isUpdate && (
- {lastUpdate > 0 && ( -

- Last Sync: {new Date(lastUpdate * 1000).toLocaleString()} -

- )} - {loading && remotePayloads.length === 0 ? ( + {loading && !repoData ? (

Syncing with Repository...

@@ -194,39 +222,131 @@ const StorageHub = ({ payloads, onInstall, onDelete, onUpload, onImportFromUsb,
- ) : ( -
- {cloudItems.length === 0 ? ( -
-

Repository Up to Date

-
- ) : ( - cloudItems.map((p, i) => ( -
-
-
- -
-

{p.description}

-
- + ) : multiSources && enrichedSources.length > 0 ? ( + /* ===== MULTI-SOURCE: accordion catalogs ===== */ +
+ {enrichedSources.map(src => { + const availablePayloads = src.payloads.filter(p => !p.isInstalled || p.isUpdate) + const isExpanded = expandedSource === src.id + return ( +
+ {/* Catalog header */} + + {/* Catalog payload list */} + {isExpanded && ( +
+ {availablePayloads.length === 0 ? ( +
+

All payloads installed

+
+ ) : ( + availablePayloads.map(p => ( +
+
+ + {p.description && ( +

{p.description}

+ )} +
+ +
+ )) + )} +
+ )}
- )) + ) + })} +
+ ) : ( + /* ===== SINGLE-SOURCE: flat list (legacy / multi-source disabled) ===== */ +
+ {legacyLastUpdate > 0 && ( +

+ Last Sync: {new Date(legacyLastUpdate * 1000).toLocaleString()} +

)} + {(() => { + const cloudItems = remotePayloads.filter(p => !p.isInstalled || p.isUpdate) + return cloudItems.length === 0 ? ( +
+

Repository Up to Date

+
+ ) : ( + cloudItems.map(p => ( +
+
+
+ +
+

{p.description}

+
+ +
+ )) + ) + })()}
)} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 231e7a1..fa25a21 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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 } } } }) diff --git a/include/payload_mgr.h b/include/payload_mgr.h index a0940b5..4732a1d 100644 --- a/include/payload_mgr.h +++ b/include/payload_mgr.h @@ -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 diff --git a/include/pldmgr.h b/include/pldmgr.h index 08d4dcc..371f76f 100644 --- a/include/pldmgr.h +++ b/include/pldmgr.h @@ -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[] = { diff --git a/src/main.c b/src/main.c index a66a7ee..ee79a16 100644 --- a/src/main.c +++ b/src/main.c @@ -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) { diff --git a/src/payload_mgr.c b/src/payload_mgr.c index 8ff7416..7b2ff57 100644 --- a/src/payload_mgr.c +++ b/src/payload_mgr.c @@ -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//*.elf.json sidecars. + * Each sidecar is named .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; +} +