Skip to content

Commit 25fa492

Browse files
committed
User installed OS packages part 1
1 parent cc72fae commit 25fa492

File tree

4 files changed

+372
-1
lines changed

4 files changed

+372
-1
lines changed

www/api/controllers/system.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,79 @@ function finalizeStatusJson($obj)
399399

400400
return $obj;
401401
}
402+
403+
// GET /api/system/GetOSPackages
404+
/**
405+
* Get a list of all available packages on the system.
406+
*
407+
* @return array List of package names.
408+
*/
409+
function GetOSPackages() {
410+
$packages = [];
411+
$cmd = 'apt list --all-versions 2>&1'; // Fetch all package names and versions
412+
$handle = popen($cmd, 'r'); // Open a process for reading the output
413+
414+
if ($handle) {
415+
while (($line = fgets($handle)) !== false) {
416+
// Extract the package name before the slash
417+
if (preg_match('/^([^\s\/]+)\//', $line, $matches)) {
418+
$packages[] = $matches[1];
419+
}
420+
}
421+
pclose($handle); // Close the process
422+
} else {
423+
error_log("Error: Unable to fetch package list.");
424+
}
425+
426+
return json_encode($packages);
427+
}
428+
/**
429+
* Get information about a specific package.
430+
*
431+
* This function retrieves the description, dependencies, and installation status for a given package.
432+
*
433+
* @param string $packageName The name of the package.
434+
* @return array An associative array containing 'Description', 'Depends', and 'Installed'.
435+
*/
436+
function GetOSPackageInfo() {
437+
$packageName = params('packageName');
438+
439+
// Fetch package information using apt-cache show
440+
$output = shell_exec("apt-cache show " . escapeshellarg($packageName) . " 2>&1");
441+
442+
if (!$output) {
443+
return ['error' => "Package '$packageName' not found or no information available."];
444+
}
445+
446+
// Check installation status using dpkg-query
447+
$installStatus = shell_exec("/usr/bin/dpkg-query -W -f='\${Status}\n' " . escapeshellarg($packageName) . " 2>&1");
448+
error_log("Raw dpkg-query output for $packageName: |" . $installStatus . "|");
449+
450+
// Trim and validate output
451+
$trimmedStatus = trim($installStatus);
452+
error_log("Trimmed dpkg-query output for $packageName: |" . $trimmedStatus . "|");
453+
454+
$isInstalled = ($trimmedStatus === 'install ok installed') ? 'Yes' : 'No';
455+
456+
// Parse apt-cache output
457+
$lines = explode("\n", $output);
458+
$description = '';
459+
$depends = '';
460+
461+
foreach ($lines as $line) {
462+
if (strpos($line, 'Description:') === 0) {
463+
$description = trim(substr($line, strlen('Description:')));
464+
} elseif (strpos($line, 'Depends:') === 0) {
465+
$depends = trim(substr($line, strlen('Depends:')));
466+
}
467+
}
468+
469+
return json_encode([
470+
'Description' => $description,
471+
'Depends' => $depends,
472+
'Installed' => $isInstalled
473+
]);
474+
}
475+
476+
477+

www/api/index.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@
178178
dispatch_post('/system/volume', 'SystemSetAudio');
179179
dispatch_post('/system/proxies', 'PostProxies');
180180
dispatch_get('/system/proxies', 'GetProxies');
181+
dispatch_get('/system/packages', 'GetOSpackages');
182+
dispatch_get('/system/packages/info/:packageName', 'GetOSpackageInfo');
181183

182184
dispatch_get('/testmode', 'testMode_Get');
183185
dispatch_post('/testmode', 'testMode_Set');

www/menu.inc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,9 @@ function list_plugin_entries($menu)
351351
Browser</a>
352352
<a class="dropdown-item" href="plugins.php"><i class="fas fa-puzzle-piece"></i> Plugin
353353
Manager</a>
354+
<? if ($uiLevel >= 2) { ?>
355+
<a class="dropdown-item" href="packages.php"><i class="fas fa-box"></i> Packages </a>
356+
<? } ?>
354357
<?php list_plugin_entries("content"); ?>
355358
</div>
356359
</li>
@@ -445,4 +448,4 @@ function list_plugin_entries($menu)
445448
</nav>
446449

447450

448-
</div>
451+
</div>

