import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; import { normalizeSettings, mergeSettings, settingVisible } from './settings-values.js'; function readJson(file, optional = false) { try { const [, bytes] = file.load_contents(null); const value = JSON.parse(new TextDecoder().decode(bytes)); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a JSON object'); return value; } catch (error) { if (optional && error.matches?.(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND)) return {}; throw error; } } export function daemonCall(method, signature = null, values = null) { return new Promise((resolve, reject) => Gio.DBus.session.call( 'io.qvac.Jarvis', '/io/qvac/Jarvis', 'io.qvac.Jarvis.Session', method, signature ? new GLib.Variant(signature, values) : null, null, Gio.DBusCallFlags.NONE, 180000, null, (_source, result) => { try { resolve(Gio.DBus.session.call_finish(result).deep_unpack()); } catch (error) { reject(error); } }, )); } async function audioDevices(mediaClass) { const process = Gio.Subprocess.new(['pw-dump'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); let timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 5000, () => { timeout = 0; process.force_exit(); return GLib.SOURCE_REMOVE; }); try { const output = await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => { try { const [, stdout, stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'PipeWire unavailable'); resolve(stdout); } catch (error) { reject(error); } })); return JSON.parse(output).filter(item => item.info?.props?.['media.class'] === mediaClass).map(item => ({ value: item.info.props['node.name'], label: item.info.props['node.description'] || item.info.props['node.nick'] || item.info.props['node.name'] })).filter(item => item.value); } finally { if (timeout) GLib.Source.remove(timeout); } } export class SettingsEditor { constructor(directory, legacySettings = null) { this.fields = readJson(Gio.File.new_for_path(`${directory}/settings-catalog.json`)).fields; this.file = Gio.File.new_for_path(GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis', 'config.json'])); this.changes = {}; this.rows = new Map(); this.statusRows = []; this.applyButtons = []; this.previewButtons = []; this.busy = false; try { this.source = readJson(this.file, true); } catch (error) { this.source = {}; this.loadError = error.message; } // Existing JSON is authoritative. Migrate only explicitly changed GSettings, // never its defaults, and never write simply because preferences opened. const legacy = { 'wake-phrase': 'wakePhrase', aliases: 'aliases', 'voice-id': 'voiceId', language: 'asrLanguage', 'tts-enabled': 'ttsEnabled', 'model-profile': 'modelProfile', 'computer-use-mode': 'computerMode' }; if (!this.loadError && legacySettings) for (const [key, canonical] of Object.entries(legacy)) { const field = this.fields.find(item => item.key === canonical); if ([canonical, ...(field.aliases || [])].some(name => this.source[name] != null)) continue; const value = legacySettings.get_user_value(key); if (value) this.changes[canonical] = value.deep_unpack(); } this.values = normalizeSettings({ ...this.source, ...this.changes }, this.fields); } status(message) { this.message = message; for (const row of this.statusRows) row.subtitle = message; } setValue(field, value) { if (this._syncing) return; this.values[field.key] = value; this.changes[field.key] = value; this.status('Unsaved changes. Apply to use these settings.'); this.refreshVisibility(); } refreshVisibility() { for (const field of this.fields) { const row = this.rows.get(field.key); if (row) row.visible = settingVisible(field, this.values); } for (const button of this.previewButtons) button.sensitive = !this.busy && this.values.ttsEnabled; } row(field, parent) { let row; const changed = value => this.setValue(field, value); if (field.type === 'boolean') { row = new Adw.SwitchRow({ title: field.title, subtitle: field.description, active: this.values[field.key] }); row.connect('notify::active', () => changed(row.active)); row._setValue = value => { row.active = value; }; } else if (field.type === 'choice') { row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(field.options.map(option => option.label)) }); row.selected = Math.max(0, field.options.findIndex(option => option.value === this.values[field.key])); row.connect('notify::selected', () => { if (field.options[row.selected]) changed(field.options[row.selected].value); }); row._setValue = value => { row.selected = field.options.findIndex(option => option.value === value); }; } else if (field.type === 'device') { let options = [{ value: '', label: 'System default' }]; if (this.values[field.key]) options.push({ value: this.values[field.key], label: this.values[field.key] }); row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(options.map(o => o.label)), selected: options.length - 1 }); let updating = false; row.connect('notify::selected', () => { if (!updating && options[row.selected]) changed(options[row.selected].value); }); const refresh = new Gtk.Button({ label: 'Refresh', valign: Gtk.Align.CENTER }); const scan = async () => { refresh.sensitive = false; try { const devices = await audioDevices(field.key === 'inputTarget' ? 'Audio/Source' : 'Audio/Sink'); options = [{ value: '', label: 'System default' }, ...devices]; if (this.values[field.key] && !options.some(o => o.value === this.values[field.key])) options.push({ value: this.values[field.key], label: `${this.values[field.key]} (not connected)` }); updating = true; row.model = Gtk.StringList.new(options.map(o => o.label)); row.selected = Math.max(0, options.findIndex(o => o.value === this.values[field.key])); row.subtitle = field.description; } catch (error) { row.subtitle = `Could not list devices: ${error.message}`; } finally { updating = false; refresh.sensitive = true; } }; refresh.connect('clicked', scan); row.add_suffix(refresh); row._setValue = value => { row.selected = Math.max(0, options.findIndex(o => o.value === value)); }; scan(); } else if (field.type === 'number') { row = new Adw.SpinRow({ title: field.title, subtitle: field.description, digits: field.step < 1 ? 2 : 0, adjustment: new Gtk.Adjustment({ lower: field.min, upper: field.max, step_increment: field.step, page_increment: field.step * 5, value: this.values[field.key] }) }); row.connect('notify::value', () => changed(row.value)); row._setValue = value => { row.value = value; }; } else if (field.type === 'file') { row = new Adw.ActionRow({ title: field.title, subtitle: this.values[field.key] || field.description }); const choose = new Gtk.Button({ label: 'Choose WAV…', valign: Gtk.Align.CENTER }); const clear = new Gtk.Button({ label: 'Clear', valign: Gtk.Align.CENTER }); choose.connect('clicked', () => { const dialog = new Gtk.FileChooserNative({ title: 'Choose a reference voice recording', transient_for: parent, action: Gtk.FileChooserAction.OPEN, accept_label: 'Choose' }); const filter = new Gtk.FileFilter(); filter.set_name('WAV audio'); filter.add_pattern('*.wav'); filter.add_pattern('*.WAV'); dialog.add_filter(filter); dialog.connect('response', (_dialog, response) => { if (response === Gtk.ResponseType.ACCEPT) { const file = dialog.get_file()?.get_path(); if (file) { changed(file); row.subtitle = file; } } dialog.destroy(); }); dialog.show(); }); clear.connect('clicked', () => { changed(''); row.subtitle = field.description; }); row.add_suffix(choose); row.add_suffix(clear); row._setValue = value => { row.subtitle = value || field.description; }; } else { row = new Adw.EntryRow({ title: field.title, tooltip_text: field.description, text: field.type === 'list' ? this.values[field.key].join(', ') : this.values[field.key] }); row.connect('changed', () => changed(field.type === 'list' ? row.text.split(',').map(v => v.trim()).filter(Boolean) : row.text)); row._setValue = value => { row.text = field.type === 'list' ? value.join(', ') : value; }; } this.rows.set(field.key, row); return row; } controls(page, fields, { preview = false } = {}) { const group = new Adw.PreferencesGroup({ title: preview ? 'Make it sound like you want' : 'Apply your changes', description: 'Voice, listening, and desktop controls apply here. Chat model and agent limits require restarting Jarvis.' }); const row = new Adw.ActionRow({ title: preview ? 'Listen before you settle on a voice' : 'Settings', subtitle: this.loadError ? `Could not read config.json: ${this.loadError}` : 'Saved locally. Nothing changes until you press Apply.' }); this.statusRows.push(row); const apply = new Gtk.Button({ label: 'Apply', valign: Gtk.Align.CENTER }); apply.add_css_class('suggested-action'); apply.connect('clicked', () => this.apply()); this.applyButtons.push(apply); row.add_suffix(apply); if (preview) { const listen = new Gtk.Button({ label: 'Apply & Preview', valign: Gtk.Align.CENTER }); listen.connect('clicked', () => this.apply(true)); this.previewButtons.push(listen); row.add_suffix(listen); const stop = new Gtk.Button({ label: 'Stop', valign: Gtk.Align.CENTER }); stop.connect('clicked', () => daemonCall('StopSpeech').catch(error => this.status(error.message))); row.add_suffix(stop); } group.add(row); const reset = new Adw.ActionRow({ title: 'Reset this page', subtitle: 'Restore defaults in the form, then Apply to save them.' }); const button = new Gtk.Button({ label: 'Reset', valign: Gtk.Align.CENTER }); button.connect('clicked', () => { for (const field of fields) { this.setValue(field, field.default); this.rows.get(field.key)?._setValue(field.default); } }); reset.add_suffix(button); group.add(reset); page.add(group); } page(parent, title, groups, iconName, preview = false) { const page = new Adw.PreferencesPage({ title, icon_name: iconName }); const fields = this.fields.filter(field => groups.includes(field.group)); this.controls(page, fields, { preview }); for (const name of groups) { const group = new Adw.PreferencesGroup({ title: name }); for (const field of fields.filter(item => item.group === name)) { group.add(this.row(field, parent)); // EntryRow has no subtitle. Show advanced guidance underneath it. if (['string', 'list'].includes(field.type)) { const help = new Gtk.Label({ label: field.description, wrap: true, xalign: 0, margin_start: 12, margin_end: 12, margin_bottom: 8 }); help.add_css_class('dim-label'); this.rows.get(field.key).bind_property('visible', help, 'visible', 2); group.add(help); } } page.add(group); } this.refreshVisibility(); return page; } save() { if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`); // Re-read on save so another window's unrelated changes survive. const current = readJson(this.file, true); const merged = mergeSettings(current, this.changes, this.fields); const validated = normalizeSettings(merged, this.fields, { strict: true }); for (const key of Object.keys(this.changes)) merged[key] = validated[key]; GLib.mkdir_with_parents(this.file.get_parent().get_path(), 0o700); this.file.replace_contents(`${JSON.stringify(merged, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); this.source = merged; this.changes = {}; this.values = validated; this._syncing = true; try { for (const field of this.fields) this.rows.get(field.key)?._setValue(validated[field.key]); } finally { this._syncing = false; this.refreshVisibility(); } return validated; } async restart() { if (this.busy) return; this.busy = true; try { this.save(); this.status('Saved. Restarting Jarvis…'); const process = Gio.Subprocess.new(['systemctl', '--user', 'restart', 'jarvisd.service'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => { try { const [, , stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'Restart failed'); resolve(); } catch (error) { reject(error); } })); this.status('Jarvis restarted. Voice models may take a moment to become ready.'); } catch (error) { this.status(`Saved settings may still need applying: ${error.message}`); } finally { this.busy = false; this.refreshVisibility(); } } async apply(preview = false) { if (this.busy) return; this.busy = true; for (const button of this.applyButtons) button.sensitive = false; this.refreshVisibility(); try { if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`); const validated = this.save(); this.status('Saved. Applying settings and preparing voice models…'); let reply; try { [reply] = await daemonCall('ReloadSettings'); } catch (error) { throw new Error(`Saved, but not applied: ${error.message}`); } const status = JSON.parse(reply); const errors = Object.entries(status.voice?.errors || {}).filter(([key]) => key !== 'tts' || validated.ttsEnabled).map(([key, message]) => `${key}: ${message}`); const restart = status.restartRequired?.length ? ' Restart Jarvis for chat model / agent changes.' : ''; const overrides = status.environmentOverrides?.length ? ` Environment overrides: ${status.environmentOverrides.join(', ')}.` : ''; if (errors.length) { this.status(`Applied.${restart}${overrides} ${errors.join(' · ')}`); return; } if (preview) { this.status('Playing your voice preview…'); await daemonCall('PreviewVoice', '(s)', [validated.previewText]); } this.status(`Applied.${restart}${overrides}${preview ? ' Preview finished.' : ''}`); } catch (error) { this.status(error.message); } finally { this.busy = false; for (const button of this.applyButtons) button.sensitive = true; this.refreshVisibility(); } } }