Files
gnome-jarvis/apps/gnome-extension/[email protected]/settings-editor.js
T
snxraven af9246151f
Rolling release / release (push) Failing after 1m50s
Updates
2026-09-14 11:31:41 -04:00

260 lines
16 KiB
JavaScript

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.mediaClass || (field.key === 'inputTarget' ? 'Audio/Source' : field.key === 'webcamDevice' ? 'Video/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 if (field.type === 'text') {
const box = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, spacing: 6, margin_start: 12, margin_end: 12, margin_top: 6, margin_bottom: 8 });
const title = new Gtk.Label({ label: field.title, xalign: 0, wrap: true });
title.add_css_class('heading');
const help = new Gtk.Label({ label: field.description, wrap: true, xalign: 0 });
help.add_css_class('dim-label');
const view = new Gtk.TextView({ wrap_mode: Gtk.WrapMode.WORD_CHAR, accepts_tab: false, left_margin: 8, right_margin: 8, top_margin: 8, bottom_margin: 8 });
view.add_css_class('jarvis-prompt');
const text = String(this.values[field.key] || '');
view.buffer.set_text(text, -1);
view.buffer.connect('changed', () => {
const buffer = view.buffer;
changed(buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false));
});
const scroll = new Gtk.ScrolledWindow({
min_content_height: 140,
max_content_height: 220,
hexpand: true,
vexpand: false,
has_frame: true,
propagate_natural_height: true,
});
scroll.set_child(view);
box.append(title);
box.append(help);
box.append(scroll);
box._setValue = value => {
const next = String(value || '');
const buffer = view.buffer;
if (buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false) !== next) buffer.set_text(next, -1);
};
row = box;
} else if (field.type === 'password') {
row = new Adw.PasswordEntryRow({ title: field.title, tooltip_text: field.description, text: this.values[field.key] || '' });
row.connect('changed', () => changed(row.text));
row._setValue = value => { row.text = value || ''; };
} 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, desktop, and Obsidian 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, intro = null, { controls = true } = {}) {
const page = new Adw.PreferencesPage({ title, icon_name: iconName });
if (intro) page.add(intro);
const fields = this.fields.filter(field => groups.includes(field.group));
if (controls) 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', 'password'].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 false;
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 true; }
if (preview) { this.status('Playing your voice preview…'); await daemonCall('PreviewVoice', '(s)', [validated.previewText]); }
this.status(`Applied.${restart}${overrides}${preview ? ' Preview finished.' : ''}`);
return true;
} catch (error) { this.status(error.message); return false; }
finally { this.busy = false; for (const button of this.applyButtons) button.sensitive = true; this.refreshVisibility(); }
}
}