import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; const BIND = Gio.SettingsBindFlags.DEFAULT; const BUS = 'io.qvac.Jarvis'; const PATH = '/io/qvac/Jarvis'; const IFACE = 'io.qvac.Jarvis.Session'; const PRIVACY_MODES = ['full-listen-after-wake', 'wake-only', 'off']; const OVERLAY_STYLES = ['tray', 'expanded']; const COMPUTER_MODES = ['off', 'observe', 'act']; const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu']; function persistDaemonConfig(settings) { const dir = GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis']); GLib.mkdir_with_parents(dir, 0o755); const file = Gio.File.new_for_path(GLib.build_filenamev([dir, 'config.json'])); let config = {}; try { const [, contents] = file.load_contents(null); const text = typeof contents === 'string' ? contents : new TextDecoder().decode(contents); config = JSON.parse(text); } catch {} config.wakePhrase = settings.get_string('wake-phrase'); config.aliases = settings.get_strv('aliases'); config.ttsEnabled = settings.get_boolean('tts-enabled'); config.modelProfile = settings.get_string('model-profile'); file.replace_contents(`${JSON.stringify(config, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); } function entryRow(settings, title, key) { const row = new Adw.EntryRow({ title }); row.set_text(settings.get_string(key)); row.connect('changed', () => settings.set_string(key, row.get_text())); return row; } function strvRow(settings, title, key) { const row = new Adw.EntryRow({ title }); row.set_text(settings.get_strv(key).join(', ')); row.connect('changed', () => { const values = row.get_text().split(',').map((item) => item.trim()).filter(Boolean); settings.set_strv(key, values); }); return row; } function switchRow(settings, title, subtitle, key) { const row = new Adw.SwitchRow({ title, subtitle }); settings.bind(key, row, 'active', BIND); return row; } function comboRow(settings, title, subtitle, key, values) { const row = new Adw.ComboRow({ title, subtitle, model: Gtk.StringList.new(values) }); const current = settings.get_string(key); row.selected = Math.max(0, values.indexOf(current)); row.connect('notify::selected', () => { const value = values[row.selected]; if (value) settings.set_string(key, value); }); return row; } function callDaemon(name, signature, values, onDone) { Gio.DBus.session.call( BUS, PATH, IFACE, name, signature ? GLib.Variant.new(signature, values) : null, null, Gio.DBusCallFlags.NONE, 4000, null, (_source, result) => { try { const reply = Gio.DBus.session.call_finish(result); onDone?.(null, reply); } catch (error) { onDone?.(error); } }, ); } function grantRow() { const row = new Adw.ActionRow({ title: 'Desktop grant', subtitle: 'Off. Jarvis cannot click or type until you allow it here.', }); const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER }); allow.add_css_class('suggested-action'); const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER }); revoke.add_css_class('destructive-action'); const refresh = () => { callDaemon('ComputerStatus', null, null, (error, reply) => { if (error) { row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.'; return; } let status = {}; try { const unpacked = reply.deep_unpack?.() ?? reply.unpack?.(); const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked; status = JSON.parse(String(raw || '{}')); } catch {} if (status.active) { row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`; } else { row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for three minutes.'; } }); }; allow.connect('clicked', () => { callDaemon('ComputerGrant', '(b)', [true], (error) => { row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.'; refresh(); }); }); revoke.connect('clicked', () => { callDaemon('ComputerRevoke', null, null, (error) => { row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.'; refresh(); }); }); row.add_suffix(allow); row.add_suffix(revoke); row.activatable_widget = allow; refresh(); return row; } export default class JarvisPreferences extends ExtensionPreferences { fillPreferencesWindow(window) { window.set_title('Jarvis QVAC'); const settings = this.getSettings(); const voice = new Adw.PreferencesPage({ title: 'Voice', name: 'voice' }); const voiceGroup = new Adw.PreferencesGroup({ title: 'Speech and wake' }); voiceGroup.add(switchRow(settings, 'Spoken replies', 'Play Jarvis replies through local TTS', 'tts-enabled')); voiceGroup.add(switchRow(settings, 'Wake chime', 'Play a short chime when Jarvis starts listening', 'chime-enabled')); voiceGroup.add(entryRow(settings, 'Wake phrase', 'wake-phrase')); voiceGroup.add(strvRow(settings, 'Wake aliases', 'aliases')); voiceGroup.add(entryRow(settings, 'Voice id', 'voice-id')); voiceGroup.add(entryRow(settings, 'Language', 'language')); voice.add(voiceGroup); const desktop = new Adw.PreferencesPage({ title: 'Desktop', name: 'desktop' }); const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts' }); desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey')); desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color')); desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES)); desktopGroup.add(switchRow(settings, 'Confirm destructive actions', 'Ask before write, delete, or computer-use changes', 'confirm-destructive')); desktop.add(desktopGroup); const computer = new Adw.PreferencesPage({ title: 'Computer use', name: 'computer' }); const computerGroup = new Adw.PreferencesGroup({ title: 'Desktop control' }); computerGroup.add(comboRow(settings, 'Computer use mode', 'Observe is read-only. Act can click and type only after you press Allow now.', 'computer-use-mode', COMPUTER_MODES)); computerGroup.add(grantRow()); computerGroup.add(switchRow(settings, 'Legacy input', 'Use the older input backend when portals are unavailable', 'computer-use-legacy-input')); computerGroup.add(comboRow(settings, 'Privacy mode', 'How long Jarvis keeps the microphone open after wake', 'privacy-mode', PRIVACY_MODES)); computer.add(computerGroup); const models = new Adw.PreferencesPage({ title: 'Models', name: 'models' }); const modelGroup = new Adw.PreferencesGroup({ title: 'Local profile' }); modelGroup.add(comboRow(settings, 'Model profile', 'Restart jarvisd after changing the QVAC profile', 'model-profile', MODEL_PROFILES)); models.add(modelGroup); window.add(voice); window.add(desktop); window.add(computer); window.add(models); persistDaemonConfig(settings); for (const key of ['wake-phrase', 'aliases', 'tts-enabled', 'model-profile']) { settings.connect(`changed::${key}`, () => persistDaemonConfig(settings)); } } }