Compare commits

...
10 Commits
Author SHA1 Message Date
ChuckBuilds 6a93b2c89e fix(web): resolve remaining valid findings from latest review pass
- custom-feeds.js: fix asset-upload contract mismatch (field name "file" ->
  "files", response read from top-level "uploaded_files" not "data.files") -
  same bug already fixed in this file on a separate branch/PR (#420), which
  this branch never received since they're independent PRs off main
- custom-feeds.js: add aria-label to the two icon-only "remove feed" buttons
- custom-feeds.js: move file-input reset into .finally() so a failed upload
  doesn't leave the input stuck holding the file, blocking retry of the
  same file
- app-shell.js: fix executePluginAction(pluginId, actionId) parameter
  order/count mismatch vs. its callers' (actionId, index, pluginId) -
  currently masked by plugins_manager.js's correct version overwriting this
  one at load time (classic vs. deferred script order), but worth fixing
  outright since it's an isolated, self-contained reassignment (not inside
  the Alpine app() object literal) and removes a latent footgun
- overview.html: align Alpine-state resolution with settings-search.js's
  two-tier getAppData() (also check appEl.__x.$data, not just _x_dataStack)

Verified already addressed by earlier passes (no change needed):
plugin_rotation_order validation, DisplayController log prefixes, togglePlugin
returning its promise for install-flow chaining, installedPlugins setter
always updating state, mobile-nav aria-label, toggleSection aria-expanded
sync, PluginOrderList bounded init retries (both display.html and
durations.html), plugin-order-list.js Array.isArray validation, batched
getImageData in the LED-dot preview renderer, app.py exception narrowing/
logging, form-submission log redaction.

Confirmed dead code, skipped (unreachable - zero template/JS callers,
verified via full-repo grep): dotToNested prototype-pollution hardening,
generateFieldHtml HTML-injection hardening, and the HTML-entity-unescape
block in JSON parsing - all three live only inside app-shell.js's two
legacy savePluginConfig implementations (one Alpine-method, one standalone),
neither of which any template or script calls. The real, live plugin-config
path is server-rendered via GET /partials/plugin-config/<id>.

