| import './styles/main.css'; |
| import './styles/settings-window.css'; |
| import { SettingsManager } from '@/services/settings-manager'; |
| import { exportSettings, importSettings, type ImportResult } from '@/utils/settings-persistence'; |
| import { |
| SETTINGS_CATEGORIES, |
| HUMAN_LABELS, |
| SIGNUP_URLS, |
| PLAINTEXT_KEYS, |
| MASKED_SENTINEL, |
| type SettingsCategory, |
| } from '@/services/settings-constants'; |
| import { fetchOllamaModels } from '@/services/ollama-models'; |
| import { |
| RUNTIME_FEATURES, |
| getEffectiveSecrets, |
| getRuntimeConfigSnapshot, |
| getSecretState, |
| isFeatureAvailable, |
| isFeatureEnabled, |
| setFeatureToggle, |
| setSecretValue, |
| validateSecret, |
| loadDesktopSecrets, |
| type RuntimeFeatureDefinition, |
| type RuntimeFeatureId, |
| type RuntimeSecretKey, |
| } from '@/services/runtime-config'; |
| import { isDesktopRuntime, resolveLocalApiPort, startSmartPollLoop, type SmartPollLoopHandle } from '@/services/runtime'; |
| import { proxyLocalApiRequest, tryInvokeTauri, invokeTauri } from '@/services/tauri-bridge'; |
| import { escapeHtml } from '@/utils/sanitize'; |
| import { initI18n, t } from '@/services/i18n'; |
| import { applyStoredTheme } from '@/utils/theme-manager'; |
| import { applyFont } from '@/services/font-settings'; |
| import { trackFeatureToggle } from '@/services/analytics'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
|
|
|
|
| let activeSection = 'overview'; |
| let settingsManager: SettingsManager; |
| let _diagCleanup: (() => void) | null = null; |
|
|
| function setActionStatus(message: string, tone: 'ok' | 'error' = 'ok'): void { |
| const statusEl = document.getElementById('settingsActionStatus'); |
| if (!statusEl) return; |
| statusEl.textContent = message; |
| statusEl.classList.remove('ok', 'error'); |
| statusEl.classList.add(tone); |
| } |
|
|
| async function invokeDesktopAction(command: string, successLabel: string): Promise<void> { |
| const result = await tryInvokeTauri<string>(command); |
| if (result) { |
| setActionStatus(`${successLabel}: ${result}`, 'ok'); |
| return; |
| } |
| setActionStatus(t('modals.settingsWindow.invokeFail', { command }), 'error'); |
| } |
|
|
| function closeSettingsWindow(): void { |
| void tryInvokeTauri<void>('close_settings_window').then(() => { }, () => window.close()); |
| } |
|
|
| async function diagFetch(path: string, init?: RequestInit): Promise<Response> { |
| return proxyLocalApiRequest(path, `http://localhost${path}`, init); |
| } |
|
|
| |
|
|
| const SIDEBAR_ICONS: Record<string, string> = { |
| overview: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95a15.65 15.65 0 00-1.38-3.56A8.03 8.03 0 0118.92 8zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56A7.987 7.987 0 015.08 16zm2.95-8H5.08a7.987 7.987 0 014.33-3.56A15.65 15.65 0 008.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 01-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>', |
| ai: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M21 10.12h-6.78l2.74-2.82c-2.73-2.7-7.15-2.8-9.88-.1s-2.73 7.08 0 9.79 7.15 2.71 9.88 0C18.32 15.65 19 14.08 19 12.1h2c0 1.98-.88 4.55-2.64 6.29-3.51 3.48-9.21 3.48-12.72 0-3.5-3.47-3.53-9.11-.02-12.58s9.14-3.49 12.65 0L21 3v7.12zM12.5 8v4.25l3.5 2.08-.72 1.21L11 13V8h1.5z"/></svg>', |
| economy: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/></svg>', |
| markets: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M5 9.2h3V19H5V9.2zM10.6 5h2.8v14h-2.8V5zm5.6 8H19v6h-2.8v-6z"/></svg>', |
| security: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>', |
| tracking: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>', |
| debug: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></svg>', |
| }; |
|
|
| |
|
|
| function getFeatureStatusCounts(cat: SettingsCategory): { ready: number; total: number } { |
| let ready = 0; |
| for (const fid of cat.features) { |
| if (isFeatureAvailable(fid)) ready++; |
| } |
| return { ready, total: cat.features.length }; |
| } |
|
|
| function getTotalProgress(): { ready: number; total: number } { |
| let ready = 0; |
| for (const f of RUNTIME_FEATURES) { |
| if (isFeatureAvailable(f.id)) ready++; |
| } |
| return { ready, total: RUNTIME_FEATURES.length }; |
| } |
|
|
| function renderSidebar(): void { |
| const nav = document.getElementById('sidebarNav'); |
| if (!nav) return; |
|
|
| const items: string[] = []; |
|
|
| const progress = getTotalProgress(); |
| const overviewDotClass = progress.ready === progress.total ? 'dot-ok' : progress.ready > 0 ? 'dot-partial' : 'dot-warn'; |
| items.push(` |
| <button class="settings-nav-item${activeSection === 'overview' ? ' active' : ''}" data-section="overview" role="tab" aria-selected="${activeSection === 'overview'}"> |
| ${SIDEBAR_ICONS.overview} |
| <span class="settings-nav-label">Overview</span> |
| <span class="settings-nav-dot ${overviewDotClass}"></span> |
| </button> |
| `); |
|
|
| items.push('<div class="settings-nav-sep"></div>'); |
|
|
| for (const cat of SETTINGS_CATEGORIES) { |
| const { ready, total } = getFeatureStatusCounts(cat); |
| const dotClass = ready === total ? 'dot-ok' : ready > 0 ? 'dot-partial' : 'dot-warn'; |
| items.push(` |
| <button class="settings-nav-item${activeSection === cat.id ? ' active' : ''}" data-section="${cat.id}" role="tab" aria-selected="${activeSection === cat.id}"> |
| ${SIDEBAR_ICONS[cat.id] || ''} |
| <span class="settings-nav-label">${escapeHtml(cat.label)}</span> |
| <span class="settings-nav-count">${ready}/${total}</span> |
| <span class="settings-nav-dot ${dotClass}"></span> |
| </button> |
| `); |
| } |
|
|
| items.push('<div class="settings-nav-sep"></div>'); |
|
|
| items.push(` |
| <button class="settings-nav-item${activeSection === 'debug' ? ' active' : ''}" data-section="debug" role="tab" aria-selected="${activeSection === 'debug'}"> |
| ${SIDEBAR_ICONS.debug} |
| <span class="settings-nav-label">Debug & Logs</span> |
| </button> |
| `); |
|
|
| setTrustedHtml(nav, trustedHtml(items.join(''), "legacy direct innerHTML migration")); |
| } |
|
|
| |
|
|
| function renderSection(sectionId: string): void { |
| const area = document.getElementById('contentArea'); |
| if (!area) return; |
|
|
| if (_diagCleanup) { _diagCleanup(); _diagCleanup = null; } |
| activeSection = sectionId; |
| renderSidebar(); |
|
|
| area.classList.add('fade-out'); |
| area.classList.remove('fade-in'); |
|
|
| requestAnimationFrame(() => { |
| if (sectionId === 'overview') { |
| renderOverview(area); |
| } else if (sectionId === 'debug') { |
| renderDebug(area); |
| } else { |
| const cat = SETTINGS_CATEGORIES.find(c => c.id === sectionId); |
| if (cat) renderFeatureSection(area, cat); |
| } |
|
|
| requestAnimationFrame(() => { |
| area.classList.remove('fade-out'); |
| area.classList.add('fade-in'); |
| }); |
| }); |
| } |
|
|
| |
|
|
| function renderOverview(area: HTMLElement): void { |
| const { ready, total } = getTotalProgress(); |
| const pct = total > 0 ? (ready / total) * 100 : 0; |
| const circumference = 2 * Math.PI * 40; |
| const dashOffset = circumference - (pct / 100) * circumference; |
| const ringColor = ready === total ? 'var(--settings-green)' : ready > 0 ? 'var(--settings-blue)' : 'var(--settings-yellow)'; |
|
|
| const wmState = getSecretState('WORLDMONITOR_API_KEY'); |
| const wmStatusText = wmState.present ? 'Active' : 'Not set'; |
| const wmStatusClass = wmState.present ? 'ok' : 'warn'; |
| const catCards = SETTINGS_CATEGORIES.map(cat => { |
| const { ready: catReady, total: catTotal } = getFeatureStatusCounts(cat); |
| const cls = catReady === catTotal ? 'ov-cat-ok' : catReady > 0 ? 'ov-cat-partial' : 'ov-cat-warn'; |
| return `<button class="settings-ov-cat ${cls}" data-section="${cat.id}"> |
| <span class="settings-ov-cat-label">${escapeHtml(cat.label)}</span> |
| <span class="settings-ov-cat-count">${catReady}/${catTotal} ready</span> |
| </button>`; |
| }).join(''); |
|
|
| setTrustedHtml(area, trustedHtml(` |
| <div class="settings-overview"> |
| <div class="settings-ov-progress"> |
| <svg class="settings-ov-ring" viewBox="0 0 100 100" width="120" height="120"> |
| <circle cx="50" cy="50" r="40" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="8"/> |
| <circle cx="50" cy="50" r="40" fill="none" stroke="${ringColor}" stroke-width="8" |
| stroke-linecap="round" stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}" |
| transform="rotate(-90 50 50)" style="transition:stroke-dashoffset 0.6s ease"/> |
| </svg> |
| <div class="settings-ov-ring-text"> |
| <span class="settings-ov-ring-num">${ready}</span> |
| <span class="settings-ov-ring-label">of ${total} ready</span> |
| </div> |
| </div> |
| <div class="settings-ov-cats">${catCards}</div> |
| </div> |
| |
| <div class="settings-ov-license"> |
| <section class="wm-section"> |
| <h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.apiKey.title')}</h2> |
| <p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.apiKey.description')}</p> |
| <div class="wm-key-row"> |
| <div class="wm-input-wrap"> |
| <input type="password" class="wm-input" data-wm-key-input |
| placeholder="${t('modals.settingsWindow.worldMonitor.apiKey.placeholder')}" |
| autocomplete="off" spellcheck="false" |
| ${wmState.present ? `value="${MASKED_SENTINEL}"` : ''} /> |
| <button type="button" class="wm-toggle-vis" data-wm-toggle title="Show/hide">👁</button> |
| </div> |
| <span class="wm-badge ${wmStatusClass}">${wmStatusText}</span> |
| </div> |
| </section> |
| |
| <div class="wm-divider"><span>${t('modals.settingsWindow.worldMonitor.dividerOr')}</span></div> |
| |
| <section class="wm-section"> |
| <h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.register.title')}</h2> |
| <p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.register.description')}</p> |
| <div class="wm-register-row"> |
| <button type="button" class="wm-submit-btn" data-wm-open-pro> |
| ${t('modals.settingsWindow.worldMonitor.register.submitBtn')} |
| </button> |
| </div> |
| </section> |
| </div> |
| `, "legacy direct innerHTML migration")); |
|
|
| initOverviewListeners(area); |
| } |
|
|
| function initOverviewListeners(area: HTMLElement): void { |
| area.querySelector('[data-wm-toggle]')?.addEventListener('click', () => { |
| const input = area.querySelector<HTMLInputElement>('[data-wm-key-input]'); |
| if (input) input.type = input.type === 'password' ? 'text' : 'password'; |
| }); |
|
|
| area.querySelector<HTMLInputElement>('[data-wm-key-input]')?.addEventListener('input', (e) => { |
| const input = e.target as HTMLInputElement; |
| if (input.value.startsWith(MASKED_SENTINEL)) { |
| input.value = input.value.slice(MASKED_SENTINEL.length); |
| } |
| }); |
|
|
| area.querySelector('[data-wm-open-pro]')?.addEventListener('click', () => { |
| const url = 'https://worldmonitor.app/pro'; |
| void invokeTauri<void>('open_url', { url }).catch(() => window.open(url, '_blank', 'noopener,noreferrer')); |
| }); |
|
|
| area.querySelectorAll<HTMLButtonElement>('.settings-ov-cat[data-section]').forEach(btn => { |
| btn.addEventListener('click', () => { |
| const section = btn.dataset.section; |
| if (section) renderSection(section); |
| }); |
| }); |
| } |
|
|
| |
|
|
| function renderFeatureSection(area: HTMLElement, cat: SettingsCategory): void { |
| const features = cat.features |
| .map(fid => RUNTIME_FEATURES.find(f => f.id === fid)) |
| .filter(Boolean) as RuntimeFeatureDefinition[]; |
|
|
| const featureCards = features.map(feature => { |
| const enabled = isFeatureEnabled(feature.id); |
| const available = isFeatureAvailable(feature.id); |
| const effectiveSecrets = getEffectiveSecrets(feature); |
| const allStaged = !available && effectiveSecrets.every( |
| k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false) |
| ); |
| const borderClass = available ? 'ready' : allStaged ? 'staged' : 'needs'; |
| const pillClass = available ? 'ok' : allStaged ? 'staged' : 'warn'; |
| const pillLabel = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys'; |
| const secretRows = effectiveSecrets.map(key => renderSecretInput(key, feature.id)).join(''); |
| const fallbackHtml = (available || allStaged) ? '' : `<p class="settings-feat-fallback">${escapeHtml(feature.fallback)}</p>`; |
|
|
| return ` |
| <div class="settings-feat ${borderClass}" data-feature-id="${feature.id}"> |
| <div class="settings-feat-header" data-feat-toggle-expand="${feature.id}"> |
| <label class="settings-feat-toggle-label" data-click-stop> |
| <div class="settings-feat-switch"> |
| <input type="checkbox" data-toggle="${feature.id}" ${enabled ? 'checked' : ''} /> |
| <span class="settings-feat-slider"></span> |
| </div> |
| </label> |
| <div class="settings-feat-info"> |
| <span class="settings-feat-name">${escapeHtml(feature.name)}</span> |
| <span class="settings-feat-desc">${escapeHtml(feature.description)}</span> |
| </div> |
| <span class="settings-feat-pill ${pillClass}">${pillLabel}</span> |
| <span class="settings-feat-chevron">▸</span> |
| </div> |
| <div class="settings-feat-body"> |
| ${secretRows} |
| ${fallbackHtml} |
| </div> |
| </div> |
| `; |
| }).join(''); |
|
|
| setTrustedHtml(area, trustedHtml(` |
| <div class="settings-section-header"> |
| <h2>${escapeHtml(cat.label)}</h2> |
| </div> |
| <div class="settings-feat-list">${featureCards}</div> |
| `, "legacy direct innerHTML migration")); |
|
|
| initFeatureSectionListeners(area); |
| } |
|
|
| function renderSecretInput(key: RuntimeSecretKey, _featureId: RuntimeFeatureId): string { |
| const state = getSecretState(key); |
| const pending = settingsManager.hasPending(key); |
| const { validated, message } = settingsManager.getValidationState(key); |
| const label = HUMAN_LABELS[key] || key; |
| const signupUrl = SIGNUP_URLS[key]; |
| const isPlaintext = PLAINTEXT_KEYS.has(key); |
| const showGetKey = signupUrl && !state.present && !pending; |
|
|
| const statusText = pending |
| ? (validated === false ? 'Invalid' : 'Staged') |
| : !state.present ? 'Missing' : state.valid ? 'Valid' : 'Looks invalid'; |
| const statusClass = pending |
| ? (validated === false ? 'warn' : 'staged') |
| : state.valid ? 'ok' : 'warn'; |
| const inputClass = pending ? (validated === false ? 'invalid' : 'valid-staged') : ''; |
| const hintText = pending && validated === false ? (message || 'Invalid value') : null; |
|
|
| if (key === 'OLLAMA_MODEL') { |
| const storedModel = pending |
| ? settingsManager.getPending(key) || '' |
| : getRuntimeConfigSnapshot().secrets[key]?.value || ''; |
| return ` |
| <div class="settings-secret-row"> |
| <div class="settings-secret-label">${escapeHtml(label)}</div> |
| <span class="settings-secret-status ${statusClass}">${escapeHtml(statusText)}</span> |
| <select data-model-select data-feature="${_featureId}" class="${inputClass}"> |
| ${storedModel ? `<option value="${escapeHtml(storedModel)}" selected>${escapeHtml(storedModel)}</option>` : '<option value="" selected disabled>Loading models...</option>'} |
| </select> |
| <input type="text" data-model-manual data-feature="${_featureId}" class="${inputClass} hidden-input" |
| placeholder="Or type model name" autocomplete="off" |
| ${storedModel ? `value="${escapeHtml(storedModel)}"` : ''}> |
| ${hintText ? `<span class="settings-secret-hint">${escapeHtml(hintText)}</span>` : ''} |
| </div> |
| `; |
| } |
|
|
| const getKeyHtml = showGetKey |
| ? `<a href="#" data-signup-url="${signupUrl}" class="settings-secret-link">Get key</a>` |
| : ''; |
|
|
| return ` |
| <div class="settings-secret-row"> |
| <div class="settings-secret-label">${escapeHtml(label)}</div> |
| <span class="settings-secret-status ${statusClass}">${escapeHtml(statusText)}</span> |
| <div class="settings-input-wrapper${showGetKey ? ' has-suffix' : ''}"> |
| <input type="${isPlaintext ? 'text' : 'password'}" data-secret="${key}" data-feature="${_featureId}" |
| placeholder="${pending ? 'Staged' : 'Enter value...'}" autocomplete="off" class="${inputClass}" |
| ${pending ? `value="${isPlaintext ? escapeHtml(settingsManager.getPending(key) || '') : MASKED_SENTINEL}"` : (isPlaintext && state.present ? `value="${escapeHtml(getRuntimeConfigSnapshot().secrets[key]?.value || '')}"` : '')}> |
| ${getKeyHtml} |
| </div> |
| ${hintText ? `<span class="settings-secret-hint">${escapeHtml(hintText)}</span>` : ''} |
| </div> |
| `; |
| } |
|
|
| function initFeatureSectionListeners(area: HTMLElement): void { |
| area.querySelectorAll<HTMLElement>('[data-feat-toggle-expand]').forEach(header => { |
| header.addEventListener('click', (e) => { |
| if ((e.target as HTMLElement).closest('[data-click-stop]')) return; |
| const card = header.closest('.settings-feat'); |
| card?.classList.toggle('expanded'); |
| }); |
| }); |
|
|
| area.querySelectorAll<HTMLInputElement>('input[data-toggle]').forEach(input => { |
| input.addEventListener('change', () => { |
| const featureId = input.dataset.toggle as RuntimeFeatureId; |
| if (!featureId) return; |
| trackFeatureToggle(featureId, input.checked); |
| setFeatureToggle(featureId, input.checked); |
| renderSidebar(); |
| }); |
| }); |
|
|
| area.querySelectorAll<HTMLInputElement>('input[data-secret]').forEach(input => { |
| input.addEventListener('input', () => { |
| const key = input.dataset.secret as RuntimeSecretKey; |
| if (!key) return; |
| if (settingsManager.hasPending(key) && input.value.startsWith(MASKED_SENTINEL)) { |
| input.value = input.value.slice(MASKED_SENTINEL.length); |
| } |
| settingsManager.setValidation(key, true); |
| input.classList.remove('valid-staged', 'invalid'); |
| const hint = input.closest('.settings-secret-row')?.querySelector('.settings-secret-hint'); |
| if (hint) hint.remove(); |
| }); |
|
|
| input.addEventListener('blur', () => { |
| const key = input.dataset.secret as RuntimeSecretKey; |
| if (!key) return; |
| const raw = input.value.trim(); |
|
|
| if (!raw) { |
| if (settingsManager.hasPending(key)) { |
| settingsManager.deletePending(key); |
| renderSection(activeSection); |
| } |
| return; |
| } |
| if (raw === MASKED_SENTINEL) return; |
|
|
| settingsManager.setPending(key, raw); |
| const result = validateSecret(key, raw); |
| if (result.valid) { |
| settingsManager.setValidation(key, true); |
| } else { |
| settingsManager.setValidation(key, false, result.hint || 'Invalid format'); |
| } |
|
|
| if (PLAINTEXT_KEYS.has(key)) { |
| input.value = raw; |
| } else { |
| input.type = 'password'; |
| input.value = MASKED_SENTINEL; |
| } |
|
|
| input.classList.remove('valid-staged', 'invalid'); |
| input.classList.add(result.valid ? 'valid-staged' : 'invalid'); |
|
|
| const statusEl = input.closest('.settings-secret-row')?.querySelector('.settings-secret-status'); |
| if (statusEl) { |
| statusEl.textContent = result.valid ? 'Staged' : 'Invalid'; |
| statusEl.className = `settings-secret-status ${result.valid ? 'staged' : 'warn'}`; |
| } |
|
|
| const row = input.closest('.settings-secret-row'); |
| const existingHint = row?.querySelector('.settings-secret-hint'); |
| if (existingHint) existingHint.remove(); |
| if (!result.valid && result.hint) { |
| const hint = document.createElement('span'); |
| hint.className = 'settings-secret-hint'; |
| hint.textContent = result.hint; |
| row?.appendChild(hint); |
| } |
|
|
| updateFeatureCardStatus(input.dataset.feature as RuntimeFeatureId); |
|
|
| if (key === 'OLLAMA_API_URL' && result.valid) { |
| const modelSelect = area.querySelector<HTMLSelectElement>('select[data-model-select]'); |
| if (modelSelect) void loadOllamaModelsIntoSelect(modelSelect); |
| } |
|
|
| renderSidebar(); |
| }); |
| }); |
|
|
| area.querySelectorAll<HTMLAnchorElement>('a[data-signup-url]').forEach(link => { |
| link.addEventListener('click', (e) => { |
| e.preventDefault(); |
| const url = link.dataset.signupUrl; |
| if (!url) return; |
| if (isDesktopRuntime()) { |
| void invokeTauri<void>('open_url', { url }).catch(() => window.open(url, '_blank', 'noopener,noreferrer')); |
| } else { |
| window.open(url, '_blank', 'noopener,noreferrer'); |
| } |
| }); |
| }); |
|
|
| const modelSelect = area.querySelector<HTMLSelectElement>('select[data-model-select]'); |
| if (modelSelect) { |
| modelSelect.addEventListener('change', () => { |
| const model = modelSelect.value; |
| if (model) { |
| settingsManager.setPending('OLLAMA_MODEL', model); |
| settingsManager.setValidation('OLLAMA_MODEL', true); |
| modelSelect.classList.remove('invalid'); |
| modelSelect.classList.add('valid-staged'); |
| updateFeatureCardStatus('aiOllama'); |
| renderSidebar(); |
| } |
| }); |
| void loadOllamaModelsIntoSelect(modelSelect); |
| } |
| } |
|
|
| function updateFeatureCardStatus(featureId: RuntimeFeatureId): void { |
| const card = document.querySelector<HTMLElement>(`.settings-feat[data-feature-id="${featureId}"]`); |
| if (!card) return; |
| const feature = RUNTIME_FEATURES.find(f => f.id === featureId); |
| if (!feature) return; |
|
|
| const available = isFeatureAvailable(featureId); |
| const effectiveSecrets = getEffectiveSecrets(feature); |
| const allStaged = !available && effectiveSecrets.every( |
| k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false) |
| ); |
|
|
| const wasExpanded = card.classList.contains('expanded'); |
| card.className = `settings-feat ${available ? 'ready' : allStaged ? 'staged' : 'needs'}${wasExpanded ? ' expanded' : ''}`; |
|
|
| const pill = card.querySelector('.settings-feat-pill'); |
| if (pill) { |
| pill.className = `settings-feat-pill ${available ? 'ok' : allStaged ? 'staged' : 'warn'}`; |
| pill.textContent = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys'; |
| } |
| } |
|
|
| async function loadOllamaModelsIntoSelect(select: HTMLSelectElement): Promise<void> { |
| const snapshot = getRuntimeConfigSnapshot(); |
| const ollamaUrl = settingsManager.getPending('OLLAMA_API_URL') |
| || snapshot.secrets['OLLAMA_API_URL']?.value |
| || ''; |
| if (!ollamaUrl) { |
| setTrustedHtml(select, trustedHtml('<option value="" disabled selected>Set Ollama URL first</option>', "legacy direct innerHTML migration")); |
| return; |
| } |
|
|
| const currentModel = settingsManager.getPending('OLLAMA_MODEL') |
| || snapshot.secrets['OLLAMA_MODEL']?.value |
| || ''; |
|
|
| const models = await fetchOllamaModels(ollamaUrl); |
|
|
| if (models.length === 0) { |
| const manual = select.parentElement?.querySelector<HTMLInputElement>('input[data-model-manual]'); |
| if (manual) { |
| select.style.display = 'none'; |
| manual.classList.remove('hidden-input'); |
| if (!manual.dataset.listenerAttached) { |
| manual.dataset.listenerAttached = '1'; |
| manual.addEventListener('blur', () => { |
| const model = manual.value.trim(); |
| if (model) { |
| settingsManager.setPending('OLLAMA_MODEL', model); |
| settingsManager.setValidation('OLLAMA_MODEL', true); |
| manual.classList.remove('invalid'); |
| manual.classList.add('valid-staged'); |
| updateFeatureCardStatus('aiOllama'); |
| renderSidebar(); |
| } |
| }); |
| } |
| } |
| return; |
| } |
|
|
| const options = currentModel ? '' : '<option value="" selected disabled>Select a model...</option>'; |
| setTrustedHtml(select, trustedHtml(options + models.map(name => |
| `<option value="${escapeHtml(name)}" ${name === currentModel ? 'selected' : ''}>${escapeHtml(name)}</option>` |
| ).join(''), "legacy direct innerHTML migration")); |
| } |
|
|
| |
|
|
| function renderDebug(area: HTMLElement): void { |
| setTrustedHtml(area, trustedHtml(` |
| <div class="settings-section-header"> |
| <h2>Debug & Logs</h2> |
| </div> |
| <div class="debug-actions"> |
| <button id="openLogsBtn" type="button">Open Logs Folder</button> |
| <button id="openSidecarLogBtn" type="button">Open API Log</button> |
| </div> |
| <section class="debug-data-section"> |
| <h3>Data Management</h3> |
| <div class="debug-data-actions"> |
| <button type="button" class="settings-btn settings-btn-secondary" id="exportSettingsBtn"> |
| ${t('components.settings.exportSettings')} |
| </button> |
| <button type="button" class="settings-btn settings-btn-secondary" id="importSettingsBtn"> |
| ${t('components.settings.importSettings')} |
| </button> |
| <input type="file" id="importSettingsInput" accept=".json" style="display: none;" /> |
| </div> |
| </section> |
| <section class="settings-diagnostics" id="diagnosticsSection"> |
| <header class="diag-header"> |
| <h2>Diagnostics</h2> |
| <div class="diag-toggles"> |
| <label><input type="checkbox" id="verboseApiLog"> Verbose Sidecar Log</label> |
| <label><input type="checkbox" id="fetchDebugLog"> Frontend Fetch Debug</label> |
| </div> |
| </header> |
| <div class="diag-traffic-bar"> |
| <h3>API Traffic <span id="trafficCount"></span></h3> |
| <div class="diag-traffic-controls"> |
| <label><input type="checkbox" id="autoRefreshLog" checked> Auto</label> |
| <button id="refreshLogBtn" type="button">Refresh</button> |
| <button id="clearLogBtn" type="button">Clear</button> |
| </div> |
| </div> |
| <div id="trafficLog" class="diag-traffic-log"></div> |
| </section> |
| `, "legacy direct innerHTML migration")); |
|
|
| area.querySelector('#openLogsBtn')?.addEventListener('click', () => { |
| void invokeDesktopAction('open_logs_folder', t('modals.settingsWindow.openLogs')); |
| }); |
|
|
| area.querySelector('#openSidecarLogBtn')?.addEventListener('click', () => { |
| void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog')); |
| }); |
|
|
| area.querySelector('#exportSettingsBtn')?.addEventListener('click', () => { |
| exportSettings(); |
| }); |
|
|
| const importInput = area.querySelector<HTMLInputElement>('#importSettingsInput'); |
| area.querySelector('#importSettingsBtn')?.addEventListener('click', () => { |
| importInput?.click(); |
| }); |
|
|
| importInput?.addEventListener('change', async (e) => { |
| const file = (e.target as HTMLInputElement).files?.[0]; |
| if (!file) return; |
| try { |
| const result: ImportResult = await importSettings(file); |
| setActionStatus(t('components.settings.importSuccess', { count: String(result.keysImported) }), 'ok'); |
| } catch (err: unknown) { |
| if (err instanceof DOMException) { |
| if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { |
| setActionStatus(t('components.settings.importFailed') + ': storage limit reached', 'error'); |
| } else if (err.name === 'SecurityError') { |
| setActionStatus(t('components.settings.importFailed') + ': storage blocked', 'error'); |
| } else { |
| setActionStatus(`${t('components.settings.importFailed')}: ${err.message || err.name}`, 'error'); |
| } |
| } else if (err instanceof Error && err.message) { |
| setActionStatus(`${t('components.settings.importFailed')}: ${err.message}`, 'error'); |
| } else { |
| setActionStatus(t('components.settings.importFailed'), 'error'); |
| } |
| } |
| importInput.value = ''; |
| }); |
|
|
| initDiagnostics(); |
| } |
|
|
| function initDiagnostics(): void { |
| const verboseToggle = document.getElementById('verboseApiLog') as HTMLInputElement | null; |
| const fetchDebugToggle = document.getElementById('fetchDebugLog') as HTMLInputElement | null; |
| const autoRefreshToggle = document.getElementById('autoRefreshLog') as HTMLInputElement | null; |
| const refreshBtn = document.getElementById('refreshLogBtn'); |
| const clearBtn = document.getElementById('clearLogBtn'); |
| const trafficLogEl = document.getElementById('trafficLog'); |
| const trafficCount = document.getElementById('trafficCount'); |
|
|
| if (fetchDebugToggle) { |
| fetchDebugToggle.checked = localStorage.getItem('wm-debug-log') === '1'; |
| fetchDebugToggle.addEventListener('change', () => { |
| localStorage.setItem('wm-debug-log', fetchDebugToggle.checked ? '1' : '0'); |
| }); |
| } |
|
|
| async function syncVerboseState(): Promise<void> { |
| if (!verboseToggle) return; |
| try { |
| const res = await diagFetch('/api/local-debug-toggle'); |
| const data = await res.json(); |
| verboseToggle.checked = data.verboseMode; |
| } catch { } |
| } |
|
|
| verboseToggle?.addEventListener('change', async () => { |
| try { |
| const res = await diagFetch('/api/local-debug-toggle', { method: 'POST' }); |
| const data = await res.json(); |
| if (verboseToggle) verboseToggle.checked = data.verboseMode; |
| setActionStatus(data.verboseMode ? t('modals.settingsWindow.verboseOn') : t('modals.settingsWindow.verboseOff'), 'ok'); |
| } catch { |
| setActionStatus(t('modals.settingsWindow.sidecarError'), 'error'); |
| } |
| }); |
|
|
| void syncVerboseState(); |
|
|
| async function refreshTrafficLog(): Promise<void> { |
| if (!trafficLogEl) return; |
| try { |
| const res = await diagFetch('/api/local-traffic-log'); |
| const data = await res.json(); |
| const entries: Array<{ timestamp: string; method: string; path: string; status: number; durationMs: number }> = data.entries || []; |
| if (trafficCount) trafficCount.textContent = `(${entries.length})`; |
|
|
| if (entries.length === 0) { |
| setTrustedHtml(trafficLogEl, trustedHtml(`<p class="diag-empty">${t('modals.settingsWindow.noTraffic')}</p>`, "legacy direct innerHTML migration")); |
| return; |
| } |
|
|
| const rows = entries.slice().reverse().map((e) => { |
| const ts = e.timestamp.split('T')[1]?.replace('Z', '') || e.timestamp; |
| const cls = e.status < 300 ? 'ok' : e.status < 500 ? 'warn' : 'err'; |
| return `<tr class="diag-${cls}"><td>${escapeHtml(ts)}</td><td>${e.method}</td><td title="${escapeHtml(e.path)}">${escapeHtml(e.path)}</td><td>${e.status}</td><td>${e.durationMs}ms</td></tr>`; |
| }).join(''); |
|
|
| setTrustedHtml(trafficLogEl, trustedHtml(`<table class="diag-table"><thead><tr><th>${t('modals.settingsWindow.table.time')}</th><th>${t('modals.settingsWindow.table.method')}</th><th>${t('modals.settingsWindow.table.path')}</th><th>${t('modals.settingsWindow.table.status')}</th><th>${t('modals.settingsWindow.table.duration')}</th></tr></thead><tbody>${rows}</tbody></table>`, "legacy direct innerHTML migration")); |
| } catch { |
| setTrustedHtml(trafficLogEl, trustedHtml(`<p class="diag-empty">${t('modals.settingsWindow.sidecarUnreachable')}</p>`, "legacy direct innerHTML migration")); |
| } |
| } |
|
|
| refreshBtn?.addEventListener('click', () => void refreshTrafficLog()); |
|
|
| clearBtn?.addEventListener('click', async () => { |
| try { await diagFetch('/api/local-traffic-log', { method: 'DELETE' }); } catch { } |
| if (trafficLogEl) setTrustedHtml(trafficLogEl, trustedHtml(`<p class="diag-empty">${t('modals.settingsWindow.logCleared')}</p>`, "legacy direct innerHTML migration")); |
| if (trafficCount) trafficCount.textContent = '(0)'; |
| }); |
|
|
| let refreshPollLoop: SmartPollLoopHandle | null = null; |
|
|
| function startAutoRefresh(): void { |
| stopAutoRefresh(); |
| refreshPollLoop = startSmartPollLoop(() => refreshTrafficLog(), { |
| intervalMs: 3000, |
| pauseWhenHidden: true, |
| refreshOnVisible: true, |
| runImmediately: true, |
| jitterFraction: 0, |
| }); |
| } |
|
|
| function stopAutoRefresh(): void { |
| if (refreshPollLoop) { refreshPollLoop.stop(); refreshPollLoop = null; } |
| } |
|
|
| autoRefreshToggle?.addEventListener('change', () => { |
| if (autoRefreshToggle.checked) startAutoRefresh(); else stopAutoRefresh(); |
| }); |
|
|
| startAutoRefresh(); |
|
|
| _diagCleanup = stopAutoRefresh; |
| } |
|
|
| |
|
|
| function highlightMatch(text: string, query: string): string { |
| const escaped = escapeHtml(text); |
| const qEscaped = escapeHtml(query); |
| if (!qEscaped) return escaped; |
| const regex = new RegExp(`(${qEscaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); |
| return escaped.replace(regex, '<mark>$1</mark>'); |
| } |
|
|
| function handleSearch(query: string): void { |
| const area = document.getElementById('contentArea'); |
| if (!area) return; |
|
|
| if (!query.trim()) { |
| renderSection(activeSection); |
| return; |
| } |
|
|
| const q = query.toLowerCase(); |
| const matches: Array<{ feature: RuntimeFeatureDefinition; catLabel: string }> = []; |
|
|
| for (const cat of SETTINGS_CATEGORIES) { |
| for (const fid of cat.features) { |
| const feature = RUNTIME_FEATURES.find(f => f.id === fid); |
| if (!feature) continue; |
| const searchable = [ |
| feature.name, |
| feature.description, |
| ...getEffectiveSecrets(feature).map(k => HUMAN_LABELS[k] || k), |
| ].join(' ').toLowerCase(); |
| if (searchable.includes(q)) { |
| matches.push({ feature, catLabel: cat.label }); |
| } |
| } |
| } |
|
|
| if (matches.length === 0) { |
| setTrustedHtml(area, trustedHtml(`<div class="settings-search-empty"><p>No features match "${escapeHtml(query)}"</p></div>`, "legacy direct innerHTML migration")); |
| return; |
| } |
|
|
| const cards = matches.map(({ feature, catLabel }) => { |
| const enabled = isFeatureEnabled(feature.id); |
| const available = isFeatureAvailable(feature.id); |
| const effectiveSecrets = getEffectiveSecrets(feature); |
| const allStaged = !available && effectiveSecrets.every( |
| k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false) |
| ); |
| const borderClass = available ? 'ready' : allStaged ? 'staged' : 'needs'; |
| const pillClass = available ? 'ok' : allStaged ? 'staged' : 'warn'; |
| const pillLabel = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys'; |
| const secretRows = effectiveSecrets.map(key => renderSecretInput(key, feature.id)).join(''); |
|
|
| return ` |
| <div class="settings-feat ${borderClass} expanded" data-feature-id="${feature.id}"> |
| <div class="settings-feat-header" data-feat-toggle-expand="${feature.id}"> |
| <label class="settings-feat-toggle-label" data-click-stop> |
| <div class="settings-feat-switch"> |
| <input type="checkbox" data-toggle="${feature.id}" ${enabled ? 'checked' : ''} /> |
| <span class="settings-feat-slider"></span> |
| </div> |
| </label> |
| <div class="settings-feat-info"> |
| <span class="settings-feat-name">${highlightMatch(feature.name, query)}</span> |
| <span class="settings-feat-desc">${highlightMatch(feature.description, query)}</span> |
| </div> |
| <span class="settings-feat-pill ${pillClass}">${pillLabel}</span> |
| <span class="settings-feat-chevron">▸</span> |
| </div> |
| <div class="settings-feat-body"> |
| <div class="settings-feat-cat-tag">${escapeHtml(catLabel)}</div> |
| ${secretRows} |
| </div> |
| </div> |
| `; |
| }).join(''); |
|
|
| setTrustedHtml(area, trustedHtml(` |
| <div class="settings-section-header"> |
| <h2>Search results for "${escapeHtml(query)}"</h2> |
| </div> |
| <div class="settings-feat-list">${cards}</div> |
| `, "legacy direct innerHTML migration")); |
|
|
| initFeatureSectionListeners(area); |
| } |
|
|
| |
|
|
| async function initSettingsWindow(): Promise<void> { |
| await initI18n(); |
| applyStoredTheme(); |
| applyFont(); |
|
|
| |
| |
| |
| document.title = t('modals.settingsWindow.shellTitle'); |
| const headerTitle = document.querySelector('.settings-header-title'); |
| if (headerTitle) headerTitle.textContent = t('modals.settingsWindow.shellTitle'); |
| const searchInputEl = document.getElementById('settingsSearch') as HTMLInputElement | null; |
| if (searchInputEl) searchInputEl.placeholder = t('modals.settingsWindow.shellSearchPlaceholder'); |
| const cancelEl = document.getElementById('cancelBtn'); |
| if (cancelEl) cancelEl.textContent = t('modals.settingsWindow.shellCancel'); |
| const okEl = document.getElementById('okBtn'); |
| if (okEl) okEl.textContent = t('modals.settingsWindow.shellSaveClose'); |
|
|
| try { await resolveLocalApiPort(); } catch { } |
|
|
| requestAnimationFrame(() => { |
| document.documentElement.classList.remove('no-transition'); |
| }); |
|
|
| await loadDesktopSecrets(); |
| settingsManager = new SettingsManager(); |
|
|
| renderSection('overview'); |
|
|
| document.getElementById('sidebarNav')?.addEventListener('click', (e) => { |
| const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-section]'); |
| if (btn?.dataset.section) { |
| renderSection(btn.dataset.section); |
| } |
| }); |
|
|
| const searchInput = document.getElementById('settingsSearch') as HTMLInputElement | null; |
| let searchTimeout: ReturnType<typeof setTimeout>; |
| searchInput?.addEventListener('input', () => { |
| clearTimeout(searchTimeout); |
| searchTimeout = setTimeout(() => handleSearch(searchInput.value), 200); |
| }); |
|
|
| document.getElementById('okBtn')?.addEventListener('click', () => { |
| void (async () => { |
| try { |
| const wmKeyInput = document.querySelector<HTMLInputElement>('[data-wm-key-input]'); |
| const wmKeyValue = wmKeyInput?.value.trim(); |
| const hasWmKeyChange = !!(wmKeyValue && wmKeyValue !== MASKED_SENTINEL && wmKeyValue.length > 0); |
|
|
| const contentArea = document.getElementById('contentArea'); |
| if (contentArea) settingsManager.captureUnsavedInputs(contentArea); |
|
|
| const hasPending = settingsManager.hasPendingChanges(); |
| if (!hasPending && !hasWmKeyChange) { |
| closeSettingsWindow(); |
| return; |
| } |
|
|
| if (hasWmKeyChange && wmKeyValue) { |
| await setSecretValue('WORLDMONITOR_API_KEY', wmKeyValue); |
| } |
|
|
| if (hasPending) { |
| setActionStatus(t('modals.settingsWindow.validating'), 'ok'); |
| const missingRequired = settingsManager.getMissingRequiredSecrets(); |
| if (missingRequired.length > 0) { |
| setActionStatus(`Missing required: ${missingRequired.join(', ')}`, 'error'); |
| return; |
| } |
| const errors = await settingsManager.verifyPendingSecrets(); |
| if (errors.length > 0) { |
| setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: errors.join(', ') }), 'error'); |
| return; |
| } |
| await settingsManager.commitVerifiedSecrets(); |
| } |
|
|
| setActionStatus(t('modals.settingsWindow.saved'), 'ok'); |
| closeSettingsWindow(); |
| } catch (err) { |
| console.error('[settings] save error:', err); |
| setActionStatus(t('modals.settingsWindow.failed', { error: String(err) }), 'error'); |
| } |
| })(); |
| }); |
|
|
| document.getElementById('cancelBtn')?.addEventListener('click', () => { |
| closeSettingsWindow(); |
| }); |
|
|
| window.addEventListener('beforeunload', () => { |
| settingsManager.destroy(); |
| }); |
| } |
|
|
| localStorage.setItem('wm-settings-open', '1'); |
| window.addEventListener('beforeunload', () => localStorage.removeItem('wm-settings-open')); |
|
|
| void initSettingsWindow(); |
|
|