222 lines
14 KiB
JavaScript
222 lines
14 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.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(); }
|
|
}
|
|
}
|