From 217e8f2a4512db3cbd0156e3dd4f4ca00031eba1 Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Wed, 9 Sep 2026 11:48:23 -0400 Subject: [PATCH 1/3] Add a navbar icon for plugin updates, next to the FPP update icon Mirrors the existing #navbarUpdateAvail FPP-update icon: a puzzle-piece icon that shows only when at least one installed plugin has an update, and links to plugins.php?tab=updates. Backend: a new GET /api/plugin/updatesAvailable endpoint aggregates PluginHasUpdates() across every installed plugin. That check itself is cheap (git log against already-fetched remote-tracking refs, no network) but only as fresh as the last `git fetch` per plugin, so the endpoint keeps a small TTL cache (same convention as the existing GitHub-stats cache) and refreshes at most one stale plugin's remote per call -- enough to stay fresh without a page load ever paying for N serial git fetches. Frontend: plugins.php didn't have a URL deep-link into its Updates tab (only a sessionStorage-remembered "last tab"), so ?tab=updates is added to RestoreTopTab() as an explicit override that also persists, consistent with how a manual tab click already behaves. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NjuzBZvDeLNNaGX7j9mC8p --- www/api/controllers/plugin.php | 116 +++++++++++++++++++++++++++++++++ www/api/index.php | 1 + www/api/openapi.json | 24 +++++++ www/css/fpp.css | 10 +++ www/js/fpp.js | 38 +++++++++++ www/menu.inc | 6 ++ www/plugins.php | 10 +++ 7 files changed, 205 insertions(+) diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 79c0a0770..de2851fa6 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -2377,6 +2377,122 @@ function PluginHasUpdates($plugin) return 0; } +define('PLUGIN_UPDATES_CACHE_TTL', 6 * 60 * 60); // 6h shared per-box cache, same horizon as PLUGIN_GITHUB_STATS_TTL +define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per call to this endpoint -- see below + +function PluginUpdatesCacheFile() +{ + global $settings; + $base = isset($settings['mediaDirectory']) ? $settings['mediaDirectory'] : '/home/fpp/media'; + return $base . '/tmp/pluginUpdates.cache.json'; +} + +/** + * Aggregate check: does any installed plugin have an update available? + * + * Backed by a TTL cache (same shape/location convention as + * PluginGitHubStatsCacheFile()) so the navbar plugin-update icon can poll + * this on every page load for free almost always. + * + * PluginHasUpdates() itself is cheap (git log against already-fetched + * remote-tracking refs, no network) but only as fresh as the last `git + * fetch` for that plugin. This endpoint is what keeps those refs from + * going stale on their own: for any plugin whose cache entry is older than + * PLUGIN_UPDATES_CACHE_TTL, it runs one `git fetch` (the same network op + * CheckForPluginUpdates() does per-plugin) before recomputing the flag. + * + * Deliberately caps live refreshes to PLUGIN_UPDATES_MAX_REFRESH_PER_CALL + * per call -- unlike the GitHub stats cache (one HTTP call per miss), a + * stale plugin here costs a full `git fetch` subprocess, and every + * installed plugin can go stale at the same time (e.g. right after + * install, or after 6h idle). Refreshing all of them inline would make + * whichever page load hits that moment pay for every plugin's fetch + * serially. Capping to one spreads the refresh across subsequent polls + * instead, the same "quiet, one at a time" approach the Updates tab's own + * background pass already uses for this exact reason (see + * CheckPluginsForUpdates()'s comment above). + * + * @route GET /api/plugin/updatesAvailable + * @response 200 Aggregate update-available flag + * ```json + * {"updatesAvailable": true} + * ``` + */ +function GetPluginUpdatesAvailable() +{ + global $settings, $SUDO; + + $pluginDir = $settings['pluginDirectory']; + $plugins = array(); + if ($dh = @opendir($pluginDir)) { + while (($file = readdir($dh)) !== false) { + if ( + (!in_array($file, array('.', '..'))) && + (is_dir($pluginDir . '/' . $file)) && + (file_exists($pluginDir . '/' . $file . '/pluginInfo.json')) + ) { + array_push($plugins, $file); + } + } + closedir($dh); + } + + $cacheFile = PluginUpdatesCacheFile(); + $cache = array(); + if (file_exists($cacheFile)) { + $tmp = json_decode(@file_get_contents($cacheFile), true); + if (is_array($tmp)) $cache = $tmp; + } + + $now = time(); + $updatesAvailable = false; + $changed = false; + $refreshesLeft = PLUGIN_UPDATES_MAX_REFRESH_PER_CALL; + + foreach ($plugins as $plugin) { + $stale = !isset($cache[$plugin]) || !is_array($cache[$plugin]) || + !isset($cache[$plugin]['ts']) || (($now - (int)$cache[$plugin]['ts']) >= PLUGIN_UPDATES_CACHE_TTL); + + if ($stale && $refreshesLeft > 0) { + $refreshesLeft--; + $fetchCmd = '(cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' git fetch)'; + exec($fetchCmd); + $cache[$plugin] = array( + 'hasUpdate' => PluginHasUpdates($plugin) ? true : false, + 'ts' => $now, + ); + $changed = true; + } elseif (!isset($cache[$plugin]) || !is_array($cache[$plugin])) { + // Never checked and out of refresh budget this call -- read whatever + // the cheap (no-fetch) check already knows rather than reporting + // nothing for a brand-new plugin until its turn comes up. + $cache[$plugin] = array( + 'hasUpdate' => PluginHasUpdates($plugin) ? true : false, + 'ts' => 0, // force a real refresh on a future call + ); + $changed = true; + } + + if (!empty($cache[$plugin]['hasUpdate'])) { + $updatesAvailable = true; + } + } + + // Drop cache entries for plugins that are no longer installed. + foreach (array_keys($cache) as $repo) { + if (!in_array($repo, $plugins)) { + unset($cache[$repo]); + $changed = true; + } + } + + if ($changed) { + @file_put_contents($cacheFile, json_encode($cache)); + } + + return json(array('updatesAvailable' => $updatesAvailable)); +} + /** * Get setting from plugin * diff --git a/www/api/index.php b/www/api/index.php index a01e23854..5294b7f92 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -224,6 +224,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'); diff --git a/www/api/openapi.json b/www/api/openapi.json index ed0a21584..db9a74106 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -6787,6 +6787,30 @@ } } }, + "/api/plugin/updatesAvailable": { + "get": { + "tags": [ + "plugin" + ], + "summary": "Aggregate check: does any installed plugin have an update available?", + "description": "Backed by a TTL cache (same shape/location convention as PluginGitHubStatsCacheFile()) so the navbar plugin-update icon can poll this on every page load for free almost always. PluginHasUpdates() itself is cheap (git log against already-fetched remote-tracking refs, no network) but only as fresh as the last `git fetch` for that plugin. This endpoint is what keeps those refs from going stale on their own: for any plugin whose cache entry is older than PLUGIN_UPDATES_CACHE_TTL, it runs one `git fetch` (the same network op CheckForPluginUpdates() does per-plugin) before recomputing the flag. Deliberately caps live refreshes to PLUGIN_UPDATES_MAX_REFRESH_PER_CALL per call -- unlike the GitHub stats cache (one HTTP call per miss), a stale plugin here costs a full `git fetch` subprocess, and every installed plugin can go stale at the same time (e.g. right after install, or after 6h idle). Refreshing all of them inline would make whichever page load hits that moment pay for every plugin's fetch serially. Capping to one spreads the refresh across subsequent polls instead, the same \"quiet, one at a time\" approach the Updates tab's own background pass already uses for this exact reason (see CheckPluginsForUpdates()'s comment above).", + "responses": { + "200": { + "description": "Aggregate update-available flag", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "updatesAvailable": true + } + } + } + } + } + } + }, "/api/plugin/{RepoName}": { "parameters": [ { diff --git a/www/css/fpp.css b/www/css/fpp.css index 37f3ffe28..d9d2e2835 100644 --- a/www/css/fpp.css +++ b/www/css/fpp.css @@ -1082,6 +1082,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; } diff --git a/www/js/fpp.js b/www/js/fpp.js index 9e8860a9f..97fdfd0c0 100644 --- a/www/js/fpp.js +++ b/www/js/fpp.js @@ -130,6 +130,15 @@ var FPP_UPDATE_STATE = { checked: false }; +// 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 @@ -14582,6 +14591,35 @@ 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'); + }); +} + +/** + * 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 diff --git a/www/menu.inc b/www/menu.inc index 23cf61902..63ce8f715 100644 --- a/www/menu.inc +++ b/www/menu.inc @@ -88,6 +88,11 @@ function list_plugin_entries($menu) + @@ -510,4 +515,5 @@ function list_plugin_entries($menu) diff --git a/www/plugins.php b/www/plugins.php index ec6d22bc0..8b0f12a46 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -2152,6 +2152,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') From 1fbb2e17ddf4ce606c881dbff2ef49c9bc648691 Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Tue, 15 Sep 2026 14:36:15 -0400 Subject: [PATCH 2/3] fix: address plugin-update cache review findings on the navbar icon PR - Invalidate the shared pluginUpdates cache on the three events that actually change a plugin's state: UpgradePlugin() (clears the flag instead of leaving it lit for up to 6h), UninstallPlugin() (drops the entry so a reinstall under the same name starts clean), and InstallPluginFromInfo() (defensive drop of any stale entry a fresh install might inherit). - CheckForPluginUpdates() (the Updates tab's own on-demand check) now writes its result back into the same cache, so the navbar icon and the tab can't disagree. - Add the same non-blocking-lock + serve-stale pattern pluginHeaders.php already uses, plus write-then-rename cache writes, so concurrent page loads at a TTL boundary don't each spawn a git fetch of the same plugin and can't observe a torn cache file. - Gate the git fetch behind the same 1s connectivity probe get_remote_git_version() uses (common.php), and wrap it in `timeout 20` -- an offline box no longer holds a php-fpm worker on a hung fetch. A failed fetch now retries after 5 minutes instead of the full 6h TTL. - Correct the docblock: PluginHasUpdates() also runs the plugin's own scripts/fpp_update_check.sh, which may do its own network I/O -- it isn't strictly "no network". Moved the implementation-rationale comment out of the /** */ docblock (it was leaking verbatim, including an internal cross-reference, into the public openapi.json) and regenerated openapi.json against current master. - menu.inc: don't schedule the poll at all on a box with zero plugins installed. No help/plugins.php exists yet for this page (pre-existing gap, not introduced by this change) -- per .claude/HELP-PAGES.md's own carve-out, noting that here rather than authoring a whole new help page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AE12rwWGhsJsyfENBXpBs5 --- www/api/controllers/plugin.php | 181 ++++++++++++++++++++++++++------- www/api/openapi.json | 150 ++++++++++++++++++++++----- www/menu.inc | 16 +++ 3 files changed, 288 insertions(+), 59 deletions(-) diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index de2851fa6..466308b25 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -1020,6 +1020,12 @@ function InstallPluginFromInfo($pluginInfo, &$visited, $stream, $depth = 0) // 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 updates-cache + // entry (reinstalled outside UninstallPlugin's own drop, or a dependency + // chain) should not inherit an old hasUpdate flag. + UpdatePluginUpdatesCacheEntry($repoName, null); + return true; } @@ -1616,6 +1622,11 @@ function UninstallPlugin() } if ($return_val == 0) { + // Drop the cache entry outright, not just mark it not-updatable: a + // plugin reinstalled under this same name later should start clean + // rather than inherit whatever this one's flag happened to be. + UpdatePluginUpdatesCacheEntry($plugin, null); + if (isset($stream) && $stream != "false") { if (!$unloaded['ok']) { return "\nUninstalled, but fppd still has it loaded (" . $unloaded['message'] . ") - restart FPPD to finish.\nDone\n"; @@ -1664,6 +1675,12 @@ function CheckForPluginUpdates() $result['Status'] = 'OK'; $result['Message'] = ''; $result['updatesAvailable'] = PluginHasUpdates($plugin); + // Write this on-demand result back into the shared cache so the navbar + // plugin-update icon can't disagree with what this tab just found. + UpdatePluginUpdatesCacheEntry($plugin, array( + 'hasUpdate' => $result['updatesAvailable'] ? true : false, + 'ts' => time(), + )); } else { $result['Status'] = 'Error'; $result['Message'] = 'Could not run git fetch for plugin ' . $plugin; @@ -1709,6 +1726,11 @@ function UpgradePlugin() if (isset($stream) && $stream != "false") { DisableOutputBuffering(); system($cmd, $return_val); + if ($return_val == 0) { + // A just-upgraded plugin is current as of right now; don't leave the + // navbar icon lit on a pre-upgrade cache entry for up to 6h. + UpdatePluginUpdatesCacheEntry($plugin, array('hasUpdate' => false, 'ts' => time())); + } return "\nDone\n"; } exec($cmd, $output, $return_val); @@ -1716,6 +1738,7 @@ function UpgradePlugin() if ($return_val == 0) { $result['Status'] = 'OK'; $result['Message'] = ''; + UpdatePluginUpdatesCacheEntry($plugin, array('hasUpdate' => false, 'ts' => time())); } else { $result['Status'] = 'Error'; $result['Message'] = 'Could not run git pull for plugin ' . $plugin; @@ -2377,8 +2400,32 @@ function PluginHasUpdates($plugin) return 0; } +// TTL cache (same shape/location convention as PluginGitHubStatsCacheFile()) +// so the navbar plugin-update icon can poll GetPluginUpdatesAvailable() on +// every page load for free almost always. PluginHasUpdates() checks +// already-fetched remote-tracking refs (cheap, no network of its own) but +// also runs the plugin's optional scripts/fpp_update_check.sh, which may do +// its own network I/O -- so "cheap" only holds until a plugin opts into that +// script. Freshness otherwise comes from `git fetch`, gated behind the same +// 1s connectivity probe get_remote_git_version() uses (www/common.php), and +// capped at PLUGIN_UPDATES_MAX_REFRESH_PER_CALL live fetches per call so a +// page load never pays for every installed plugin's fetch serially -- the +// same "quiet, one at a time" approach CheckPluginsForUpdates() (the Updates +// tab's own background pass) already uses for this exact reason. A plugin +// whose most recent fetch failed (offline box, or a real network hiccup) +// gets PLUGIN_UPDATES_RETRY_TTL instead of the full TTL, so an outage costs +// minutes of stale icon state rather than hours. +// +// The cache is also updated directly -- not just by this endpoint's own TTL +// refresh -- by UpgradePlugin() (clears the flag on a successful upgrade), +// UninstallPlugin() (drops the entry so a later reinstall starts clean), and +// InstallPluginFromInfo() (drops any stale entry a freshly-installed plugin +// might inherit), plus CheckForPluginUpdates() (the Updates tab's own +// on-demand check) writes its result back here too, so the navbar icon and +// the Updates tab can't disagree. define('PLUGIN_UPDATES_CACHE_TTL', 6 * 60 * 60); // 6h shared per-box cache, same horizon as PLUGIN_GITHUB_STATS_TTL -define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per call to this endpoint -- see below +define('PLUGIN_UPDATES_RETRY_TTL', 5 * 60); // shorter TTL after a failed fetch attempt +define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per call to this endpoint function PluginUpdatesCacheFile() { @@ -2387,30 +2434,54 @@ function PluginUpdatesCacheFile() return $base . '/tmp/pluginUpdates.cache.json'; } +function ReadPluginUpdatesCache($cacheFile) +{ + if (!file_exists($cacheFile)) { + return array(); + } + $data = @file_get_contents($cacheFile); + if ($data === false) { + return array(); + } + $decoded = json_decode($data, true); + return is_array($decoded) ? $decoded : array(); +} + +function WritePluginUpdatesCache($cacheFile, $cache) +{ + // Write then rename so a concurrent reader never sees a half-written file. + $tmp = $cacheFile . '.' . getmypid() . '.tmp'; + if (@file_put_contents($tmp, json_encode($cache)) !== false) { + @rename($tmp, $cacheFile); + } +} + +// Locked read-modify-write against a single cache entry. Used by the +// install/upgrade/uninstall success paths and by CheckForPluginUpdates() so +// they never race GetPluginUpdatesAvailable()'s own refresh pass and never +// write a torn file out from under it. Pass $entry = null to drop the entry +// entirely (uninstall, or a fresh install clearing anything stale). +function UpdatePluginUpdatesCacheEntry($plugin, $entry) +{ + $cacheFile = PluginUpdatesCacheFile(); + $lock = @fopen($cacheFile . '.lock', 'c'); + if ($lock === false) { + return; // best-effort; a missed update just waits out the TTL + } + flock($lock, LOCK_EX); + $cache = ReadPluginUpdatesCache($cacheFile); + if ($entry === null) { + unset($cache[$plugin]); + } else { + $cache[$plugin] = $entry; + } + WritePluginUpdatesCache($cacheFile, $cache); + flock($lock, LOCK_UN); + fclose($lock); +} + /** - * Aggregate check: does any installed plugin have an update available? - * - * Backed by a TTL cache (same shape/location convention as - * PluginGitHubStatsCacheFile()) so the navbar plugin-update icon can poll - * this on every page load for free almost always. - * - * PluginHasUpdates() itself is cheap (git log against already-fetched - * remote-tracking refs, no network) but only as fresh as the last `git - * fetch` for that plugin. This endpoint is what keeps those refs from - * going stale on their own: for any plugin whose cache entry is older than - * PLUGIN_UPDATES_CACHE_TTL, it runs one `git fetch` (the same network op - * CheckForPluginUpdates() does per-plugin) before recomputing the flag. - * - * Deliberately caps live refreshes to PLUGIN_UPDATES_MAX_REFRESH_PER_CALL - * per call -- unlike the GitHub stats cache (one HTTP call per miss), a - * stale plugin here costs a full `git fetch` subprocess, and every - * installed plugin can go stale at the same time (e.g. right after - * install, or after 6h idle). Refreshing all of them inline would make - * whichever page load hits that moment pay for every plugin's fetch - * serially. Capping to one spreads the refresh across subsequent polls - * instead, the same "quiet, one at a time" approach the Updates tab's own - * background pass already uses for this exact reason (see - * CheckPluginsForUpdates()'s comment above). + * Do any installed plugins have an update available? * * @route GET /api/plugin/updatesAvailable * @response 200 Aggregate update-available flag @@ -2438,31 +2509,70 @@ function GetPluginUpdatesAvailable() } $cacheFile = PluginUpdatesCacheFile(); - $cache = array(); - if (file_exists($cacheFile)) { - $tmp = json_decode(@file_get_contents($cacheFile), true); - if (is_array($tmp)) $cache = $tmp; + + // Non-blocking: if another request (another open tab, another kiosk) is + // already refreshing, serve the existing cache as-is rather than queue up + // behind it holding a php-fpm worker, and rather than both spawning a + // `git fetch` for the same plugin at the same TTL boundary. Same pattern + // as GetPluginHeaderIndicators() in pluginHeaders.php. + $lock = @fopen($cacheFile . '.lock', 'c'); + if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + if ($lock !== false) { + fclose($lock); + } + $cache = ReadPluginUpdatesCache($cacheFile); + $updatesAvailable = false; + foreach ($plugins as $plugin) { + if (!empty($cache[$plugin]['hasUpdate'])) { + $updatesAvailable = true; + break; + } + } + return json(array('updatesAvailable' => $updatesAvailable)); } + $cache = ReadPluginUpdatesCache($cacheFile); + $now = time(); $updatesAvailable = false; $changed = false; $refreshesLeft = PLUGIN_UPDATES_MAX_REFRESH_PER_CALL; + $haveConnectivity = null; // computed at most once per call, only if actually needed foreach ($plugins as $plugin) { - $stale = !isset($cache[$plugin]) || !is_array($cache[$plugin]) || - !isset($cache[$plugin]['ts']) || (($now - (int)$cache[$plugin]['ts']) >= PLUGIN_UPDATES_CACHE_TTL); + $entry = (isset($cache[$plugin]) && is_array($cache[$plugin])) ? $cache[$plugin] : null; + $ttl = (!empty($entry['fetchFailed'])) ? PLUGIN_UPDATES_RETRY_TTL : PLUGIN_UPDATES_CACHE_TTL; + $stale = $entry === null || !isset($entry['ts']) || (($now - (int)$entry['ts']) >= $ttl); if ($stale && $refreshesLeft > 0) { $refreshesLeft--; - $fetchCmd = '(cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' git fetch)'; - exec($fetchCmd); + + if ($haveConnectivity === null) { + // Same 1s-ping-before-network-op gate get_remote_git_version() uses + // (www/common.php): an offline show box shouldn't hold a php-fpm + // worker on a DNS timeout for every plugin it tries to check. + exec('ping -q -c 1 -W 1 8.8.8.8 > /dev/null 2>&1', $pingOutput, $pingReturn); + unset($pingOutput); + $haveConnectivity = ($pingReturn == 0); + } + + if ($haveConnectivity) { + $fetchCmd = 'timeout 20 bash -c ' . escapeshellarg( + 'cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' git fetch' + ) . ' >/dev/null 2>&1'; + exec($fetchCmd, $fetchOutput, $fetchReturn); + unset($fetchOutput); + } else { + $fetchReturn = 1; + } + $cache[$plugin] = array( 'hasUpdate' => PluginHasUpdates($plugin) ? true : false, 'ts' => $now, + 'fetchFailed' => ($fetchReturn != 0), ); $changed = true; - } elseif (!isset($cache[$plugin]) || !is_array($cache[$plugin])) { + } elseif ($entry === null) { // Never checked and out of refresh budget this call -- read whatever // the cheap (no-fetch) check already knows rather than reporting // nothing for a brand-new plugin until its turn comes up. @@ -2487,9 +2597,12 @@ function GetPluginUpdatesAvailable() } if ($changed) { - @file_put_contents($cacheFile, json_encode($cache)); + WritePluginUpdatesCache($cacheFile, $cache); } + flock($lock, LOCK_UN); + fclose($lock); + return json(array('updatesAvailable' => $updatesAvailable)); } diff --git a/www/api/openapi.json b/www/api/openapi.json index e9d4c9678..7efcf0900 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -36,6 +36,9 @@ { "name": "configfile" }, + { + "name": "crashes" + }, { "name": "dir" }, @@ -102,6 +105,9 @@ { "name": "plugin" }, + { + "name": "privacy" + }, { "name": "proxies" }, @@ -117,6 +123,9 @@ { "name": "remotes" }, + { + "name": "rtspoutput" + }, { "name": "schedule" }, @@ -666,6 +675,37 @@ } } }, + "/api/cape/defaults": { + "get": { + "tags": [ + "cape" + ], + "summary": "Ask what the installed cape's defaultSettings WOULD do under a jurisdiction", + "description": "Cape detection runs from fppinit at boot, before anyone has been asked where they are, so a cape's telemetry defaults are held rather than applied. By the time the setup wizard has that answer, detection has already run. This lets the wizard re-ask the question without re-running detection for real -- which would write to the settings file mid-wizard and break the property the page depends on, that nothing is persisted until Finalize. Nothing is written. The evaluation calls the same predicate a real run calls, so the answer cannot drift from what would actually happen. A key the user has already set is absent from both lists; a key already holding exactly the proposed value is absent too, since there is nothing to offer and nothing to explain.", + "responses": { + "200": { + "description": "What the cape would set, and what it would be refused", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "jurisdiction": "US", + "priorOptIn": false, + "settings": { + "LEDDisplayType": "7" + }, + "refused": { + "statsPublish": "it transmits, and this jurisdiction requires prior opt-in" + } + } + } + } + } + } + } + }, "/api/cape/eeprom/sign/{key}/{order}": { "parameters": [ { @@ -1618,6 +1658,34 @@ } } }, + "/api/crashes/upload/": { + "post": { + "tags": [ + "crashes" + ], + "summary": "Upload one locally-kept crash report from the player itself.", + "description": "Deliberately does NOT delete on success. The caller deletes, through the existing file API, only after this reports the upload actually landed -- deleting the only copy of a crash report on an unverified \"probably sent\" is how the evidence disappears.", + "responses": { + "200": { + "description": "{\"Status\":\"OK\"|\"Error\", ...}" + } + } + } + }, + "/api/crashes/uploadTarget": { + "get": { + "tags": [ + "crashes" + ], + "summary": "crashes/uploadTarget", + "description": "Where a browser should post a report it fetched from this player.", + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/dir/{DirName}/{SubDir}": { "parameters": [ { @@ -3265,10 +3333,10 @@ "geoip" ], "summary": "GeoIP lookup", - "description": "Server-side proxy for ipapi.co's IP geolocation lookup, used by the Timezone/GeoLocation \"Lookup\"/\"Detect\" buttons on settings.php. ipapi.co does not send Access-Control-Allow-Origin, so the browser can't call it directly from FPP's UI (blocked by the Same Origin Policy) - PHP isn't subject to that, so we fetch it here and hand back the same JSON.", + "description": "Server-side proxy for ipapi.co's IP geolocation lookup, used by the Timezone/GeoLocation \"Lookup\"/\"Detect\" buttons. It is fetched here rather than from the browser so that only the PLAYER's address is disclosed, and only when somebody presses the button -- and so that no third-party host has to appear in the CSP, which is where the rest of the UI has deliberately ended up. (ipapi.co does send Access-Control-Allow-Origin: *, so a browser-side call is not blocked by CORS the way an older comment here claimed; connect-src is the only thing stopping it, and that is ours to decide rather than a technical obstacle.) The response also carries an `fpp` object naming the settings the lookup implies, so the mapping lives in one place instead of being reimplemented in the browser. Every one of them is a SEED: the buttons fill the fields in, the user sees the result and can change it, and nothing is saved until they finish setup. Any field the lookup cannot determine is simply absent, so the caller leaves that control alone rather than asserting a wrong answer. Note geoip resolves the PUBLIC IP: it says where the device reaches the internet, not where the device is. Fine for something shown and correctable, which is the only way it is used.", "responses": { "200": { - "description": "ipapi.co's JSON response, passed through unmodified", + "description": "ipapi.co's JSON response, plus the settings it implies", "content": { "application/json": { "schema": { @@ -3280,7 +3348,14 @@ "region": "South Australia", "timezone": "Australia/Adelaide", "latitude": -34.9, - "longitude": 138.6 + "longitude": 138.6, + "country_code": "AU", + "in_eu": false, + "fpp": { + "Locale": "Global", + "LegalJurisdiction": "AU", + "WifiRegulatoryDomain": "AU" + } } } } @@ -3335,35 +3410,21 @@ "git" ], "summary": "Get fork branches", - "description": "Returns branches from the authenticated user's fork of FPP (github.com//fpp) if that fork exists. Probed via GitHub API using saved gitHubUser/gitHubPAT; returns hasFork false when no credentials, no fork, or GitHub unreachable. Falls back to local git remote when offline. Used by Developer settings to offer a fork-branch switcher defaulting to Use Official Branches.", + "description": "Returns branches from the authenticated user's fork of FPP (github.com//fpp) if that fork exists. Used by the Developer settings page to offer a \"switch to fork branch\" dropdown. The fork is probed via the GitHub API using the saved gitHubUser / gitHubPAT credentials; an unreachable GitHub or a missing repo returns hasFork false rather than an error.", "responses": { "200": { "description": "Fork branch list", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "hasFork": { - "type": "boolean" - }, - "user": { - "type": "string" - }, - "branches": { - "type": "array", - "items": { - "type": "string" - } - } - } + "type": "object" }, "example": { "hasFork": true, - "user": "jessica12ryan", + "user": "myuser", "branches": [ "master", - "fixoverflow" + "my-feature" ] } } @@ -6837,8 +6898,8 @@ "tags": [ "plugin" ], - "summary": "Aggregate check: does any installed plugin have an update available?", - "description": "Backed by a TTL cache (same shape/location convention as PluginGitHubStatsCacheFile()) so the navbar plugin-update icon can poll this on every page load for free almost always. PluginHasUpdates() itself is cheap (git log against already-fetched remote-tracking refs, no network) but only as fresh as the last `git fetch` for that plugin. This endpoint is what keeps those refs from going stale on their own: for any plugin whose cache entry is older than PLUGIN_UPDATES_CACHE_TTL, it runs one `git fetch` (the same network op CheckForPluginUpdates() does per-plugin) before recomputing the flag. Deliberately caps live refreshes to PLUGIN_UPDATES_MAX_REFRESH_PER_CALL per call -- unlike the GitHub stats cache (one HTTP call per miss), a stale plugin here costs a full `git fetch` subprocess, and every installed plugin can go stale at the same time (e.g. right after install, or after 6h idle). Refreshing all of them inline would make whichever page load hits that moment pay for every plugin's fetch serially. Capping to one spreads the refresh across subsequent polls instead, the same \"quiet, one at a time\" approach the Updates tab's own background pass already uses for this exact reason (see CheckPluginsForUpdates()'s comment above).", + "summary": "plugin/updatesAvailable", + "description": "Do any installed plugins have an update available?", "responses": { "200": { "description": "Aggregate update-available flag", @@ -7135,6 +7196,30 @@ } } }, + "/api/privacy/consent": { + "get": { + "tags": [ + "privacy" + ], + "summary": "privacy/consent", + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "privacy" + ], + "summary": "privacy/consent", + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/proxies": { "delete": { "tags": [ @@ -7453,6 +7538,20 @@ } } }, + "/api/rtspoutput": { + "get": { + "tags": [ + "rtspoutput" + ], + "summary": "rtspoutput", + "description": "Get RTSP output status: whether the server is running, its port, and the URL and audio state of each configured mount.", + "responses": { + "200": { + "description": "RTSP output status." + } + } + } + }, "/api/schedule": { "get": { "tags": [ @@ -8708,7 +8807,8 @@ "localCommit": "ece480e86", "versionUnknown": false, "isEndOfLife": false, - "latestMajorVersion": 9 + "latestMajorVersion": 9, + "checked": true } } } @@ -9028,4 +9128,4 @@ } } } -} +} \ No newline at end of file diff --git a/www/menu.inc b/www/menu.inc index b898ebed7..d15aa74ae 100644 --- a/www/menu.inc +++ b/www/menu.inc @@ -536,5 +536,21 @@ function list_plugin_entries($menu) checkForFppUpdate(); } }, 500); + setTimeout(checkForPluginUpdates, 700); // staggered behind the FPP update check above + From 2983180cce573a934054cdbb759770a80ec94b0d Mon Sep 17 00:00:00 2001 From: Nick Scilingo Date: Mon, 21 Sep 2026 15:10:48 -0400 Subject: [PATCH 3/3] fix: address round-2 plugin-update cache review findings Per Daryl's second review on PR #2929: 1. Replace the bespoke per-plugin cache (its own lockfile, five call sites doing read-modify-write) with a single {updatesAvailable} flag per box via file_cache() - the same TTL-cache helper GetPluginList() already uses in this file. Mutation sites (install/uninstall/upgrade/on-demand check) just @unlink() the cache file instead of updating an entry, so there's nothing to keep in sync out of band and nothing to lock against. - Fixes: icon stayed lit after a partial upgrade (rc=2 - code pulled, only the post-script failed) because UpgradePlugin() only cleared the entry on rc 0. Both call sites now clear on rc != 1 (0 or 2 both mean the code itself is current). - Fixes: writers blocking behind the poll's own git-fetch, since mutation sites no longer take any lock at all. 2. Use InstalledPluginNames() instead of duplicating the installed-plugin directory scan. Small items from the same review: - git fetch now built the same way PluginFetchBranch() does (cd X && $SUDO timeout 20 git fetch), not a bash -c wrapper. - menu.inc: (bool) glob(...) instead of foreach-ing a result that can be false on a read error (PHP 8 warning). - RunUpgradePlugin() and UpdateAllFinish() both now re-poll the navbar icon immediately when they finish, instead of leaving it showing pre-upgrade state until the next full page load. Left alone (per round 1's already-given reasoning, which stood): the help-page item - www/help/plugins.php documents page-level controls, not global header chrome, matching the existing navbarUpdateAvail icon this one is modeled after. Co-Authored-By: Claude Sonnet 5 --- www/api/controllers/plugin.php | 278 ++++++++++----------------------- www/js/fpp.js | 11 ++ www/menu.inc | 14 +- www/plugins.php | 5 +- 4 files changed, 101 insertions(+), 207 deletions(-) diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index 0bc45fab6..afc0f62d6 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -1202,10 +1202,10 @@ function InstallPluginFromInfo($pluginInfo, &$visited, $stream, $depth = 0, $dep // inheriting anything from the plugin that pulled it in. RecordPluginInstallSource($repoName, $origSrcURL); - // A plugin installed under a name that still has a stale updates-cache - // entry (reinstalled outside UninstallPlugin's own drop, or a dependency - // chain) should not inherit an old hasUpdate flag. - UpdatePluginUpdatesCacheEntry($repoName, null); + // 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); @@ -1858,10 +1858,10 @@ function UninstallPlugin() } if ($return_val == 0) { - // Drop the cache entry outright, not just mark it not-updatable: a - // plugin reinstalled under this same name later should start clean - // rather than inherit whatever this one's flag happened to be. - UpdatePluginUpdatesCacheEntry($plugin, null); + // 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); @@ -1930,12 +1930,11 @@ function CheckForPluginUpdates() $result['Status'] = 'OK'; $result['Message'] = ''; $result['updatesAvailable'] = PluginHasUpdates($plugin); - // Write this on-demand result back into the shared cache so the navbar - // plugin-update icon can't disagree with what this tab just found. - UpdatePluginUpdatesCacheEntry($plugin, array( - 'hasUpdate' => $result['updatesAvailable'] ? true : false, - 'ts' => time(), - )); + // 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/ up to date, so // this reads the incoming declaration without a second fetch. // Not gated on updatesAvailable: no record is "changed" too. @@ -2137,11 +2136,16 @@ function UpgradePlugin() DisableOutputBuffering(); system($cmd, $return_val); if ($return_val == 0) { - // A just-upgraded plugin is current as of right now; don't leave the - // navbar icon lit on a pre-upgrade cache entry for up to 6h. - UpdatePluginUpdatesCacheEntry($plugin, array('hasUpdate' => false, 'ts' => time())); PluginReinstallPendingSync($plugin); // rebuilt on this OS } + // rc 0 or 2 both mean the code was actually pulled (2 = pulled, but + // the plugin's own post-pull script then failed) -- either way the + // plugin is current as of right now, so the aggregate cache needs to + // forget whatever it thought before this upgrade rather than leaving + // the navbar icon lit for up to 6h on a plugin that is not stale. + if ($return_val != 1) { + @unlink(PluginUpdatesCacheFile()); + } PluginRecordUpgradedPrivacy($plugin, $return_val, $changed, $pending, $stream); return "\nDone\n"; } @@ -2155,11 +2159,14 @@ 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'] = ''; - UpdatePluginUpdatesCacheEntry($plugin, array('hasUpdate' => false, 'ts' => time())); } else { $result['Status'] = 'Error'; $result['Message'] = ($return_val == 2) @@ -3309,84 +3316,38 @@ function PluginHasUpdates($plugin) return 0; } -// TTL cache (same shape/location convention as PluginGitHubStatsCacheFile()) -// so the navbar plugin-update icon can poll GetPluginUpdatesAvailable() on -// every page load for free almost always. PluginHasUpdates() checks -// already-fetched remote-tracking refs (cheap, no network of its own) but -// also runs the plugin's optional scripts/fpp_update_check.sh, which may do -// its own network I/O -- so "cheap" only holds until a plugin opts into that -// script. Freshness otherwise comes from `git fetch`, gated behind the same -// 1s connectivity probe get_remote_git_version() uses (www/common.php), and -// capped at PLUGIN_UPDATES_MAX_REFRESH_PER_CALL live fetches per call so a -// page load never pays for every installed plugin's fetch serially -- the -// same "quiet, one at a time" approach CheckPluginsForUpdates() (the Updates -// tab's own background pass) already uses for this exact reason. A plugin -// whose most recent fetch failed (offline box, or a real network hiccup) -// gets PLUGIN_UPDATES_RETRY_TTL instead of the full TTL, so an outage costs -// minutes of stale icon state rather than hours. +// 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. // -// The cache is also updated directly -- not just by this endpoint's own TTL -// refresh -- by UpgradePlugin() (clears the flag on a successful upgrade), -// UninstallPlugin() (drops the entry so a later reinstall starts clean), and -// InstallPluginFromInfo() (drops any stale entry a freshly-installed plugin -// might inherit), plus CheckForPluginUpdates() (the Updates tab's own -// on-demand check) writes its result back here too, so the navbar icon and -// the Updates tab can't disagree. -define('PLUGIN_UPDATES_CACHE_TTL', 6 * 60 * 60); // 6h shared per-box cache, same horizon as PLUGIN_GITHUB_STATS_TTL -define('PLUGIN_UPDATES_RETRY_TTL', 5 * 60); // shorter TTL after a failed fetch attempt -define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per call to this endpoint +// 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() { - global $settings; - $base = isset($settings['mediaDirectory']) ? $settings['mediaDirectory'] : '/home/fpp/media'; - return $base . '/tmp/pluginUpdates.cache.json'; -} - -function ReadPluginUpdatesCache($cacheFile) -{ - if (!file_exists($cacheFile)) { - return array(); - } - $data = @file_get_contents($cacheFile); - if ($data === false) { - return array(); - } - $decoded = json_decode($data, true); - return is_array($decoded) ? $decoded : array(); -} - -function WritePluginUpdatesCache($cacheFile, $cache) -{ - // Write then rename so a concurrent reader never sees a half-written file. - $tmp = $cacheFile . '.' . getmypid() . '.tmp'; - if (@file_put_contents($tmp, json_encode($cache)) !== false) { - @rename($tmp, $cacheFile); - } -} - -// Locked read-modify-write against a single cache entry. Used by the -// install/upgrade/uninstall success paths and by CheckForPluginUpdates() so -// they never race GetPluginUpdatesAvailable()'s own refresh pass and never -// write a torn file out from under it. Pass $entry = null to drop the entry -// entirely (uninstall, or a fresh install clearing anything stale). -function UpdatePluginUpdatesCacheEntry($plugin, $entry) -{ - $cacheFile = PluginUpdatesCacheFile(); - $lock = @fopen($cacheFile . '.lock', 'c'); - if ($lock === false) { - return; // best-effort; a missed update just waits out the TTL - } - flock($lock, LOCK_EX); - $cache = ReadPluginUpdatesCache($cacheFile); - if ($entry === null) { - unset($cache[$plugin]); - } else { - $cache[$plugin] = $entry; - } - WritePluginUpdatesCache($cacheFile, $cache); - flock($lock, LOCK_UN); - fclose($lock); + return '/tmp/cache_plugin_updates.cache'; } /** @@ -3400,118 +3361,41 @@ function UpdatePluginUpdatesCacheEntry($plugin, $entry) */ function GetPluginUpdatesAvailable() { - global $settings, $SUDO; + $json = file_cache('plugin_updates', function () { + global $settings, $SUDO; + $pluginDir = $settings['pluginDirectory']; + $plugins = InstalledPluginNames(); - $pluginDir = $settings['pluginDirectory']; - $plugins = array(); - if ($dh = @opendir($pluginDir)) { - while (($file = readdir($dh)) !== false) { - if ( - (!in_array($file, array('.', '..'))) && - (is_dir($pluginDir . '/' . $file)) && - (file_exists($pluginDir . '/' . $file . '/pluginInfo.json')) - ) { - array_push($plugins, $file); - } - } - closedir($dh); - } + $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; - $cacheFile = PluginUpdatesCacheFile(); - - // Non-blocking: if another request (another open tab, another kiosk) is - // already refreshing, serve the existing cache as-is rather than queue up - // behind it holding a php-fpm worker, and rather than both spawning a - // `git fetch` for the same plugin at the same TTL boundary. Same pattern - // as GetPluginHeaderIndicators() in pluginHeaders.php. - $lock = @fopen($cacheFile . '.lock', 'c'); - if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { - if ($lock !== false) { - fclose($lock); - } - $cache = ReadPluginUpdatesCache($cacheFile); $updatesAvailable = false; - foreach ($plugins as $plugin) { - if (!empty($cache[$plugin]['hasUpdate'])) { - $updatesAvailable = true; - break; - } - } - return json(array('updatesAvailable' => $updatesAvailable)); - } - - $cache = ReadPluginUpdatesCache($cacheFile); - - $now = time(); - $updatesAvailable = false; - $changed = false; - $refreshesLeft = PLUGIN_UPDATES_MAX_REFRESH_PER_CALL; - $haveConnectivity = null; // computed at most once per call, only if actually needed - - foreach ($plugins as $plugin) { - $entry = (isset($cache[$plugin]) && is_array($cache[$plugin])) ? $cache[$plugin] : null; - $ttl = (!empty($entry['fetchFailed'])) ? PLUGIN_UPDATES_RETRY_TTL : PLUGIN_UPDATES_CACHE_TTL; - $stale = $entry === null || !isset($entry['ts']) || (($now - (int)$entry['ts']) >= $ttl); - - if ($stale && $refreshesLeft > 0) { - $refreshesLeft--; - - if ($haveConnectivity === null) { - // Same 1s-ping-before-network-op gate get_remote_git_version() uses - // (www/common.php): an offline show box shouldn't hold a php-fpm - // worker on a DNS timeout for every plugin it tries to check. - exec('ping -q -c 1 -W 1 8.8.8.8 > /dev/null 2>&1', $pingOutput, $pingReturn); - unset($pingOutput); - $haveConnectivity = ($pingReturn == 0); + 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 ($haveConnectivity) { - $fetchCmd = 'timeout 20 bash -c ' . escapeshellarg( - 'cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' git fetch' - ) . ' >/dev/null 2>&1'; - exec($fetchCmd, $fetchOutput, $fetchReturn); - unset($fetchOutput); - } else { - $fetchReturn = 1; + if (PluginHasUpdates($plugin)) { + $updatesAvailable = true; } - - $cache[$plugin] = array( - 'hasUpdate' => PluginHasUpdates($plugin) ? true : false, - 'ts' => $now, - 'fetchFailed' => ($fetchReturn != 0), - ); - $changed = true; - } elseif ($entry === null) { - // Never checked and out of refresh budget this call -- read whatever - // the cheap (no-fetch) check already knows rather than reporting - // nothing for a brand-new plugin until its turn comes up. - $cache[$plugin] = array( - 'hasUpdate' => PluginHasUpdates($plugin) ? true : false, - 'ts' => 0, // force a real refresh on a future call - ); - $changed = true; } - if (!empty($cache[$plugin]['hasUpdate'])) { - $updatesAvailable = true; - } - } - - // Drop cache entries for plugins that are no longer installed. - foreach (array_keys($cache) as $repo) { - if (!in_array($repo, $plugins)) { - unset($cache[$repo]); - $changed = true; - } - } - - if ($changed) { - WritePluginUpdatesCache($cacheFile, $cache); - } - - flock($lock, LOCK_UN); - fclose($lock); + 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)); } diff --git a/www/js/fpp.js b/www/js/fpp.js index 375b48939..0faa6f285 100644 --- a/www/js/fpp.js +++ b/www/js/fpp.js @@ -14764,6 +14764,17 @@ function checkForPluginUpdates () { }); } +/** + * 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 */ diff --git a/www/menu.inc b/www/menu.inc index d6b636fa7..460c86492 100644 --- a/www/menu.inc +++ b/www/menu.inc @@ -518,14 +518,12 @@ function list_plugin_entries($menu) setTimeout(checkForPluginUpdates, 700); // staggered behind the FPP update check above diff --git a/www/plugins.php b/www/plugins.php index 84a32e51e..6baa4466a 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -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 }); } @@ -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'); } }