Explicitly NOT reverted: the htmx:afterSwap script-execution listener. An
earlier finding batch asked to remove it as "duplicate" htmx behavior; that
was tried and reverted this session after live testing on hardware proved
it broke every partial whose Alpine x-data depends on an inline <script>
in the same partial (confirmed: WiFi tab hard-failed with "wifiSetup is not
defined"). Removing it again would reintroduce that regression.
2026-07-16 20:31:49 -04:00
ChuckBuilds 14df879e31 fix(web): wait for async plugin install to finish before auto-enabling it
Confirmed live: installing hockey-scoreboard logged "installation queued"
(success) immediately followed by "enabling it failed" with a 404 "Plugin
not found" from /api/v3/plugins/toggle.

/api/v3/plugins/install runs the actual clone + plugin-manager discovery
asynchronously via an operation queue when one is configured - the response
installPlugin() was checking only means the operation was queued, not that
the plugin is installed yet. Calling togglePlugin() right after that
response 404s because plugin_manager hasn't discovered the new plugin.

Fix: reuse the same operation-polling mechanism uninstallPlugin() already
has (generalized pollOperationStatus() to take onComplete/onFailed/onTimeout
callbacks instead of hardcoding uninstall behavior) so installPlugin() waits
for the operation to actually complete before enabling it. Falls back to
enabling immediately when no operation_id is returned (direct/synchronous
install path, no queue configured).
2026-07-16 20:20:10 -04:00
ChuckBuilds 99ea157fb2 fix(web): restore htmx script re-execution timing that Alpine x-data depends on
Removing the custom htmx:afterSwap script-reexecution handler (in a prior
commit, as a "duplicate execution" cleanup) broke every partial whose Alpine
x-data component function is defined by an inline <script> in that same
partial (e.g. wifi.html's wifiSetup()) - confirmed live via browser console:
"Alpine Expression Error: wifiSetup is not defined" on every field in the
WiFi tab.

Root cause: htmx's own native script execution runs during its "settle"
phase (~20ms after swap, per htmx's own defaultSettleDelay), but Alpine's
MutationObserver evaluates x-data on newly-inserted elements synchronously,
right as the swap lands - before settle. So the inline <script> defining
wifiSetup() was still un-run when Alpine tried to call it, and Alpine does
not retry a failed x-data evaluation later once the function does become
defined.

Fix: re-execute swapped <script> tags ourselves on htmx:afterSwap (which
fires synchronously, before settle, beating Alpine's observer), and disable
htmx's own native script re-execution (htmx.config.allowScriptTags = false)
so the same script doesn't also run a second time during settle - restoring
correct timing without reintroducing the original double-execution bug.

Also in this commit:
- fix XSS: unescaped repoUrl in a title attribute in renderSavedRepositories
- replace .includes('github.com') substring checks with real URL hostname
  validation (CodeQL: incomplete URL substring sanitization)
2026-07-16 20:17:32 -04:00
ChuckBuilds 35bc299162 fix(web): remove redundant htmx.org substring check flagged by CodeQL
CodeQL flags .includes('htmx.org') as "incomplete URL substring
sanitization" - a false positive here, since this string is only ever
matched against console.error/warn message text to decide whether to
suppress a known-harmless HTMX timing-race log line, not used for any
URL-trust/redirect decision. The check was also redundant: 'htmx' is
already a substring of 'htmx.org', so the plain .includes('htmx') check
right next to it already covers every case the removed check did.
2026-07-16 20:07:23 -04:00
ChuckBuilds cf84a76fb2 fix(web): raise display Rows field max from 64 to 128
Cols already allowed up to 128; Rows was capped at 64, which rejects
valid larger panel configurations (e.g. 128-row tile chains). No
server-side schema enforces a rows max, so this was purely an
overly-strict HTML input attribute.
2026-07-16 19:59:06 -04:00
ChuckBuilds 30e1837535 fix(web): repair dead /v3/logs link on the display hardware-error banner
The "Logs tab" link in the display-settings simulation-mode banner was a
real <a href> to /v3/logs, but no such route has ever existed (log content
is loaded client-side via activeTab, not a dedicated page route) - the link
404'd regardless of the /v3 prefix change. Switch it to the same
activeTab-switching pattern the real nav uses.
2026-07-16 18:57:30 -04:00
ChuckBuildsandClaude Sonnet 5 f4301f2675 chore(display): add missing [DisplayController] prefix to the reconcile-complete log
Re-verifying the full CodeRabbit findings list against current code
surfaced one still-open item: the nitpick asked for the prefix on BOTH
rotation-related log lines, but only "Applied plugin rotation order" got
it in the earlier pass - "Plugin reconcile complete" was missed. No
message/argument/level change, matching the finding's own scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 16:34:58 -04:00
ChuckBuildsandClaude Sonnet 5 560513d435 fix(web): remove redundant htmx:afterSwap script re-execution (was double-executing every partial's inline script)
Re-verified this CodeRabbit finding, previously deferred as "needs isolated
testing" - traced it to a confirmed, active bug rather than a style
concern:

htmx 1.9.10's own config defaults to allowScriptTags: true (confirmed in
the vendored htmx.min.js, which itself contains the same clone-and-reinsert
<script> mechanism internally). This means htmx ALREADY re-executes every
<script> tag in swapped content by default, exactly like a browser
navigating to a new page. The custom htmx:afterSwap listener in
htmx-config.js did the identical clone-and-reinsert a SECOND time on top of
htmx's own handling - so every inline <script> block in every HTMX-loaded
partial (overview, display, durations, plugin config, etc. - most partials
have one) executed twice per load.

Confirmed safe to delete outright, not just narrow: grepped every hand-written
JS file for a manual `dispatchEvent(... 'htmx:afterSwap' ...)` that might
have relied on this handler for a non-htmx code path (e.g. the direct-fetch
fallbacks like loadOverviewDirect) - none exists, so nothing depended on
this listener specifically; htmx's native handling covers every real
htmx-driven swap on its own.

Left in place, unchanged: the console.error/console.warn global override
a few lines up in the same file, which suppresses known-noisy
HTMX-timing-race messages. That one is a legitimate anti-pattern too
(broad substring matching can mask unrelated errors) but redesigning it
needs care to preserve real diagnostics while still hiding the specific
harmless races it targets - a scoped follow-up, not a same-day deletion
like this confirmed-duplicate handler.

Validation: all 27 fast web tests pass; JS brace/paren balance sanity
checked (no local Node/browser available in this sandbox to execute the
file directly - verify manually in-browser before merge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 16:11:00 -04:00
ChuckBuildsandClaude Sonnet 5 f2c1d6f80c fix(web): repair garbled Advanced Hardware section description
An earlier sed-based text update concatenated the old and new copies of
this description instead of replacing one with the other, leaving a
duplicated sentence with the &mdash; entity broken into ".mdash;" (visible
as literal "mdash;" text on the page). Restored to one clean sentence.

Other findings from this review were already fixed in a prior commit
(installedPlugins setter) or are confirmed dead code with zero live
callers (executePluginAction/dotToNested/entity-unescape/generateFieldHtml,
all reachable only from the two unused savePluginConfig copies in
app-shell.js - grepped every template, no references) - same legacy
cluster flagged in earlier review passes, still queued for a dedicated
deletion follow-up rather than patched in place here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 16:03:08 -04:00
ChuckBuildsandClaude Sonnet 5 b54d56a276 fix(web): stop duration fields leaking into config root; isolate per-file dependency installs; fix test fixture leak
Verified each finding against current code.

- api_v3 save_main_config: both duration blocks (*_duration suffix fields
  and the newer duration__<mode> fields) only READ from `data`, never
  removed the keys. The generic "remaining keys" merge later in the same
  function has no skip-list entry for either pattern, so every duration
  field was ALSO written a second time as a bogus top-level config key
  (e.g. "clock_duration": 30 and "duration__mlb_live": 42 sitting at
  config root, alongside the correct nested
  display.display_durations.<key>). Confirmed by tracing the full
  function. Fixed by popping each handled key from `data` (same pattern
  already used for plugin_rotation_order) and validating strictly: a
  non-integer duration now returns 400 with a message naming the
  offending field/mode instead of silently logging and moving on (for the
  *_duration fields, which previously had zero validation at all).
- api_v3 dependency-install loops (git_pull's post-update sync and
  install_base_requirements): _pip_install_requirements can raise
  subprocess.TimeoutExpired or OSError (confirmed: install_requirements_file
  in permission_utils.py never catches either internally, despite its
  docstring's "never raises on non-zero exit" only covering return codes).
  Both loops previously let one file's exception either abort the whole
  try block (skipping the second requirements file entirely) or propagate
  uncaught. Each file's install is now in its own try/except, so a timeout
  or OSError on one file is recorded as a labeled failure and the loop
  continues to the next file.
- test_web_smoke.py: the `client` fixture mutated the module-level
  pages_v3 Blueprint singleton's config_manager/plugin_manager directly
  with no teardown - since pages_v3 is shared across the whole pytest
  process (test_web_settings_ui.py touches the same attributes), this
  fixture's mocks could leak into whichever test ran next. Now saves the
  originals, yields the client, and restores them in a finally block.

Validation: py_compile passes; all 40 web tests pass with the now-generator
fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 15:59:41 -04:00
9 changed files with 205 additions and 111 deletions
+1 -1
View File
@@ -2851,7 +2851,7 @@ class DisplayController:
# configured rotation slot before resyncing the index.
self._apply_plugin_rotation_order()
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True
+12 -1
View File
@@ -77,6 +77,13 @@ def client():
from web_interface.blueprints import pages_v3 as pv
# pages_v3 is a module-level Blueprint singleton shared by the whole test
# process (test_web_settings_ui.py mutates the same attributes) - save
# the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
@@ -99,7 +106,11 @@ def client():
# /v3 kept as a working legacy alias.
app.register_blueprint(pv.pages_v3, url_prefix="")
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
return app.test_client()
try:
yield app.test_client()
finally:
pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager
# (path, [markers that must appear in the body])
+56 -13
View File
@@ -978,8 +978,14 @@ def save_main_config():
current_config['display'] = {}
current_config['display']['plugin_rotation_order'] = parsed
# Handle display durations
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
# Handle display durations. Popped from `data` (not just read) so
# they can never also fall through to the generic "remaining keys"
# merge near the end of this function, which would otherwise write
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
# sitting at config root alongside the correct
# display.display_durations.clock_duration).
duration_fields = [k for k in list(data.keys())
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
if duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
@@ -987,13 +993,19 @@ def save_main_config():
current_config['display']['display_durations'] = {}
for field in duration_fields:
if field in data:
current_config['display']['display_durations'][field] = int(data[field])
raw_value = data.pop(field)
try:
int_value = int(raw_value)
except (ValueError, TypeError):
return jsonify({'status': 'error',
'message': f"Invalid duration for {field}: must be an integer"}), 400
current_config['display']['display_durations'][field] = int_value
# Per-mode durations from the Rotation & Durations page, posted as
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
# they can't use the suffix convention above)
mode_duration_fields = [k for k in data.keys() if k.startswith('duration__')]
# they can't use the suffix convention above). Same pop-and-validate
# treatment, for the same reason.
mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')]
if mode_duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
@@ -1001,13 +1013,16 @@ def save_main_config():
current_config['display']['display_durations'] = {}
for field in mode_duration_fields:
raw_value = data.pop(field)
mode_key = field[len('duration__'):]
if not mode_key:
continue
try:
current_config['display']['display_durations'][mode_key] = int(data[field])
int_value = int(raw_value)
except (ValueError, TypeError):
logger.warning("Ignoring non-integer duration for %s", mode_key)
return jsonify({'status': 'error',
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
current_config['display']['display_durations'][mode_key] = int_value
# Handle plugin configurations dynamically
# Any key that matches a plugin ID should be saved as plugin config
@@ -1719,7 +1734,12 @@ def execute_system_action():
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
req_path = PROJECT_ROOT / rel
if rel in changed and req_path.exists():
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
@@ -1729,6 +1749,17 @@ def execute_system_action():
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
@@ -1773,10 +1804,22 @@ def execute_system_action():
outputs = []
all_ok = True
for req_file in req_files:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {req_file.relative_to(PROJECT_ROOT)} ==\n"
+ _truncate_output(result.stdout, result.stderr))
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if all_ok else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
+1 -1
View File
@@ -2775,7 +2775,7 @@
}
};
window.executePluginAction = function(pluginId, actionId) {
window.executePluginAction = function(actionId, actionIndex, pluginId) {
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
method: 'POST'
})
+29 -29
View File
@@ -69,12 +69,11 @@
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx.org') ||
errorStr.includes('htmx') ||
const isHtmxError = errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx.org') || arg.includes('htmx');
return arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
@@ -162,34 +161,35 @@
// Log but don't break the app
console.warn('HTMX swap error:', event.detail);
});
// Execute <script> tags in swapped content ourselves, on
// htmx:afterSwap (synchronous, right after the swap) rather
// than relying on htmx's own script handling, which runs
// during its later "settle" phase (~20ms after swap, per
// htmx's defaultSettleDelay). Alpine's MutationObserver
// processes newly-inserted x-data elements synchronously
// as soon as the swap lands, which is BEFORE htmx's settle
// phase - so any partial whose x-data component function
// (e.g. wifiSetup()) is defined by an inline <script> in
// that same partial would have that script still un-run
// when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the
// same script doesn't also run a second time via settle.
if (typeof htmx !== 'undefined' && htmx.config) {
htmx.config.allowScriptTags = false;
}
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail && event.detail.target) {
try {
const scripts = event.detail.target.querySelectorAll('script');
scripts.forEach(function(oldScript) {
try {
if (oldScript.textContent.trim() || oldScript.src) {
const newScript = document.createElement('script');
if (oldScript.src) newScript.src = oldScript.src;
if (oldScript.type) newScript.type = oldScript.type;
if (oldScript.textContent) newScript.textContent = oldScript.textContent;
if (oldScript.parentNode) {
oldScript.parentNode.insertBefore(newScript, oldScript);
oldScript.parentNode.removeChild(oldScript);
} else {
// If no parent, append to head or body
(document.head || document.body).appendChild(newScript);
}
}
} catch {
// Silently ignore script execution errors
}
});
} catch {
// Silently ignore errors in script processing
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
}
newScript.textContent = oldScript.textContent;
oldScript.replaceWith(newScript);
});
});
// Mark tab containers as loaded once their content settles, so switching
@@ -209,6 +209,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -333,6 +334,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -404,7 +406,10 @@
if (!file) return;
const formData = new FormData();
formData.append('file', file);
// Backend contract (api_v3.upload_plugin_asset): field must be named
// "files" (request.files.getlist('files')), and the response carries
// results in a top-level "uploaded_files" key, not nested under "data".
formData.append('files', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
@@ -421,8 +426,8 @@
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedFile = data.uploaded_files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
@@ -495,8 +500,6 @@
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file
event.target.value = '';
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
@@ -506,6 +509,12 @@
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
})
.finally(() => {
// Reset regardless of outcome, so the same file can be re-selected
// to retry after a failure (browsers won't fire "change" again
// for an input that still holds that exact file).
event.target.value = '';
});
};
+85 -57
View File
@@ -667,7 +667,7 @@ window.handleGitHubPluginInstall = function() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
@@ -3334,16 +3334,28 @@ window.uninstallPlugin = function(pluginId) {
});
}
function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const maxAttempts = options.maxAttempts || 60;
const attempt = options.attempt || 0;
const onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
const onFailed = options.onFailed || ((errorMsg) => {
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
const onTimeout = options.onTimeout || (() => {
showNotification(`Operation timed out for ${pluginName}`, 'error');
setTimeout(() => loadInstalledPlugins(), 1000);
});
if (attempt >= maxAttempts) {
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
// Refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
onTimeout();
return;
}
const pollAgain = () => setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, { ...options, attempt: attempt + 1 });
}, 1000);
fetch(`/api/v3/plugins/operation/${operationId}`)
.then(response => response.json())
.then(data => {
@@ -3352,32 +3364,16 @@ function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60
const status = operation.status;
if (status === 'completed') {
// Operation completed successfully
handleUninstallSuccess(pluginId);
onComplete();
} else if (status === 'failed') {
// Operation failed
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
showNotification(errorMsg, 'error');
// Refresh plugin list to see actual state
setTimeout(() => {
loadInstalledPlugins();
}, 1000);
} else if (status === 'pending' || status === 'in_progress') {
// Still in progress, poll again
setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000); // Poll every second
onFailed(operation.error || operation.message);
} else {
// Unknown status, poll again
setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000);
// 'pending', 'in_progress', or unknown - poll again
pollAgain();
}
} else {
// Error getting operation status, try again
setTimeout(() => {
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}, 1000);
pollAgain();
}
})
.catch(error => {
@@ -3883,6 +3879,33 @@ window.installPlugin = function(pluginId, branch = null) {
requestBody.branch = branch;
}
function enableAfterInstall() {
// Enable immediately so install -> enable is one step; only nudge
// for a restart once enablement actually succeeded (persistent
// toast; duration 0 = stays until dismissed).
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
if (toggleResult && toggleResult.status === 'success') {
showNotification(
`${pluginId} installed and enabled — restart the display to show it`,
{
type: 'success',
duration: 0,
actionLabel: 'Restart Now',
onAction: () => restartDisplay()
}
);
} else {
showNotification(
`${pluginId} installed, but enabling it failed — use its toggle in the plugin list`,
'warning'
);
}
});
// Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins();
setTimeout(() => applyStoreFiltersAndSort(true), 500);
}
fetch('/api/v3/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -3891,31 +3914,23 @@ window.installPlugin = function(pluginId, branch = null) {
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Enable immediately so install -> enable is one step; only nudge
// for a restart once enablement actually succeeded (persistent
// toast; duration 0 = stays until dismissed).
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
if (toggleResult && toggleResult.status === 'success') {
showNotification(
`${pluginId} installed and enabled — restart the display to show it`,
{
type: 'success',
duration: 0,
actionLabel: 'Restart Now',
onAction: () => restartDisplay()
}
);
} else {
showNotification(
`${pluginId} installed, but enabling it failed — use its toggle in the plugin list`,
'warning'
);
}
if (data.status !== 'success') return;
if (data.data && data.data.operation_id) {
// Install runs async via the operation queue - this response only
// means "queued", not "installed". Enabling immediately here would
// 404 with "Plugin not found" against the toggle endpoint, since
// the plugin manager hasn't discovered the new plugin yet (seen
// live: "installation queued" followed immediately by a failed
// enable). Wait for the operation to actually finish first.
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
onComplete: enableAfterInstall,
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
});
// Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins();
setTimeout(() => applyStoreFiltersAndSort(true), 500);
} else {
// No operation queue configured - install already completed synchronously.
enableAfterInstall();
}
})
.catch(error => {
@@ -4022,9 +4037,9 @@ function renderSavedRepositories(repositories) {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
<span class="text-sm font-medium text-gray-900 truncate" title="${repoUrl}">${escapeHtml(repoName)}</span>
<span class="text-sm font-medium text-gray-900 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoName)}</span>
</div>
<p class="text-xs text-gray-500 truncate" title="${repoUrl}">${escapeHtml(repoUrl)}</p>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
</div>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
<i class="fas fa-trash"></i>
@@ -4107,7 +4122,7 @@ function attachInstallButtonHandler() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
@@ -4280,7 +4295,7 @@ function setupGitHubInstallHandlers() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
return;
}
@@ -4336,7 +4351,7 @@ function setupGitHubInstallHandlers() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
showError('Please enter a valid GitHub URL');
return;
}
@@ -4480,6 +4495,19 @@ function showError(message) {
}
// Validate that a URL's actual host is github.com (not just a substring
// match, which 'evil.com/github.com' or 'github.com.evil.com' would pass).
// This is only a UX nicety pointing users at a valid URL - the server does
// its own proper hostname validation before actually acting on the URL.
function isGithubUrl(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === 'github.com' || hostname === 'www.github.com';
} catch {
return false;
}
}
// Utility function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
@@ -22,7 +22,7 @@
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error.
</p>
</div>
@@ -47,7 +47,7 @@
name="rows"
value="{{ main_config.display.hardware.rows or 32 }}"
min="1"
max="64"
max="128"
class="form-control">
</div>
@@ -144,7 +144,7 @@
<h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15)
</h4>
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options Multiplexing, panel variants, and PWM tuning &mdash; the defaults work for standard HUB75 panels.mdash; the defaults work for standard HUB75 panels.</p>
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options &mdash; the defaults work for standard HUB75 panels.</p>
</div>
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
</button>
@@ -153,7 +153,10 @@
return;
}
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
var data = appEl && appEl._x_dataStack && appEl._x_dataStack[0];
// Same two-tier resolution as settings-search.js's getAppData():
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
(appEl.__x && appEl.__x.$data));
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;