Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion www/api/controllers/plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,12 @@ function InstallPluginFromInfo($pluginInfo, &$visited, $stream, $depth = 0, $dep
// call, so each one is judged on where it actually came from rather than
// inheriting anything from the plugin that pulled it in.
RecordPluginInstallSource($repoName, $origSrcURL);

// A plugin installed under a name that still has a stale aggregate
// updates-cache (reinstalled outside UninstallPlugin's own drop, or a
// dependency chain) should not inherit an old hasUpdate flag.
@unlink(PluginUpdatesCacheFile());

// Freshly built on this OS: no longer waiting for a post-FPPOS reinstall.
PluginReinstallPendingSync($repoName);
return true;
Expand Down Expand Up @@ -1899,6 +1905,11 @@ function UninstallPlugin()


if ($return_val == 0) {
// Drop the aggregate cache outright, not just mark this plugin
// not-updatable: a plugin reinstalled under this same name later
// should start clean rather than inherit whatever was true before.
@unlink(PluginUpdatesCacheFile());

MarkPluginPrivacyUninstalled($plugin);
PluginReinstallPendingSync($plugin);
if (isset($stream) && $stream != "false") {
Expand Down Expand Up @@ -1966,6 +1977,11 @@ function CheckForPluginUpdates()
$result['Status'] = 'OK';
$result['Message'] = '';
$result['updatesAvailable'] = PluginHasUpdates($plugin);
// Drop the aggregate cache so the navbar icon's next check recomputes
// from scratch rather than disagreeing with what this on-demand check
// just found (it already fetched this plugin, so the recompute won't
// need to spend its own fetch budget on it).
@unlink(PluginUpdatesCacheFile());
// The fetch above has just brought origin/<branch> up to date, so
// this reads the incoming declaration without a second fetch.
// Not gated on updatesAvailable: no record is "changed" too.
Expand Down Expand Up @@ -2292,7 +2308,11 @@ function UpgradePlugin()
// not updated (pull and its reset fallback failed, or still behind
// origin), 2 = the code was updated but the plugin's own
// fpp_upgrade.sh / fpp_install.sh returned non-zero. The two need
// different next steps, so they get different messages.
// different next steps, so they get different messages -- but both mean
// the code itself is current, so both clear the aggregate cache.
if ($return_val != 1) {
@unlink(PluginUpdatesCacheFile());
}
if ($return_val == 0) {
$result['Status'] = 'OK';
$result['Message'] = '';
Expand Down Expand Up @@ -3445,6 +3465,89 @@ function PluginHasUpdates($plugin)
return 0;
}

// One {updatesAvailable} flag for the whole box, via file_cache() -- the
// same TTL-cache helper GetPluginList() already uses in this file -- rather
// than a bespoke per-plugin cache with its own lockfile. There is nothing to
// keep in sync out of band this way: UpgradePlugin(), UninstallPlugin(),
// InstallPluginFromInfo() and CheckForPluginUpdates() (the Updates tab's own
// on-demand check) just @unlink() PluginUpdatesCacheFile() on any change that
// could affect the answer, and the next call recomputes from scratch.
// file_cache() itself provides the TTL, the non-blocking single-flighted
// refresh (concurrent callers get the stale answer instead of each spawning
// their own recompute), and torn-write-safe concurrent reads.
//
// Recomputing walks every installed plugin (InstalledPluginNames()).
// PluginHasUpdates() itself is cheap -- it only reads already-fetched
// remote-tracking refs, no network of its own, though it may also run the
// plugin's optional scripts/fpp_update_check.sh, which can do anything.
// Freshness otherwise comes from a live `git fetch`, gated behind the same
// 1s connectivity probe get_remote_git_version() uses (an offline box
// shouldn't hold a php-fpm worker on a DNS timeout), and capped at
// PLUGIN_UPDATES_MAX_REFRESH_PER_CALL per recompute -- which, since recompute
// itself only happens once per TTL window box-wide (not once per page load),
// still means a box with many plugins doesn't pay for all of their fetches
// in the one request that happens to trigger it. Which plugin gets that
// budget rotates across successive TTL windows (keyed off the window index,
// not any stored state) so coverage spreads out over time instead of always
// re-fetching the same (e.g. alphabetically first) plugin forever.
define('PLUGIN_UPDATES_CACHE_TTL', 6 * 60 * 60); // 6h, same horizon as PLUGIN_GITHUB_STATS_TTL
define('PLUGIN_UPDATES_CACHE_GRACE', 5 * 60);
define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per recompute

function PluginUpdatesCacheFile()
{
return '/tmp/cache_plugin_updates.cache';
}

/**
* Do any installed plugins have an update available?
*
* @route GET /api/plugin/updatesAvailable
* @response 200 Aggregate update-available flag
* ```json
* {"updatesAvailable": true}
* ```
*/
function GetPluginUpdatesAvailable()
{
$json = file_cache('plugin_updates', function () {
global $settings, $SUDO;
$pluginDir = $settings['pluginDirectory'];
$plugins = InstalledPluginNames();

$refreshesLeft = PLUGIN_UPDATES_MAX_REFRESH_PER_CALL;
$haveConnectivity = null; // computed at most once per call, only if actually needed
$startIdx = count($plugins) ? intdiv(time(), PLUGIN_UPDATES_CACHE_TTL) % count($plugins) : 0;

$updatesAvailable = false;
foreach ($plugins as $i => $plugin) {
$dueForFetch = (($i - $startIdx + count($plugins)) % count($plugins)) < $refreshesLeft;
if ($dueForFetch) {
if ($haveConnectivity === null) {
// Same 1s-ping-before-network-op gate get_remote_git_version()
// uses (www/common.php).
exec('ping -q -c 1 -W 1 8.8.8.8 > /dev/null 2>&1', $pingOutput, $pingReturn);
unset($pingOutput);
$haveConnectivity = ($pingReturn == 0);
}
if ($haveConnectivity) {
exec('cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' timeout 20 git fetch >/dev/null 2>&1', $fetchOutput, $fetchReturn);
unset($fetchOutput);
}
}
if (PluginHasUpdates($plugin)) {
$updatesAvailable = true;
}
}

return json_encode(array('updatesAvailable' => $updatesAvailable));
}, PLUGIN_UPDATES_CACHE_TTL, PLUGIN_UPDATES_CACHE_GRACE);

$decoded = json_decode($json, true);
$updatesAvailable = is_array($decoded) && !empty($decoded['updatesAvailable']);
return json(array('updatesAvailable' => $updatesAvailable));
}

/**
* Get setting from plugin
*
Expand Down
1 change: 1 addition & 0 deletions www/api/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@
dispatch_get('/plugin/popularity', 'GetPluginPopularity'); // keep above /plugin/:RepoName
dispatch_get('/plugin/githubStats', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName
dispatch_get('/plugin/source', 'GetPluginSource'); // keep above /plugin/:RepoName
dispatch_get('/plugin/updatesAvailable', 'GetPluginUpdatesAvailable'); // keep above /plugin/:RepoName
dispatch_get('/plugin/:RepoName', 'GetPluginInfo');
dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon');
dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl');
Expand Down
24 changes: 24 additions & 0 deletions www/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6976,6 +6976,30 @@
}
}
},
"/api/plugin/updatesAvailable": {
"get": {
"tags": [
"plugin"
],
"summary": "plugin/updatesAvailable",
"description": "Do any installed plugins have an update available?",
"responses": {
"200": {
"description": "Aggregate update-available flag",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"updatesAvailable": true
}
}
}
}
}
}
},
"/api/plugin/{RepoName}": {
"parameters": [
{
Expand Down
10 changes: 10 additions & 0 deletions www/css/fpp.css
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,16 @@ body.modal-open #scrollTopButton {
font-size: 1.5em;
}

#navbarPluginUpdateAvail {
display: none;
color: red;
}

#navbarPluginUpdateAvail a {
color: rgb(245, 136, 34);
font-size: 1.5em;
}

#divLEDPanelMatrices a.nav-link.active {
color: blue;
}
Expand Down
49 changes: 49 additions & 0 deletions www/js/fpp.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ var FPP_UPDATE_STATE = {
var _fppUpdateCheckInFlight = false;
var FPP_UPDATE_CHECK_RETRY_MS = 5000;

// Global plugin-update state - used by the navbar plugin-update icon.
// Populated from api/plugin/updatesAvailable, which is itself cache-backed
// (see GetPluginUpdatesAvailable() server-side) so polling it here on every
// page load is cheap.
var FPP_PLUGIN_UPDATE_STATE = {
updatesAvailable: false,
checked: false
};

// Build "http://host" + path. IPv6 literals (contain ':') must be bracketed;
// IPv4 and hostnames never contain ':' so they pass through unchanged.
// No zone-id ("%eth0") handling on purpose: a link-local address can't be
Expand Down Expand Up @@ -14783,6 +14792,46 @@ function updateNavbarUpdateIndicator () {
}
}

/**
* Poll api/plugin/updatesAvailable for the navbar plugin-update icon. Cheap
* to call on every page load -- the endpoint is TTL-cached server-side (see
* GetPluginUpdatesAvailable()) and only occasionally pays for a real
* `git fetch`, never more than one per call.
*/
function checkForPluginUpdates () {
$.get('api/plugin/updatesAvailable')
.done(function (data) {
FPP_PLUGIN_UPDATE_STATE.updatesAvailable = !!(data && data.updatesAvailable);
FPP_PLUGIN_UPDATE_STATE.checked = true;
updateNavbarPluginUpdateIndicator();
})
.fail(function () {
console.log('Failed to check for plugin updates via API');
});
}

/**
* StreamURL doneCallback/errorCallback for a single-plugin upgrade: runs the
* normal ProgressDialogDone, then re-polls the navbar icon immediately
* rather than leaving it showing whatever was true before the upgrade until
* the next full page load.
*/
function PluginUpgradeStreamDone (id) {
ProgressDialogDone(id);
checkForPluginUpdates();
}

/**
* Update the navbar plugin-update indicator based on FPP_PLUGIN_UPDATE_STATE
*/
function updateNavbarPluginUpdateIndicator () {
if (FPP_PLUGIN_UPDATE_STATE.updatesAvailable) {
$('#navbarPluginUpdateAvail').show();
} else {
$('#navbarPluginUpdateAvail').hide();
}
}

/**
* Set by menuHead.inc from GPIOPlatformHasStablePinNumbers(): whether this platform's
* gpiochip/line numbering is fixed enough to put in front of a user. On the
Expand Down
20 changes: 20 additions & 0 deletions www/menu.inc
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ function list_plugin_entries($menu)
<i class="fa-solid fa-download" id="navbarUpdateAvailIcon"></i>
</a>
</div>
<div id="navbarPluginUpdateAvail">
&nbsp;<a href="plugins.php?tab=updates" role="button">
<i class="fa-solid fa-puzzle-piece" id="navbarPluginUpdateAvailIcon" title="Plugin updates available"></i>
</a>
</div>
</div>

</div>
Expand Down Expand Up @@ -516,4 +521,19 @@ function list_plugin_entries($menu)
checkForFppUpdate();
}
}, 500);
<?php
// Cheap existence check -- skip scheduling the poll at all on a box
// with no plugins installed, rather than hitting the endpoint every
// page load just to learn the aggregate is trivially false. Cast to
// bool rather than foreach-ing the result: glob() returns false (not
// an empty array) on a read error, which a foreach over it warns on
// under PHP 8.
$anyPluginsInstalled = isset($pluginDirectory) && is_dir($pluginDirectory)
&& (bool) glob($pluginDirectory . '/*/pluginInfo.json');
if ($anyPluginsInstalled) {
?>
setTimeout(checkForPluginUpdates, 700); // staggered behind the FPP update check above
<?php
}
?>
</script>
15 changes: 13 additions & 2 deletions www/plugins.php
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,7 @@ function UpdateAllFinish() {
$.jGrowl('All ' + ok + ' plugin(s) updated successfully', { themeState: 'success' });
FilterPlugins();
ProgressDialogDone('pluginsProgressPopupText');
checkForPluginUpdates(); // refresh the navbar icon now, not on next page load
});
}

Expand Down Expand Up @@ -1020,10 +1021,10 @@ function RunUpgradePlugin(plugin, ack) {
var url = 'api/plugin/' + plugin + '/upgrade?stream=true';
DisplayProgressDialog("pluginsProgressPopup", "Upgrade Plugin");
if (ack !== null) {
StreamURL(url, 'pluginsProgressPopupText', 'ProgressDialogDone', 'ProgressDialogDone',
StreamURL(url, 'pluginsProgressPopupText', 'PluginUpgradeStreamDone', 'PluginUpgradeStreamDone',
'POST', JSON.stringify(ack), 'application/json');
} else {
StreamURL(url, 'pluginsProgressPopupText', 'ProgressDialogDone', 'ProgressDialogDone');
StreamURL(url, 'pluginsProgressPopupText', 'PluginUpgradeStreamDone', 'PluginUpgradeStreamDone');
}
}

Expand Down Expand Up @@ -3024,6 +3025,16 @@ function ShowTopTab(name) {
// Re-select the tab the user was on before the last load. Called once the
// plugin data is in so the Updates tab can run its update check.
function RestoreTopTab() {
// Explicit deep link (e.g. plugins.php?tab=updates from the navbar
// plugin-update icon) wins over whatever tab the session was last
// left on, and becomes the new remembered tab going forward --
// ShowTopTab() persists it to sessionStorage same as a manual click.
var requested = new URLSearchParams(window.location.search).get('tab');
if (requested === 'installed' || requested === 'updates') {
ShowTopTab(requested);
return;
}

var saved = '';
try { saved = sessionStorage.getItem('pluginsTopTab') || ''; } catch (e) { }
if (saved === 'installed' || saved === 'updates')
Expand Down