www/packages.php

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
<?php
2+
if (isset($_REQUEST['action'])) {
3+
$skipJSsettings = 1;
4+
}
5+
require_once('config.php');
6+
require_once('common.php');
7+
DisableOutputBuffering();
8+
9+
$userPackagesFile = $settings['configDirectory'] . '/userpackages.json';
10+
$userPackages = [];
11+
if (file_exists($userPackagesFile)) {
12+
$userPackages = json_decode(file_get_contents($userPackagesFile), true);
13+
if (!is_array($userPackages)) {
14+
$userPackages = [];
15+
}
16+
}
17+
18+
// Handle backend actions
19+
$action = $_POST['action'] ?? $_GET['action'] ?? null;
20+
$packageName = $_POST['package'] ?? $_GET['package'] ?? null;
21+
22+
if ($action) {
23+
if ($action === 'install' && !empty($packageName)) {
24+
$packageName = escapeshellarg($packageName);
25+
header('Content-Type: text/plain');
26+
27+
$process = popen("sudo apt-get install -y $packageName 2>&1", 'r');
28+
if (is_resource($process)) {
29+
while (!feof($process)) {
30+
echo fread($process, 1024);
31+
flush();
32+
}
33+
pclose($process);
34+
}
35+
36+
// Add package to user-installed packages if not already added
37+
if (!in_array(trim($packageName, "'"), $userPackages)) {
38+
$userPackages[] = trim($packageName, "'");
39+
file_put_contents($userPackagesFile, json_encode($userPackages, JSON_PRETTY_PRINT));
40+
}
41+
42+
exit;
43+
}
44+
45+
if ($action === 'uninstall' && !empty($packageName)) {
46+
$packageName = escapeshellarg($packageName);
47+
header('Content-Type: text/plain');
48+
49+
$process = popen("sudo apt-get remove -y $packageName 2>&1", 'r');
50+
if (is_resource($process)) {
51+
while (!feof($process)) {
52+
echo fread($process, 1024);
53+
flush();
54+
}
55+
pclose($process);
56+
}
57+
58+
// Remove package from user-installed packages
59+
$userPackages = array_filter($userPackages, function($pkg) use ($packageName) {
60+
return $pkg !== trim($packageName, "'");
61+
});
62+
file_put_contents($userPackagesFile, json_encode($userPackages, JSON_PRETTY_PRINT));
63+
64+
exit;
65+
}
66+
}
67+
include 'common/menuHead.inc';
68+
writeFPPVersionJavascriptFunctions();
69+
?>
70+
<style>
71+
.taller-modal .modal-dialog {
72+
max-height: 90%;
73+
height: 90%;
74+
overflow-y: auto;
75+
}
76+
77+
.taller-modal .modal-body {
78+
max-height: calc(100% - 120px);
79+
overflow-y: auto;
80+
}
81+
</style>
82+
<script>
83+
var systemPackages = [];
84+
var userInstalledPackages = <?php echo json_encode($userPackages); ?>;
85+
var selectedPackageName = "";
86+
87+
function ShowLoadingIndicator() {
88+
$('#loadingIndicator').show();
89+
$('#packageInputContainer').hide();
90+
}
91+
92+
function HideLoadingIndicator() {
93+
$('#loadingIndicator').hide();
94+
$('#packageInputContainer').show();
95+
}
96+
97+
function GetSystemPackages() {
98+
ShowLoadingIndicator();
99+
$.ajax({
100+
url: '/api/system/packages',
101+
type: 'GET',
102+
dataType: 'json',
103+
success: function (data) {
104+
if (!data || !Array.isArray(data)) {
105+
console.error('Invalid data received from server.', data);
106+
alert('Error: Unable to retrieve package list.');
107+
return;
108+
}
109+
console.log('Raw Data Received:', data);
110+
systemPackages = data;
111+
console.log('Parsed Packages:', systemPackages);
112+
InitializeAutocomplete();
113+
HideLoadingIndicator();
114+
},
115+
error: function () {
116+
alert('Error, failed to get system packages list.');
117+
HideLoadingIndicator();
118+
}
119+
});
120+
}
121+
122+
function GetPackageInfo(packageName) {
123+
selectedPackageName = packageName;
124+
$.ajax({
125+
url: `/api/system/packages/info/${encodeURIComponent(packageName)}`,
126+
type: 'GET',
127+
dataType: 'json',
128+
success: function (data) {
129+
if (data.error) {
130+
alert(`Error: ${data.error}`);
131+
return;
132+
}
133+
const description = data.Description || 'No description available.';
134+
const dependencies = data.Depends
135+
? data.Depends.replace(/\([^)]*\)/g, '').trim()
136+
: 'No dependencies.';
137+
const installed = data.Installed === "Yes" ? "(Already Installed)" : "";
138+
$('#packageInfo').html(`
139+
<strong>Selected Package:</strong> ${packageName} ${installed}<br>
140+
${data.Installed !== "Yes" ?`
141+
<strong>Description:</strong> ${description}<br>
142+
<strong>Will also install these packages:</strong> ${dependencies}<br>
143+
<div class="buttons btn-lg btn-rounded btn-outline-success mt-2" onClick='InstallPackage();'>
144+
<i class="fas fa-download"></i> Install Package
145+
</div>` : ""}
146+
`);
147+
},
148+
error: function () {
149+
alert('Error, failed to fetch package information.');
150+
}
151+
});
152+
}
153+
154+
function InitializeAutocomplete() {
155+
if (!systemPackages.length) {
156+
console.warn('System packages list is empty.');
157+
return;
158+
}
159+
160+
$("#packageInput").autocomplete({
161+
source: systemPackages,
162+
select: function (event, ui) {
163+
const selectedPackage = ui.item.value;
164+
$(this).val(selectedPackage);
165+
return false;
166+
}
167+
});
168+
}
169+
170+
function InstallPackage() {
171+
if (!selectedPackageName) {
172+
alert('Please select a package and retrieve its info before installing.');
173+
return;
174+
}
175+
176+
const url = `packages.php?action=install&package=${encodeURIComponent(selectedPackageName)}`;
177+
$('#packageProgressPopupCloseButton').text('Please Wait').prop("disabled", true);
178+
DisplayProgressDialog("packageProgressPopup", `Installing Package: ${selectedPackageName}`);
179+
StreamURL(
180+
url,
181+
'packageProgressPopupText',
182+
'EnableCloseButtonAfterOperation',
183+
'EnableCloseButtonAfterOperation'
184+
);
185+
}
186+
187+
function UninstallPackage(packageName) {
188+
const url = `packages.php?action=uninstall&package=${encodeURIComponent(packageName)}`;
189+
$('#packageProgressPopupCloseButton').text('Please Wait').prop("disabled", true);
190+
DisplayProgressDialog("packageProgressPopup", `Uninstalling Package: ${packageName}`);
191+
StreamURL(
192+
url,
193+
'packageProgressPopupText',
194+
'EnableCloseButtonAfterOperation',
195+
'EnableCloseButtonAfterOperation'
196+
);
197+
}
198+
199+
function EnableCloseButtonAfterOperation(id) {
200+
$('#packageProgressPopupCloseButton')
201+
.text('Close')
202+
.prop("disabled", false)
203+
.on('click', function () {
204+
$('#packageInput').val(''); // Clear the input field
205+
location.reload(); // Refresh the page when the close button is clicked
206+
});
207+
}
208+
209+
$(document).ready(function () {
210+
GetSystemPackages();
211+
212+
const userPackagesList = userInstalledPackages.map(pkg => `<li>${pkg} <button class='btn btn-sm btn-outline-danger' onClick='UninstallPackage("${pkg}");'>Uninstall</button></li>`).join('');
213+
$('#userPackagesList').html(userPackagesList || '<p>No user-installed packages found.</p>');
214+
215+
$('#packageInput').on('input', function () {
216+
const packageName = $(this).val().trim();
217+
if (packageName) {
218+
$('#packageStatus').text('');
219+
}
220+
});
221+
});
222+
</script>
223+
<title>Package Manager</title>
224+
</head>
225+
226+
<body>
227+
<div id="bodyWrapper">
228+
<?php
229+
$activeParentMenuItem = 'content';
230+
include 'menu.inc'; ?>
231+
<div class="mainContainer">
232+
<h1 class="title">Package Manager</h1>
233+
<div class="pageContent">
234+
<div id="packages" class="settings">
235+
236+
237+
<h2>Please Note:</h2>
238+
Installing additional packages can break your FPP installation requiring complete reinstallation of FPP. Continue at your own risk.
239+
<p>
240+
<h2>Installed User Packages</h2>
241+
<ul id="userPackagesList"></ul>
242+
243+
<div id="loadingIndicator" style="display: none; text-align: center;">
244+
<p>Loading package list, please wait...</p>
245+
</div>
246+
247+
<div id="packageInputContainer" style="display: none;">
248+
<div class="row">
249+
<div class="col">
250+
<input type="text" id="packageInput" class="form-control form-control-lg form-control-rounded has-shadow" placeholder="Enter package name" />
251+
</div>
252+
<div class="col-auto">
253+
<div class="buttons btn-lg btn-rounded btn-outline-info" onClick='GetPackageInfo($("#packageInput").val().trim());'>
254+
<i class="fas fa-info-circle"></i> Get Info
255+
</div>
256+
</div>
257+
</div>
258+
</div>
259+
260+
<div class='packageDiv'>
261+
<div id="packageInfo" class="mt-3 text-muted"></div>
262+
<div id="overlay"></div>
263+
</div>
264+
</div>
265+
266+
<div id="packageProgressPopup" class="modal taller-modal" tabindex="-1">
267+
<div class="modal-dialog modal-lg">
268+
<div class="modal-content">
269+
<div class="modal-header">
270+
<h5 class="modal-title">Installing Package</h5>
271+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
272+
</div>
273+
<div class="modal-body">
274+
<pre id="packageProgressPopupText" style="white-space: pre-wrap;"></pre>
275+
</div>
276+
<div class="modal-footer">
277+
<button id="packageProgressPopupCloseButton" type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
278+
</div>
279+
</div>
280+
</div>
281+
</div>
282+
283+
</div>
284+
</div>
285+
<?php include 'common/footer.inc'; ?>
286+
</div>
287+
</body>
288+
289+
</html>
290+

0 commit comments

Comments
 (0)