mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
Some checks are pending
ship-ci / ci (push) Waiting to run
* fix(settings): enable Linux nightly AppImage auto-update in System settings
Fixes the Settings → System "App updates" panel so it actually works on
Linux, and adds Nightly as a selectable channel — previously missing
entirely, so Linux self-update couldn't be reached from this UI at all.
- The channel dropdown no longer gets permanently disabled the moment
the desktop bridge reports 'unsupported', which is the normal state
whenever the channel isn't Nightly on Linux. It stays enabled so the
user can switch to Nightly, the only way out of that state.
- Shows live download progress ("Downloading update… N%") and an
explicit button state machine (Check → grayed out while busy →
Restart now once staged), instead of a frozen "Checking…" during the
~1.5GB background download.
- Renders every status update from the triggering action's own return
value (checkNow()/setChannel()'s result) rather than a separate
follow-up getStatus() call, which can race against other state
changes and show a stale result even after a real success.
- setupAppUpdates() no longer re-syncs the channel to the backend on
every Settings-panel re-render — only once per page load — so a
redundant sync can no longer stomp an in-flight download's state.
- Routes update-flow events into the existing diagnostics.js
console-capture + contribute() snapshot API, so the user's existing
"Export Diagnostics" button now captures the full update decision
trace end to end — no new UI or log file. This diagnostic tracing is
what actually root-caused the bugs above, from real on-device
captures rather than guesswork.
Companion PR in feedBack-desktop (the underlying update engine).
Verified end-to-end on a Steam Deck: channel switch → check → live
download progress → restart button → relaunch onto the new build,
confirmed via a real Export Diagnostics capture showing a clean,
fully-accounted-for trace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(settings): extract + unit-test the app-update status view; dedupe diag log
- Extract the status→UI state machine from renderFrom into a pure, exported
_appUpdateStatusView() (DOM-free) and cover it with tests/js — settings.js's
large module graph made importing it for a full harness impractical, so the
pure function is the testable seam. Behavior-preserving; renderFrom applies
the returned shape to the DOM exactly as before.
- Dedupe the [update-diag] renderFrom console line so the ~1.5s download poll
no longer floods the diagnostics ring buffer with byte-identical entries;
every real state/percent change still logs, and the structured contribute()
snapshot stays unconditional.
Left the 'audio_engine' diagnostics key as-is: the server export filters
client contributions to loaded plugin ids (diagnostics_bundle.py path-traversal
guard), so a dedicated key would be silently dropped from the bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
---------
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
722 lines
36 KiB
JavaScript
722 lines
36 KiB
JavaScript
// Settings: load/save, the AV-offset nudge, the default-arrangement pin, the instrument
|
|
// pathway, and the app-update channel.
|
|
//
|
|
// INTERFACE WIDTH 1 — app.js calls loadSettings() and nothing else. It got that clean by
|
|
// PULLING THE WRITERS IN: _defaultArrangement was the one binding written from outside the
|
|
// cluster, by saveSettings and pinCurrentArrangementDefault — which are themselves settings
|
|
// functions. Widening the slice to include them left ZERO outside writes, so every export is a
|
|
// plain read-only import and no state container is needed.
|
|
//
|
|
// (An imported binding is read-only. One write from outside would have forced a setter or a
|
|
// container, as it did for the player and the library. Here the fix was to draw the boundary in
|
|
// the right place instead.)
|
|
//
|
|
// ─── handleSliderInput STAYS A HOST HOOK, DELIBERATELY ───────────────────────
|
|
//
|
|
// It lives here (it is a settings control), but player-controls.js must NOT import it: this
|
|
// module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a direct
|
|
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
|
|
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
|
|
import { hwcInitSettingsUI } from './highway-colors.js';
|
|
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
|
|
import {
|
|
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
|
|
} from './player-controls.js';
|
|
|
|
// ── Settings ─────────────────────────────────────────────────────────────
|
|
export let _defaultArrangement = '';
|
|
|
|
export const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
|
|
|
export function _normalizeInstrumentPathway(value) {
|
|
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
|
}
|
|
|
|
export function _syncDefaultArrangementSelect(value) {
|
|
const sel = document.getElementById('default-arrangement');
|
|
if (!sel) return;
|
|
const wanted = value || '';
|
|
const existing = Array.from(sel.options).find(opt => opt.value === wanted);
|
|
const dynamic = sel.querySelector('option[data-dynamic-default-arrangement]');
|
|
if (dynamic && dynamic.value !== wanted) dynamic.remove();
|
|
if (wanted && !existing) {
|
|
const opt = document.createElement('option');
|
|
opt.value = wanted;
|
|
opt.textContent = `${wanted} (saved default)`;
|
|
opt.dataset.dynamicDefaultArrangement = 'true';
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.value = wanted;
|
|
}
|
|
|
|
export function _currentArrangementName() {
|
|
const song = window.feedBack?.currentSong;
|
|
const sel = document.getElementById('arr-select');
|
|
if (song?.arrangements && sel) {
|
|
const match = song.arrangements.find(a => String(a.index) === String(sel.value));
|
|
if (match?.name) return String(match.name);
|
|
}
|
|
if (song?.arrangement) return String(song.arrangement);
|
|
const selectedText = sel?.selectedOptions?.[0]?.textContent || '';
|
|
return selectedText.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
}
|
|
|
|
export function syncDefaultArrangementPin() {
|
|
const btn = document.getElementById('arr-default-pin');
|
|
if (!btn) return;
|
|
const name = _currentArrangementName();
|
|
const isDefault = !!name && name === _defaultArrangement;
|
|
const label = name
|
|
? (isDefault ? `${name} is the default arrangement` : `Make ${name} the default for new songs`)
|
|
: 'Select an arrangement to make it the default';
|
|
btn.textContent = isDefault ? '★' : '☆';
|
|
btn.setAttribute('aria-pressed', isDefault ? 'true' : 'false');
|
|
btn.setAttribute('aria-label', label);
|
|
btn.disabled = !name;
|
|
btn.classList.toggle('text-yellow-300', isDefault);
|
|
btn.classList.toggle('text-gray-400', !isDefault);
|
|
btn.title = label;
|
|
}
|
|
|
|
export async function pinCurrentArrangementDefault() {
|
|
const name = _currentArrangementName();
|
|
if (!name || name === _defaultArrangement) {
|
|
syncDefaultArrangementPin();
|
|
return;
|
|
}
|
|
const resp = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ default_arrangement: name }),
|
|
});
|
|
if (!resp.ok) return;
|
|
_defaultArrangement = name;
|
|
_syncDefaultArrangementSelect(name);
|
|
syncDefaultArrangementPin();
|
|
}
|
|
|
|
export async function loadSettings() {
|
|
// App Updates UI does not depend on /api/settings — run it first so a
|
|
// failed fetch below still leaves the desktop updater wired up.
|
|
// setupAppUpdates() is idempotent via _appUpdatesWired.
|
|
setupAppUpdates();
|
|
setupWindowOptions();
|
|
const resp = await fetch('/api/settings');
|
|
const data = await resp.json();
|
|
// Null-guard the form fields: on the v3 tabbed settings page the markup is
|
|
// rendered by settings.js, so a control may be absent if that render hasn't
|
|
// run yet (or on a follower window). The optional-chaining keeps loadSettings
|
|
// from throwing and aborting the rest of the hydration.
|
|
const dlcEl = document.getElementById('dlc-path');
|
|
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
|
_defaultArrangement = data.default_arrangement || '';
|
|
_syncDefaultArrangementSelect(_defaultArrangement);
|
|
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
|
|
// tuning facet, filter, sort and badges all answer for the profile the
|
|
// player actually plays.
|
|
_setLibraryProfile(data.active_instrument_profile);
|
|
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
|
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
|
const demucsEl = document.getElementById('demucs-server-url');
|
|
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
|
const leftyEl = document.getElementById('setting-lefty');
|
|
if (leftyEl) leftyEl.checked = window.highway.getLefty();
|
|
const autoplayExitEl = document.getElementById('setting-autoplay-exit');
|
|
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
|
|
const showUpNextEl = document.getElementById('setting-show-upnext');
|
|
if (showUpNextEl) showUpNextEl.checked = _showUpNextEnabled();
|
|
const confirmExitEl = document.getElementById('setting-confirm-exit');
|
|
if (confirmExitEl) confirmExitEl.checked = _exitConfirmEnabled();
|
|
// Restore master-difficulty slider from persisted value (defaults
|
|
// to 100 when the key is absent — no behaviour change for users
|
|
// who've never touched the slider).
|
|
const masteryPct = typeof data.master_difficulty === 'number'
|
|
? Math.max(0, Math.min(100, data.master_difficulty))
|
|
: 100;
|
|
// Drives both the player-popover slider (#mastery-slider) and the
|
|
// Gameplay-tab "Note highway speed" slider (#setting-highway-speed), which
|
|
// share the master_difficulty key. skipPersist so loading the value doesn't
|
|
// echo it back to the server.
|
|
_applyMastery(masteryPct, { skipPersist: true });
|
|
// Route the loaded value through setAvOffsetMs so the highway's
|
|
// render clock, the Settings slider, the HUD readout, and the
|
|
// module variable all pick it up consistently. Pass skipPersist
|
|
// so we don't echo the loaded value back to the server.
|
|
setAvOffsetMs(Number(data.av_offset_ms) || 0, /* skipPersist */ true);
|
|
// Arrangement naming mode is localStorage-only (client preference).
|
|
const namingModeEl = document.getElementById('arrangement-naming-mode');
|
|
if (namingModeEl) namingModeEl.value = _getArrangementNamingMode();
|
|
// Gameplay-tab settings (tabbed settings page). Countdown is mirrored to
|
|
// localStorage so the song-start path reads it synchronously without an
|
|
// async /api/settings fetch on the play hot path. Miss penalty / fail
|
|
// behavior are persist-only stubs (not yet consumed by scoring).
|
|
const countdownOn = data.countdown_before_song === true;
|
|
try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ }
|
|
const countdownEl = document.getElementById('setting-countdown-before-song');
|
|
if (countdownEl) countdownEl.checked = countdownOn;
|
|
// Achievements epic: mirror the opt-in flag to localStorage so the
|
|
// onboarding card + the bundled achievements plugin can read the current
|
|
// state app-wide (the plugin's own settings panel still owns the toggle).
|
|
try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ }
|
|
const missEl = document.getElementById('setting-miss-penalty');
|
|
if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none';
|
|
const failEl = document.getElementById('setting-fail-behavior');
|
|
if (failEl) failEl.value = typeof data.fail_behavior === 'string' ? data.fail_behavior : 'continue';
|
|
// Native folder picker — only present when running inside feedBack-desktop.
|
|
if (window.feedBackDesktop && typeof window.feedBackDesktop.pickDirectory === 'function') {
|
|
document.getElementById('btn-pick-dlc')?.classList.remove('hidden');
|
|
}
|
|
syncDefaultArrangementPin();
|
|
// Hydrate the highway-color settings UI (theme select + per-string pickers)
|
|
// — the runtime apply path (initHighwayColors) doesn't render these controls.
|
|
hwcInitSettingsUI();
|
|
}
|
|
|
|
// ── Window options (desktop-only) ────────────────────────────────────────
|
|
// Desktop-only window preferences (start-in-fullscreen, …). The whole block
|
|
// stays hidden in the plain web / Docker app; unhide + wire only when the
|
|
// feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter
|
|
// and setter. Persistence lives desktop-side because only the Electron main
|
|
// process can read the pref at window-creation time — core just proxies.
|
|
export let _windowOptionsWired = false;
|
|
|
|
export function setupWindowOptions() {
|
|
const block = document.getElementById('window-options-block');
|
|
if (!block) return;
|
|
const winApi = window.feedBackDesktop?.window;
|
|
// Per-method capability check: a partial/older bridge may expose `window`
|
|
// without this shape. Leave the block hidden rather than half-wiring it.
|
|
if (!winApi
|
|
|| typeof winApi.getStartFullscreen !== 'function'
|
|
|| typeof winApi.setStartFullscreen !== 'function') {
|
|
return;
|
|
}
|
|
|
|
block.classList.remove('hidden');
|
|
|
|
const cb = document.getElementById('setting-start-fullscreen');
|
|
if (!cb) return;
|
|
|
|
// Hydrate from the desktop-persisted value. The getter may be sync or
|
|
// async (IPC round-trip); Promise.resolve normalises both.
|
|
Promise.resolve(winApi.getStartFullscreen()).then(function (on) {
|
|
cb.checked = !!on;
|
|
}).catch(function () { /* leave unchecked on error */ });
|
|
|
|
// Guard only the listener against double-binding; unhide + re-hydrate
|
|
// stay idempotent so re-entering Settings refreshes the checkbox.
|
|
if (!_windowOptionsWired) {
|
|
_windowOptionsWired = true;
|
|
cb.addEventListener('change', function () {
|
|
try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ }
|
|
});
|
|
}
|
|
}
|
|
|
|
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
|
|
|
|
export let _appUpdatesWired = false;
|
|
// Poll handle for the active-download watcher (module-scoped so re-running
|
|
// setupAppUpdates on a panel re-render never stacks a second poll).
|
|
let _appUpdatePollTimer = null;
|
|
// Last channel main actually acknowledged (initial sync or a successful
|
|
// user switch). Used to revert the dropdown/localStorage if a switch fails,
|
|
// so the UI/persisted state can never end up ahead of the real updater state.
|
|
let _appUpdateAckedChannel = null;
|
|
// Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and
|
|
// repeated no-op re-renders) don't flood the diagnostics ring buffer with
|
|
// byte-identical lines and evict genuinely useful trace. Every real state or
|
|
// percent change still differs and logs; the structured contribute() snapshot
|
|
// (with its own ts) is unconditional, so liveness is never lost.
|
|
let _appUpdateLastRenderLog = null;
|
|
|
|
// Pure status → view model for the App-updates panel. DOM-free and exported so
|
|
// the button/channel/text state machine can be unit-tested without a browser;
|
|
// renderFrom() applies the returned shape to the DOM. `canApply` is whether the
|
|
// bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp`
|
|
// formats the "last checked" time, `channelValue` is the dropdown's fallback
|
|
// when the status omits a channel.
|
|
export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) {
|
|
if (!s) return { kind: 'unavailable' };
|
|
if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' };
|
|
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`;
|
|
let action;
|
|
let btnLabel = 'Check for updates';
|
|
let btnMode = 'check';
|
|
let btnDisabled = false;
|
|
// Lock the channel selector only while a check/download is in flight —
|
|
// switching mid-operation abandons it. Enabled for every other status.
|
|
const channelDisabled = s.status === 'checking' || s.status === 'downloading';
|
|
switch (s.status) {
|
|
case 'checking':
|
|
action = 'checking for updates…';
|
|
btnDisabled = true;
|
|
break;
|
|
case 'downloading': {
|
|
const pct = typeof s.percent === 'number' ? s.percent : null;
|
|
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
|
|
btnDisabled = true;
|
|
break;
|
|
}
|
|
case 'downloaded':
|
|
action = 'update ready';
|
|
if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; }
|
|
else { action = 'update ready — restart to apply'; }
|
|
break;
|
|
case 'error':
|
|
action = s.message ? `update error: ${s.message}` : 'update check failed';
|
|
break;
|
|
case 'idle':
|
|
default:
|
|
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
|
|
break;
|
|
}
|
|
return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled };
|
|
}
|
|
|
|
export function setupAppUpdates() {
|
|
const block = document.getElementById('app-updates-block');
|
|
if (!block) return;
|
|
const updateApi = window.feedBackDesktop?.update;
|
|
// Per-method capability check: an older or partial feedBack-desktop
|
|
// bridge may expose `update` without the full shape. Skip wiring (and
|
|
// leave the block hidden) rather than throwing on first interaction.
|
|
if (!updateApi
|
|
|| typeof updateApi.getStatus !== 'function'
|
|
|| typeof updateApi.setChannel !== 'function'
|
|
|| typeof updateApi.checkNow !== 'function') {
|
|
return;
|
|
}
|
|
|
|
block.classList.remove('hidden');
|
|
|
|
const channelSelect = document.getElementById('app-update-channel');
|
|
const checkBtn = document.getElementById('app-update-check-now');
|
|
const statusEl = document.getElementById('app-update-status');
|
|
const linuxNote = document.getElementById('app-update-linux-note');
|
|
if (!channelSelect || !checkBtn || !statusEl) return;
|
|
|
|
// localStorage access can throw in storage-restricted contexts (sandbox
|
|
// iframes, privacy modes, etc.); fall back to the default channel so the
|
|
// panel still renders rather than aborting wiring entirely.
|
|
let storedRaw = null;
|
|
// Read the canonical key, falling back to the pre-rename
|
|
// 'slopsmith-update-channel' so an existing channel preference survives.
|
|
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
|
|
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
|
|
channelSelect.value = stored;
|
|
_appUpdateAckedChannel = stored;
|
|
|
|
// Diagnostic: every entry into this function, with whether the one-time
|
|
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
|
|
// so it only resets to false on a genuine fresh evaluation of this
|
|
// script (a real page reload/navigation) — not on loadSettings() simply
|
|
// being called again within the same page. A second "wired=false" in one
|
|
// exported log is direct proof of a reload; a series of "wired=true"
|
|
// entries proves it's just repeated Settings-panel visits (harmless).
|
|
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
|
|
|
|
function showLinuxFallback(message) {
|
|
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
|
|
// usually just means "the channel isn't Nightly yet", and the dropdown
|
|
// is the only way to switch to Nightly. Disabling it would trap the
|
|
// user on whatever channel they booted with. Only the check button and
|
|
// the note reflect the unsupported state.
|
|
if (linuxNote) linuxNote.classList.remove('hidden');
|
|
checkBtn.disabled = true;
|
|
// Reset the button out of any leftover "Restart now" state (e.g. an
|
|
// update was staged on nightly, then the user switched channels).
|
|
checkBtn.textContent = 'Check for updates';
|
|
checkBtn.dataset.mode = 'check';
|
|
statusEl.textContent = message || 'Auto-update is not available on this platform.';
|
|
}
|
|
|
|
function fmtTimestamp(ts) {
|
|
if (!ts) return 'never';
|
|
try {
|
|
const d = new Date(ts);
|
|
return Number.isNaN(d.getTime()) ? 'never' : d.toLocaleString();
|
|
} catch (_) { return 'never'; }
|
|
}
|
|
|
|
// Render one status object. Always keeps the current version + channel
|
|
// visible and appends what's happening, so the download progress never
|
|
// obscures which build you're on.
|
|
function renderFrom(s, extra) {
|
|
// Diagnostic trace: log the raw status object before any branching —
|
|
// auto-captured by diagnostics.js's console wrap into the exportable
|
|
// ring buffer, so "Export Diagnostics" in this same Settings → System
|
|
// panel captures exactly what the app saw and decided, not just what
|
|
// the UI showed. Deduped so a steady poll doesn't flood the ring buffer
|
|
// (see _appUpdateLastRenderLog); a real state/percent change differs and
|
|
// still logs; the structured contribute() snapshot below is unconditional.
|
|
const logKey = `${JSON.stringify(s)}|${extra || ''}`;
|
|
if (logKey !== _appUpdateLastRenderLog) {
|
|
_appUpdateLastRenderLog = logKey;
|
|
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
|
|
}
|
|
const view = _appUpdateStatusView(s, {
|
|
channelValue: channelSelect.value,
|
|
canApply: typeof updateApi.apply === 'function',
|
|
fmtTimestamp,
|
|
});
|
|
if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
|
if (view.kind === 'unsupported') {
|
|
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
|
|
return;
|
|
}
|
|
// Healthy for the current channel — clear any "unsupported" UI left
|
|
// over from a prior channel selection.
|
|
if (linuxNote) linuxNote.classList.add('hidden');
|
|
// The button is a little state machine (dataset.mode drives the click
|
|
// handler's restart-vs-check branch); the channel selector locks only
|
|
// while a check/download is active. See _appUpdateStatusView.
|
|
channelSelect.disabled = view.channelDisabled;
|
|
checkBtn.textContent = view.btnLabel;
|
|
checkBtn.dataset.mode = view.btnMode;
|
|
checkBtn.disabled = view.btnDisabled;
|
|
const line = view.line;
|
|
statusEl.textContent = extra ? `${extra} · ${line}` : line;
|
|
|
|
// Live structured snapshot (overwrites, not a log) via the existing
|
|
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
|
|
// own registered plugin id, so the server's diagnostics export won't
|
|
// filter it out. Always current, no scrolling through console history
|
|
// needed to answer "what does the app think is going on right now."
|
|
try {
|
|
window.feedBack?.diagnostics?.contribute('audio_engine', {
|
|
update: {
|
|
channel: s.channel || channelSelect.value,
|
|
status: s.status,
|
|
currentVersion: s.currentVersion ?? null,
|
|
lastChecked: s.lastChecked ?? null,
|
|
percent: typeof s.percent === 'number' ? s.percent : null,
|
|
message: s.message ?? null,
|
|
rendered: line,
|
|
ts: Date.now(),
|
|
},
|
|
});
|
|
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
|
|
|
|
// A download runs in the background (the check returns immediately), so
|
|
// poll for the terminal state rather than relying solely on a one-shot
|
|
// "downloaded" event that could be missed or arrive out of order.
|
|
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
|
|
}
|
|
|
|
function renderStatus(extra) {
|
|
try {
|
|
// Wrap in Promise.resolve so a future getStatus() that returns
|
|
// synchronously won't blow up on .then().
|
|
void Promise.resolve(updateApi.getStatus())
|
|
.then((s) => renderFrom(s, extra))
|
|
.catch((e) => {
|
|
console.warn('[updater] getStatus failed:', e);
|
|
statusEl.textContent = extra || 'Failed to read updater status.';
|
|
});
|
|
} catch (e) {
|
|
console.warn('[updater] getStatus threw:', e);
|
|
statusEl.textContent = extra || 'Failed to read updater status.';
|
|
}
|
|
}
|
|
|
|
// While a download (or check) is active, re-read the authoritative status
|
|
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
|
|
// what guarantees the panel leaves "downloading… 100%" and lands on "update
|
|
// ready" (or surfaces a swap error) even if the completion event is lost.
|
|
function pollWhileBusy() {
|
|
if (_appUpdatePollTimer) return;
|
|
_appUpdatePollTimer = setInterval(() => {
|
|
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
|
renderFrom(s);
|
|
const st = s && s.status;
|
|
if (st !== 'downloading' && st !== 'checking') {
|
|
clearInterval(_appUpdatePollTimer);
|
|
_appUpdatePollTimer = null;
|
|
}
|
|
}).catch(() => {
|
|
clearInterval(_appUpdatePollTimer);
|
|
_appUpdatePollTimer = null;
|
|
});
|
|
}, 1500);
|
|
}
|
|
|
|
// Inform main of the persisted channel — but ONLY the first time this page
|
|
// wires up, not on every loadSettings() re-render. This used to run
|
|
// unconditionally on every call and was caught (via Export Diagnostics)
|
|
// stomping an in-flight check/download: a redundant setChannel() call
|
|
// mid-download bumps main's checkGeneration and resets progress state,
|
|
// so the download silently loses its ability to report completion even
|
|
// though the file swap itself still happens in the background. Once
|
|
// wired, the channel select's own 'change' handler is the only thing
|
|
// that needs to tell main about a channel switch.
|
|
if (!_appUpdatesWired) {
|
|
try {
|
|
// Render from THIS call's own result (same reasoning as the check
|
|
// button and the 'change' handler below), not just catch its
|
|
// errors. The unconditional renderStatus() at the bottom of this
|
|
// function fires a SEPARATE getStatus() round-trip immediately
|
|
// after — if that resolves before main has processed this
|
|
// setChannel() (e.g. main is still on its 'stable' boot default),
|
|
// the UI would render 'unsupported' and — since this call's own
|
|
// eventual success was never rendered — get stuck there
|
|
// permanently, even once main correctly switches channel a moment
|
|
// later. Rendering here too means whichever of the two calls
|
|
// resolves LAST wins and shows the true state, regardless of
|
|
// which order they land in.
|
|
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
|
|
_appUpdateAckedChannel = stored;
|
|
renderFrom(result);
|
|
}).catch((e) => {
|
|
console.warn('[updater] setChannel(initial) failed:', e);
|
|
});
|
|
} catch (e) {
|
|
console.warn('[updater] setChannel(initial) threw:', e);
|
|
}
|
|
}
|
|
|
|
if (!_appUpdatesWired) {
|
|
// Wire DOM listeners once. The elements live in static index.html
|
|
// and are not recreated, so re-wiring on every loadSettings() call
|
|
// would just stack duplicate handlers.
|
|
channelSelect.addEventListener('change', async () => {
|
|
const val = channelSelect.value;
|
|
if (!APP_UPDATE_CHANNELS.includes(val)) return;
|
|
console.log('[update-diag] user switched channel to', val);
|
|
try {
|
|
// Render from setChannel()'s own return value (same reasoning
|
|
// as the check button: it's computed synchronously at the
|
|
// moment of the switch, so it can't be stale, unlike a
|
|
// follow-up getStatus() call).
|
|
const result = await Promise.resolve(updateApi.setChannel(val));
|
|
// Only persist once main has actually acknowledged the switch —
|
|
// a failed setChannel() must never leave localStorage (or the
|
|
// dropdown) ahead of what main is really using.
|
|
_appUpdateAckedChannel = val;
|
|
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
|
renderFrom(result, `Channel set to ${val}.`);
|
|
} catch (e) {
|
|
console.warn('[updater] setChannel failed:', e);
|
|
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
|
|
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
|
|
}
|
|
});
|
|
|
|
checkBtn.addEventListener('click', async () => {
|
|
// In restart mode (set by renderFrom once an update is staged) the
|
|
// button applies the update instead of checking again.
|
|
if (checkBtn.dataset.mode === 'restart') {
|
|
console.log('[update-diag] user clicked Restart now');
|
|
checkBtn.disabled = true;
|
|
checkBtn.textContent = 'Restarting…';
|
|
try {
|
|
const r = await updateApi.apply();
|
|
if (r?.status === 'error') {
|
|
console.warn('[updater] apply returned error:', r.message || 'unknown');
|
|
renderFrom(r, 'Restart failed.');
|
|
}
|
|
// On success the app quits + relaunches — nothing to render.
|
|
} catch (e) {
|
|
console.warn('[updater] apply failed:', e);
|
|
statusEl.textContent = `Restart failed: ${e?.message || e}`;
|
|
checkBtn.textContent = 'Restart now';
|
|
checkBtn.disabled = false;
|
|
}
|
|
return;
|
|
}
|
|
console.log('[update-diag] user clicked Check for updates');
|
|
checkBtn.disabled = true;
|
|
statusEl.textContent = 'Checking for updates…';
|
|
let result;
|
|
try {
|
|
// The Linux check returns immediately (any download runs in the
|
|
// background).
|
|
result = await updateApi.checkNow();
|
|
} catch (e) {
|
|
console.warn('[updater] checkNow failed:', e);
|
|
statusEl.textContent = `Update check failed: ${e?.message || e}`;
|
|
checkBtn.disabled = false;
|
|
return;
|
|
}
|
|
// Render straight from checkNow()'s own return value rather than a
|
|
// follow-up getStatus() call. checkNow() computes that value
|
|
// synchronously at the moment it decides the outcome, so it can't
|
|
// be stale; a separate getStatus() round-trip right after it can
|
|
// race with anything that resets state in between (a concurrent
|
|
// channel switch, another in-flight check settling) and show a
|
|
// blanked "up to date · last checked never" even though this check
|
|
// just succeeded.
|
|
renderFrom(result);
|
|
});
|
|
|
|
// Main-process events (checkNow/download decisions in update-manager.ts)
|
|
// are invisible to this page's console — forward them into it so a
|
|
// single "Export Diagnostics" click captures both sides of the story.
|
|
if (typeof updateApi.onDiag === 'function') {
|
|
updateApi.onDiag((payload) => {
|
|
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
|
|
});
|
|
}
|
|
|
|
_appUpdatesWired = true;
|
|
}
|
|
|
|
renderStatus();
|
|
}
|
|
|
|
// Updates the fill on slider elements. Expects a CSS variable --range-pct used
|
|
// in the track fill styling. Declared as a function (not a const) so it is
|
|
// hoisted onto window — audio-mixer.js calls it as window.handleSliderInput,
|
|
// matching the window.playSong / window.showScreen cross-script convention.
|
|
export function handleSliderInput(el) {
|
|
if (!el) return;
|
|
const min = el.min || 0;
|
|
const max = el.max || 100;
|
|
const pct = (el.value - min) / (max - min) * 100;
|
|
el.style.setProperty('--range-pct', pct + '%');
|
|
}
|
|
|
|
// A/V sync calibration. Positive = audio runs ahead of visuals; we
|
|
// add this to audio.currentTime when driving the highway so the
|
|
// visuals catch up. Persisted via /api/settings as av_offset_ms.
|
|
// Live-tunable from the player screen via [ / ] keys (Shift for
|
|
// ±50 ms) and from the Settings slider; both auto-save with the
|
|
// same debounced POST. loadSettings() seeds the value via
|
|
// setAvOffsetMs without saving (skipPersist=true) to avoid an
|
|
// echo-back round-trip.
|
|
export let _avOffsetMs = 0;
|
|
|
|
export let _avSaveDebounce = null;
|
|
|
|
export function setAvOffsetMs(ms, skipPersist) {
|
|
// Clamp to the same bounds the Settings/player-bar sliders enforce
|
|
// (-1000..1000 ms). Defends against bad values from /api/settings
|
|
// landing as `value` on <input type=range>.
|
|
const n = Number(ms);
|
|
_avOffsetMs = Math.max(-1000, Math.min(1000, Number.isFinite(n) ? n : 0));
|
|
// Drive the highway's render-time shift. getTime() still returns
|
|
// the audio-aligned chart time so plugins (note detection, etc.)
|
|
// keep scoring against the real chart clock regardless of visual
|
|
// calibration.
|
|
if (window.highway?.setAvOffset) window.highway.setAvOffset(_avOffsetMs);
|
|
// Sync any visible Settings slider
|
|
const avSlider = document.getElementById('setting-av-offset');
|
|
if (avSlider) {
|
|
avSlider.value = _avOffsetMs;
|
|
handleSliderInput(avSlider);
|
|
}
|
|
const avVal = document.getElementById('setting-av-offset-val');
|
|
if (avVal) avVal.textContent = Math.round(_avOffsetMs);
|
|
// Sync the inline player-bar slider (live-tunable while playing)
|
|
const playerAvSlider = document.getElementById('player-av-offset-slider');
|
|
if (playerAvSlider) {
|
|
playerAvSlider.value = _avOffsetMs;
|
|
handleSliderInput(playerAvSlider);
|
|
}
|
|
const playerAvLabel = document.getElementById('player-av-offset-label');
|
|
if (playerAvLabel) {
|
|
const rounded = Math.round(_avOffsetMs);
|
|
playerAvLabel.textContent = `${rounded >= 0 ? '+' : ''}${rounded}ms`;
|
|
}
|
|
// Update the player HUD readout (hidden when offset = 0 to
|
|
// avoid clutter; the keyboard shortcut is documented in the
|
|
// Settings help text so it stays discoverable).
|
|
const hud = document.getElementById('hud-avoffset');
|
|
if (hud) {
|
|
hud.textContent = `A/V ${_avOffsetMs >= 0 ? '+' : ''}${Math.round(_avOffsetMs)} ms`;
|
|
hud.classList.toggle('hidden', _avOffsetMs === 0);
|
|
}
|
|
if (!skipPersist) _persistAvOffset();
|
|
}
|
|
|
|
export function _persistAvOffset() {
|
|
// Debounced persist — POST only the one field; the server merges.
|
|
if (_avSaveDebounce) clearTimeout(_avSaveDebounce);
|
|
_avSaveDebounce = setTimeout(async () => {
|
|
_avSaveDebounce = null;
|
|
try {
|
|
await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ av_offset_ms: _avOffsetMs }),
|
|
});
|
|
} catch (e) {
|
|
console.warn('A/V offset save failed:', e);
|
|
}
|
|
}, 400);
|
|
}
|
|
|
|
export function nudgeAvOffsetMs(delta) {
|
|
setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta)));
|
|
}
|
|
|
|
export async function saveSettings() {
|
|
const defaultArrangement = document.getElementById('default-arrangement').value;
|
|
const resp = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
dlc_dir: document.getElementById('dlc-path').value.trim(),
|
|
default_arrangement: defaultArrangement,
|
|
demucs_server_url: document.getElementById('demucs-server-url').value.trim(),
|
|
av_offset_ms: _avOffsetMs,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
_defaultArrangement = defaultArrangement;
|
|
_syncDefaultArrangementSelect(_defaultArrangement);
|
|
syncDefaultArrangementPin();
|
|
}
|
|
document.getElementById('settings-status').textContent = data.message || data.error;
|
|
}
|
|
|
|
// Persist a single settings field the instant a control changes (used by
|
|
// the Settings dropdowns). The /api/settings POST handler merges only the
|
|
// keys present in the body, so this one-field write won't clobber dlc_dir
|
|
// or any other setting. No debounce: a <select> change event fires once
|
|
// per selection, unlike the A/V / mastery sliders' per-pixel oninput.
|
|
//
|
|
// The Settings-dropdown autosaves run through one chain so their POSTs are
|
|
// sent one at a time, in the order the user made the changes — the last
|
|
// selection is always the last write, for both rapid changes to one
|
|
// dropdown and back-to-back changes across different dropdowns. The A/V
|
|
// and mastery slider autosaves POST directly (not through this chain);
|
|
// the server-side config.json lock is what keeps those from racing the
|
|
// dropdown writes (see save_settings() in server.py).
|
|
export let _settingSaveChain = Promise.resolve();
|
|
|
|
export function persistSetting(key, value) {
|
|
const next = _settingSaveChain.then(() => _postSetting(key, value));
|
|
// Swallow failures so one failed write doesn't poison the chain and
|
|
// block every later save.
|
|
_settingSaveChain = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
export function setInstrumentPathway(value) {
|
|
const pathway = _normalizeInstrumentPathway(value);
|
|
const el = document.getElementById('setting-instrument-pathway');
|
|
if (el) el.value = pathway;
|
|
persistSetting('pathway', pathway).then(() => {
|
|
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
|
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function _postSetting(key, value) {
|
|
const status = document.getElementById('settings-status');
|
|
try {
|
|
const resp = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [key]: value }),
|
|
});
|
|
const data = await resp.json();
|
|
if (status) status.textContent = data.message || data.error || '';
|
|
} catch (e) {
|
|
if (status) status.textContent = 'Save failed: ' + e.message;
|
|
}
|
|
}
|