mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-05 18:58:10 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a93b2c89e | ||
|
|
14df879e31 | ||
|
|
99ea157fb2 | ||
|
|
35bc299162 | ||
|
|
cf84a76fb2 | ||
|
|
30e1837535 | ||
|
|
f4301f2675 | ||
|
|
560513d435 | ||
|
|
f2c1d6f80c | ||
|
|
b54d56a276 |
@@ -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
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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 = '';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 & 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 — 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 — 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;
|
||||
|
||||
Reference in New Issue
Block a user