diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php
index 54d2f373e..9bb6c99ba 100644
--- a/www/api/controllers/plugin.php
+++ b/www/api/controllers/plugin.php
@@ -3186,6 +3186,251 @@ function GetPluginGitHubStats()
return json(array('repos' => $result, 'source' => $source));
}
+define('PLUGIN_RELEASE_NOTES_CACHE_TTL', 6 * 60 * 60); // 6h, same horizon as PLUGIN_GITHUB_STATS_TTL
+
+function PluginReleaseNotesCacheFile()
+{
+ global $settings;
+ $base = isset($settings['mediaDirectory']) ? $settings['mediaDirectory'] : '/home/fpp/media';
+ return $base . '/tmp/pluginReleaseNotes.cache.json';
+}
+
+// Same idea as GitHubRepoOf() in plugins.php's JS, but server-side: parse an
+// installed plugin's own srcURL into "owner/repo" for the GitHub API. Kept
+// server-side (rather than trusting a client-supplied repo param, the old
+// design) so GetPluginReleaseNotes() only ever talks to the repo its own
+// installed pluginInfo.json actually points at.
+function PluginGitHubRepoOf($pluginInfo)
+{
+ $url = isset($pluginInfo['srcURL']) ? $pluginInfo['srcURL'] : '';
+ if ($url === '') {
+ return '';
+ }
+ $parts = parse_url($url);
+ if (!$parts || !isset($parts['host']) || strtolower($parts['host']) !== 'github.com' || !isset($parts['path'])) {
+ return '';
+ }
+ $seg = array_values(array_filter(explode('/', $parts['path']), function ($s) {
+ return $s !== '';
+ }));
+ if (count($seg) < 2) {
+ return '';
+ }
+ $repo = preg_replace('/\.git$/i', '', $seg[1]);
+ return strtolower($seg[0] . '/' . $repo);
+}
+
+// Read an installed plugin's own pluginInfo.json off disk, or null. Shared by
+// every releaseNotes style below so each one is judged on what the plugin
+// actually ships, not on anything a client could claim about it.
+function ReadInstalledPluginInfo($plugin)
+{
+ global $settings;
+ $infoFile = $settings['pluginDirectory'] . '/' . $plugin . '/pluginInfo.json';
+ if (!file_exists($infoFile)) {
+ return null;
+ }
+ $data = json_decode(@file_get_contents($infoFile), true);
+ return is_array($data) ? $data : null;
+}
+
+function PluginReleaseNotesStyleOf($pluginInfo)
+{
+ $style = isset($pluginInfo['releaseNotesStyle']) ? $pluginInfo['releaseNotesStyle'] : 'none';
+ return in_array($style, array('gitRelease', 'gitHistory', 'script'), true) ? $style : 'none';
+}
+
+/**
+ * Show a plugin's release notes, in whichever form it declared via
+ * `releaseNotesStyle` in its `pluginInfo.json` (see PLUGININFO_FORMAT.md):
+ * `gitRelease` (latest GitHub Release), `gitHistory` (commits not yet
+ * pulled, read from the plugin's own clone -- no GitHub API call, works
+ * for any git-hosted plugin whether or not it tags releases), or `script`
+ * (runs the plugin's own scripts/fpp_releasenotes.sh). `none` or absent:
+ * 404, nothing to show -- the Plugin Manager doesn't offer the icon in
+ * that case either, this is just the server-side half of that gate.
+ *
+ * @route GET /api/plugin/{RepoName}/releaseNotes
+ * @response 200 Shape depends on `style`
+ * ```json
+ * {"style": "gitRelease", "name": "v1.2.0", "tag_name": "v1.2.0", "published_at": "2026-01-01T00:00:00Z", "body": "...", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0"}
+ * ```
+ */
+function GetPluginReleaseNotes()
+{
+ $plugin = params('RepoName');
+ $pluginInfo = ReadInstalledPluginInfo($plugin);
+ if ($pluginInfo === null) {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'Plugin is not installed'));
+ }
+
+ $style = PluginReleaseNotesStyleOf($pluginInfo);
+ switch ($style) {
+ case 'gitRelease':
+ return PluginReleaseNotesFromGitHub($plugin, $pluginInfo);
+ case 'gitHistory':
+ return PluginReleaseNotesFromGitHistory($plugin, $pluginInfo);
+ case 'script':
+ return PluginReleaseNotesFromScript($plugin, $pluginInfo);
+ default:
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'This plugin has no release notes'));
+ }
+}
+
+// releaseNotesStyle: gitRelease -- latest GitHub Release, proxied and cached
+// server-side. Same idea as GitOSReleaseNotes() (FPP's own release-notes
+// endpoint, hardcoded to FalconChristmas/fpp) but for an arbitrary plugin
+// repo: GitHub's unauthenticated API rate limit is shared across every box
+// calling in from behind the same NAT, and this keeps api.github.com out of
+// the CSP the way every other third-party call in this file already does.
+function PluginReleaseNotesFromGitHub($plugin, $pluginInfo)
+{
+ $repo = PluginGitHubRepoOf($pluginInfo);
+ if ($repo === '') {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'No GitHub repo could be determined for this plugin'));
+ }
+
+ $cacheFile = PluginReleaseNotesCacheFile();
+ $cache = array();
+ if (file_exists($cacheFile)) {
+ $tmp = json_decode(@file_get_contents($cacheFile), true);
+ if (is_array($tmp)) $cache = $tmp;
+ }
+
+ $key = $repo; // already lowercased by PluginGitHubRepoOf()
+ $now = time();
+ if (isset($cache[$key]['ts']) && ($now - (int)$cache[$key]['ts']) < PLUGIN_RELEASE_NOTES_CACHE_TTL) {
+ if (!empty($cache[$key]['notFound'])) {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'No releases found'));
+ }
+ return json($cache[$key]['data']);
+ }
+
+ $curl = curl_init();
+ curl_setopt($curl, CURLOPT_URL, "https://api.github.com/repos/" . $repo . "/releases/latest");
+ curl_setopt($curl, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:17.0) Gecko/20100101 Firefox/17.0");
+ curl_setopt($curl, CURLOPT_FAILONERROR, true);
+ curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($curl, CURLOPT_CONNECTTIMEOUT_MS, 4000);
+ $response = curl_exec($curl);
+ $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+ curl_close($curl);
+
+ if ($response === false || $httpCode >= 400) {
+ $cache[$key] = array('notFound' => true, 'ts' => $now);
+ @file_put_contents($cacheFile, json_encode($cache));
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'No releases found'));
+ }
+
+ $data = json_decode($response, true);
+ if ($data === null) {
+ http_response_code(502);
+ return json(array('status' => 'ERROR', 'message' => 'Invalid response from GitHub'));
+ }
+
+ // Only keep the fields the UI actually uses -- GitHub's release object
+ // carries a lot more (assets, author, reactions, ...) that would just
+ // bloat the cache file for no benefit here.
+ $trimmed = array(
+ 'style' => 'gitRelease',
+ 'name' => isset($data['name']) ? $data['name'] : '',
+ 'tag_name' => isset($data['tag_name']) ? $data['tag_name'] : '',
+ 'published_at' => isset($data['published_at']) ? $data['published_at'] : '',
+ 'body' => isset($data['body']) ? $data['body'] : '',
+ 'html_url' => isset($data['html_url']) ? $data['html_url'] : '',
+ );
+
+ $cache[$key] = array('data' => $trimmed, 'ts' => $now);
+ @file_put_contents($cacheFile, json_encode($cache));
+
+ return json($trimmed);
+}
+
+// releaseNotesStyle: gitHistory -- the commits between the installed clone's
+// HEAD and origin/, read directly from the plugin's own repo. No
+// GitHub API call (works the same for a non-GitHub git host), and no live
+// `git fetch` here either -- this rides whatever origin/ the last
+// PluginHasUpdates()/CheckForPluginUpdates() pass already brought in, the
+// same "cheap, already-fetched refs" assumption PluginHasUpdates() itself
+// makes. Same delimited log format changelog.php uses for FPP's own commit
+// history, capped well short of a wall of history for what is meant to
+// answer "what's in this update", not serve as a full log viewer.
+define('PLUGIN_RELEASE_HISTORY_MAX_COMMITS', 50);
+
+function PluginReleaseNotesFromGitHistory($plugin, $pluginInfo)
+{
+ global $settings;
+
+ $branch = PluginCurrentBranch($plugin);
+ if ($branch === '') {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'Could not determine this plugin\'s branch'));
+ }
+
+ $dir = $settings['pluginDirectory'] . '/' . $plugin;
+ $logFormat = '%h%x1f%an%x1f%ai%x1f%s';
+ $cmd = 'cd ' . escapeshellarg($dir)
+ . ' && git log --pretty=format:' . escapeshellarg($logFormat)
+ . ' HEAD..' . escapeshellarg('origin/' . $branch)
+ . ' | head -' . PLUGIN_RELEASE_HISTORY_MAX_COMMITS;
+ exec($cmd, $lines, $return_val);
+
+ $commits = array();
+ foreach ($lines as $line) {
+ $parts = explode("\x1f", $line);
+ if (count($parts) !== 4) {
+ continue;
+ }
+ $commits[] = array(
+ 'hash' => $parts[0],
+ 'author' => $parts[1],
+ 'date' => $parts[2],
+ 'subject' => $parts[3],
+ );
+ }
+
+ if (empty($commits)) {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'No new commits found'));
+ }
+
+ return json(array('style' => 'gitHistory', 'branch' => $branch, 'commits' => $commits));
+}
+
+// releaseNotesStyle: script -- for a plugin whose update-worthy changes
+// aren't in the git log at all (see scripts/fpp_update_check.sh's own
+// doc comment on PluginHasUpdates() -- components like Pulshmesh/FPPMon
+// that live outside the git repo). Runs scripts/fpp_releasenotes.sh with
+// the same FPPDIR/SRCDIR environment fpp_update_check.sh gets, and returns
+// its stdout as plain text -- never interpreted as markdown/HTML, so a
+// plugin's own script output can't inject markup any more than a GitHub
+// release body can (PluginMarkdownToSafeHtml HTML-escapes first; plain
+// script output goes through the same escaping on the way to the page).
+function PluginReleaseNotesFromScript($plugin, $pluginInfo)
+{
+ global $settings, $fppDir;
+
+ $script = $settings['pluginDirectory'] . '/' . $plugin . '/scripts/fpp_releasenotes.sh';
+ if (!file_exists($script)) {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'This plugin has no scripts/fpp_releasenotes.sh'));
+ }
+
+ exec('FPPDIR=' . escapeshellarg($fppDir) . ' SRCDIR=' . escapeshellarg($fppDir . '/src') . ' ' . escapeshellarg($script), $lines, $return_val);
+ if ($return_val != 0 || empty($lines)) {
+ http_response_code(404);
+ return json(array('status' => 'ERROR', 'message' => 'No release notes text was returned'));
+ }
+
+ return json(array('style' => 'script', 'text' => implode("\n", $lines)));
+}
+
/**
* Fetch a pluginInfo.json on the browser's behalf
*
diff --git a/www/api/index.php b/www/api/index.php
index 3ce71ff78..8cd3eaf9c 100644
--- a/www/api/index.php
+++ b/www/api/index.php
@@ -233,6 +233,7 @@
dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon');
dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl');
dispatch_get('/plugin/:RepoName/privacy', 'GetPluginPrivacyStatus');
+dispatch_get('/plugin/:RepoName/releaseNotes', 'GetPluginReleaseNotes');
dispatch_delete('/plugin/:RepoName', 'UninstallPlugin');
dispatch_get('/plugin/:RepoName/settings/:SettingName', 'PluginGetSetting');
dispatch_put('/plugin/:RepoName/settings/:SettingName', 'PluginSetSetting');
diff --git a/www/api/openapi.json b/www/api/openapi.json
index 310af5d00..014d7ca85 100644
--- a/www/api/openapi.json
+++ b/www/api/openapi.json
@@ -7100,6 +7100,45 @@
}
}
},
+ "/api/plugin/{RepoName}/releaseNotes": {
+ "parameters": [
+ {
+ "name": "RepoName",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "get": {
+ "tags": [
+ "plugin"
+ ],
+ "summary": "plugin/{RepoName}/releaseNotes",
+ "description": "Show a plugin's release notes, in whichever form it declared via `releaseNotesStyle` in its `pluginInfo.json` (see PLUGININFO_FORMAT.md): `gitRelease` (latest GitHub Release), `gitHistory` (commits not yet pulled, read from the plugin's own clone -- no GitHub API call, works for any git-hosted plugin whether or not it tags releases), or `script` (runs the plugin's own scripts/fpp_releasenotes.sh). `none` or absent: 404, nothing to show -- the Plugin Manager doesn't offer the icon in that case either, this is just the server-side half of that gate.",
+ "responses": {
+ "200": {
+ "description": "Shape depends on `style`",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ },
+ "example": {
+ "style": "gitRelease",
+ "name": "v1.2.0",
+ "tag_name": "v1.2.0",
+ "published_at": "2026-01-01T00:00:00Z",
+ "body": "...",
+ "html_url": "https://github.com/owner/repo/releases/tag/v1.2.0"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/plugin/{RepoName}/settings/{SettingName}": {
"parameters": [
{
diff --git a/www/css/fpp.css b/www/css/fpp.css
index 76d2d14e2..b0445292e 100644
--- a/www/css/fpp.css
+++ b/www/css/fpp.css
@@ -4635,6 +4635,21 @@ pre.testData,
-webkit-box-orient: vertical;
overflow: hidden;
}
+/* Release-notes icon link in the card action row / detail modal footer */
+.pluginReleaseNotesLink {
+ color: var(--fpp-text-muted);
+ text-decoration: none;
+ font-size: 1rem;
+}
+.pluginReleaseNotesLink:hover {
+ color: var(--bs-link-color);
+}
+/* releaseNotesStyle: script -- plain-text release notes, wrapped rather than
+ scrolling horizontally like a code block would. */
+pre.release-notes-body {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
/* Popular Plugins: single horizontally-scrollable row (functional scroll
boundary + fixed item width so flex children don't collapse; no Bootstrap
utility covers a fixed rem width). Scroll is contained here so the page
diff --git a/www/plugins.php b/www/plugins.php
index 7436d7baa..d3a61871f 100644
--- a/www/plugins.php
+++ b/www/plugins.php
@@ -2215,6 +2215,174 @@ function GitHubStatsRowHtml(repo) {
return '' + badge + '
';
}
+ // Whether/how the Plugin Manager offers this plugin's release notes --
+ // author-declared via `releaseNotesStyle` in pluginInfo.json (see
+ // PLUGININFO_FORMAT.md), not inferred from whether the repo happens to be
+ // on GitHub. Most plugins have no tagged GitHub Releases at all, so
+ // gating on "is this GitHub-hosted" (the original approach here) meant
+ // showing an icon that was a dead end for almost every plugin. Explicit
+ // opt-in means: no icon for a plugin that hasn't declared a style, and no
+ // upfront round trip needed to find that out ahead of render time.
+ function PluginReleaseNotesStyle(data) {
+ var style = data && data.releaseNotesStyle;
+ return (style === 'gitRelease' || style === 'gitHistory' || style === 'script') ? style : 'none';
+ }
+
+ // Release-notes trigger, wrapped for inline placement at the right end of
+ // the action row alongside (or in place of) the GitHub stats badge -- same
+ // ms-auto pattern as GitHubStatsRowHtml, so the two cluster together at the
+ // trailing edge whether or not the stats badge is also present. '' when
+ // the plugin hasn't opted into a releaseNotesStyle.
+ // Shown at every UI level: unlike the stats badge this needs no upfront API
+ // call (ShowPluginReleaseNotes fetches on click), so there's no rate-limit
+ // reason to gate it behind Advanced/Developer.
+ // javascript:void(0) href, not a real link: this opens an in-app modal (see
+ // ShowPluginReleaseNotes) via the card's existing data-plugin-action
+ // delegated click handler, same as the Install/Update/Uninstall buttons.
+ function ReleaseNotesRowHtml(data) {
+ if (PluginReleaseNotesStyle(data) === 'none') return '';
+ return '' +
+ '';
+ }
+
+ // Narrow, dependency-free markdown-to-HTML used for GitHub release bodies --
+ // mirrors the converter about.php uses for FPP's own OS release notes
+ // (rather than pulling in a general markdown renderer for text a plugin
+ // author supplies). HTML-escapes FIRST, then only ever adds back a fixed
+ // whitelist of tags for a fixed whitelist of markdown constructs, so nothing
+ // in the source text can inject arbitrary markup.
+ function PluginMarkdownToSafeHtml(md) {
+ var body = md
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/__(.+?)__/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/_(.+?)_/g, '$1')
+ .replace(/```(.+?)```/gs, '$1
')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\[(.+?)\]\((.+?)\)/g, '$1')
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, '
');
+
+ body = body.replace(/(
)?- (.+?)(
|<\/p>)/g, function (match, br1, content) {
+ return '
' + content + '';
+ });
+ body = body.replace(/(.*?<\/li>)+/g, function (match) {
+ return '';
+ });
+
+ return '' + body + '
';
+ }
+
+ // releaseNotesStyle: gitRelease renderer.
+ function RenderGitReleaseNotes(release, ghRepo) {
+ var html = '';
+ if (release.published_at) {
+ var date = new Date(release.published_at);
+ html += ' Published: ' + date.toLocaleDateString() + '
';
+ }
+ html += release.body
+ ? '' + PluginMarkdownToSafeHtml(release.body) + '
'
+ : 'No release notes text was provided for this release.
';
+ if (IsSafeHttpUrl(release.html_url)) {
+ html += '';
+ }
+ return html;
+ }
+
+ // releaseNotesStyle: gitHistory renderer -- same commit-row shape
+ // changelog.php uses for FPP's own history, just without the "Current"
+ // highlight (there's no local commit to compare against here, only what
+ // the update would bring in).
+ function RenderGitHistoryReleaseNotes(release) {
+ if (!release.commits || !release.commits.length) {
+ return 'No new commits were found.
';
+ }
+ var html = '| Commit | Message |
';
+ release.commits.forEach(function (c) {
+ html += '' + EscapeHtml(c.hash) + ' | ' + EscapeHtml(c.subject) +
+ ' ' + EscapeHtml(c.author) + ' · ' + EscapeHtml(c.date) + ' |
';
+ });
+ html += '
';
+ return html;
+ }
+
+ // releaseNotesStyle: script renderer -- plain text from the plugin's own
+ // scripts/fpp_releasenotes.sh, HTML-escaped and line-wrapped. Never
+ // interpreted as markdown/HTML, same reasoning as PluginMarkdownToSafeHtml:
+ // a plugin's own script output shouldn't be able to inject markup either.
+ function RenderScriptReleaseNotes(release) {
+ if (!release.text) {
+ return 'No release notes text was returned.
';
+ }
+ return '' + EscapeHtml(release.text) + '
';
+ }
+
+ // Fetch and show a plugin's release notes in a modal, in whichever style
+ // it declared (see PluginReleaseNotesStyle) -- same shape as about.php's
+ // OS-release-notes flow (loading spinner, then patch .modal-body once
+ // api/plugin/{RepoName}/releaseNotes resolves).
+ function ShowPluginReleaseNotes(repo) {
+ var i = FindPluginInfo(repo);
+ if (i < 0) return;
+ var data = pluginInfos[i];
+ if (PluginReleaseNotesStyle(data) === 'none') return;
+
+ // This link also lives inside the plugin detail modal, so a click there
+ // would otherwise stack a second Bootstrap modal on top of it. Close it
+ // first -- but only when it's actually open: reachable from the card
+ // grid directly, where pluginDetailDialog may never have existed this
+ // session, and CloseModalDialog()'s unconditional .hide() throws on a
+ // modal with no Bootstrap instance yet.
+ if ($('#pluginDetailDialog').hasClass('show')) {
+ CloseModalDialog('pluginDetailDialog');
+ }
+
+ DoModalDialog({
+ id: 'pluginReleaseNotesModal',
+ title: 'Release Notes: ' + EscapeHtml(data.name),
+ body: ' Loading...
',
+ class: 'modal-lg modal-dialog-scrollable',
+ keyboard: true,
+ backdrop: true
+ });
+
+ var ghRepo = GitHubRepoOf(data);
+
+ $.ajax({
+ url: 'api/plugin/' + encodeURIComponent(repo) + '/releaseNotes',
+ dataType: 'json',
+ success: function (release) {
+ var html;
+ if (release.style === 'gitHistory') {
+ html = RenderGitHistoryReleaseNotes(release);
+ } else if (release.style === 'script') {
+ html = RenderScriptReleaseNotes(release);
+ } else {
+ html = RenderGitReleaseNotes(release, ghRepo);
+ }
+ $('#pluginReleaseNotesModal .modal-body').html(html);
+ },
+ error: function () {
+ var html = '' +
+ '
No release notes are available for this plugin right now.
';
+ if (ghRepo) {
+ html += '
';
+ }
+ html += '
';
+ $('#pluginReleaseNotesModal .modal-body').html(html);
+ }
+ });
+ }
+
// Stamp the counts onto already-rendered cards (called once counts
// arrive; cards rendered after that get the corner inline in LoadPlugin).
// Only touches cards whose repo we have counts for -- cards without data
@@ -2562,6 +2730,7 @@ function ShowPluginDetail(repo) {
};
if (IsSafeHttpUrl(data.srcURL) && !sameLink(data.srcURL, data.homeURL)) body += ' View Source';
if (IsSafeHttpUrl(data.bugURL)) body += ' Report a Bug';
+ if (PluginReleaseNotesStyle(data) !== 'none') body += ' Release Notes';
body += '';
// What a plugin declares is one tap away from its card at any
// time, not only at install. Lines open on tap here too. For a
@@ -2781,7 +2950,7 @@ function LoadPlugin(data, insert = false) {
// closest() and stops there, same effect as the old inline
// event.stopPropagation() without needing it on every button.
html += '' +
- actions + PrivacyRowHtml(data) + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + '
';
+ actions + ReleaseNotesRowHtml(data) + PrivacyRowHtml(data) + GitHubStatsRowHtml(pluginGitHubRepos[data.repoName]) + '';
html += '';
if (installed) {
@@ -3215,6 +3384,7 @@ function FilterPlugins() {
else if (action === 'uninstall') ShowUninstallPluginPopup(repo);
else if (action === 'reinstall') ShowReinstallPluginPopup(repo);
else if (action === 'install') ConfirmAndInstall(repo, el.dataset.branch, el.dataset.sha);
+ else if (action === 'releaseNotes') ShowPluginReleaseNotes(repo);
// 'none' (blank space in a card's actions row): absorb the click,
// do nothing -- same effect the old event.stopPropagation() had.
});