@@ -0,0 +1,221 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user