bcc6d40ed7
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
234 lines
8.8 KiB
JavaScript
234 lines
8.8 KiB
JavaScript
// ============================================================
|
|
// Callbacks: CRUD management
|
|
// ============================================================
|
|
|
|
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
|
|
import { IconSelect } from './icon-select.js';
|
|
import { callbackEventIcons } from './icons.js';
|
|
|
|
export let callbackFormDirty = false;
|
|
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
|
|
|
|
let _callbackEventIconSelect = null;
|
|
|
|
function _ensureCallbackEventIconSelect() {
|
|
if (_callbackEventIconSelect) return;
|
|
const select = document.getElementById('callbackName');
|
|
if (!select) return;
|
|
|
|
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
|
|
value,
|
|
icon,
|
|
label: value,
|
|
}));
|
|
|
|
_callbackEventIconSelect = new IconSelect({
|
|
target: select,
|
|
items,
|
|
columns: 3,
|
|
placeholder: t('callbacks.placeholder.event'),
|
|
onChange: () => { callbackFormDirty = true; },
|
|
});
|
|
}
|
|
|
|
let _loadCallbacksPromise = null;
|
|
export async function loadCallbacksTable() {
|
|
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
|
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
|
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
|
return _loadCallbacksPromise;
|
|
}
|
|
|
|
async function _loadCallbacksTableImpl() {
|
|
const tbody = document.getElementById('callbacksTableBody');
|
|
|
|
try {
|
|
const response = await fetch('/api/callbacks/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch callbacks');
|
|
}
|
|
|
|
const callbacksList = await response.json();
|
|
|
|
if (callbacksList.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = callbacksList.map(callback => `
|
|
<tr>
|
|
<td><code>${escapeHtml(callback.name)}</code></td>
|
|
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
|
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
|
|
<td>${callback.timeout}s</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
|
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
|
|
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
</button>
|
|
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
|
|
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('Error loading callbacks:', error);
|
|
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
export function showAddCallbackDialog() {
|
|
const dialog = document.getElementById('callbackDialog');
|
|
const form = document.getElementById('callbackForm');
|
|
const title = document.getElementById('callbackDialogTitle');
|
|
|
|
form.reset();
|
|
document.getElementById('callbackIsEdit').value = 'false';
|
|
document.getElementById('callbackName').disabled = false;
|
|
title.textContent = t('callbacks.dialog.add');
|
|
|
|
_ensureCallbackEventIconSelect();
|
|
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
|
|
|
|
callbackFormDirty = false;
|
|
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
}
|
|
|
|
export async function showEditCallbackDialog(callbackName) {
|
|
const dialog = document.getElementById('callbackDialog');
|
|
const title = document.getElementById('callbackDialogTitle');
|
|
|
|
try {
|
|
const response = await fetch('/api/callbacks/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch callback details');
|
|
}
|
|
|
|
const callbacksList = await response.json();
|
|
const callback = callbacksList.find(c => c.name === callbackName);
|
|
|
|
if (!callback) {
|
|
showToast(t('callbacks.msg.not_found'), 'error');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('callbackIsEdit').value = 'true';
|
|
document.getElementById('callbackName').value = callbackName;
|
|
document.getElementById('callbackName').disabled = true;
|
|
|
|
_ensureCallbackEventIconSelect();
|
|
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
|
|
document.getElementById('callbackCommand').value = callback.command;
|
|
document.getElementById('callbackTimeout').value = callback.timeout;
|
|
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
|
|
|
|
title.textContent = t('callbacks.dialog.edit');
|
|
callbackFormDirty = false;
|
|
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
} catch (error) {
|
|
console.error('Error loading callback for edit:', error);
|
|
showToast(t('callbacks.msg.load_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function closeCallbackDialog() {
|
|
if (callbackFormDirty) {
|
|
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const dialog = document.getElementById('callbackDialog');
|
|
callbackFormDirty = false;
|
|
closeDialog(dialog);
|
|
document.body.classList.remove('dialog-open');
|
|
}
|
|
|
|
export async function saveCallback(event) {
|
|
event.preventDefault();
|
|
|
|
const submitBtn = event.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
|
|
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
|
|
const callbackName = document.getElementById('callbackName').value;
|
|
|
|
const data = {
|
|
command: document.getElementById('callbackCommand').value,
|
|
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
|
|
working_dir: document.getElementById('callbackWorkingDir').value || null,
|
|
shell: true
|
|
};
|
|
|
|
const encodedName = encodeURIComponent(callbackName);
|
|
const endpoint = isEdit ?
|
|
`/api/callbacks/update/${encodedName}` :
|
|
`/api/callbacks/create/${encodedName}`;
|
|
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
|
|
callbackFormDirty = false;
|
|
closeCallbackDialog();
|
|
loadCallbacksTable();
|
|
} else {
|
|
showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving callback:', error);
|
|
showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
|
} finally {
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
export async function deleteCallbackConfirm(callbackName) {
|
|
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(t('callbacks.msg.deleted'), 'success');
|
|
loadCallbacksTable();
|
|
} else {
|
|
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting callback:', error);
|
|
showToast(t('callbacks.msg.delete_failed'), 'error');
|
|
}
|
|
}
|