import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; import Gdk from 'gi://Gdk'; import { SettingsEditor } from './settings-editor.js'; import { BRAND, normalizeAccent } from './brand.js'; const BUS = 'io.qvac.Jarvis'; const PATH = '/io/qvac/Jarvis'; const IFACE = 'io.qvac.Jarvis.Session'; const OVERLAY_STYLES = ['tray', 'expanded']; function applyBrandCss(directory) { try { const css = new Gtk.CssProvider(); css.load_from_path(`${directory}/brand/gtk.css`); Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); } catch {} } function brandBanner(directory) { const row = new Adw.ActionRow({ title: BRAND.product, subtitle: `${BRAND.tagline} ยท ${BRAND.publisher}` }); try { const image = Gtk.Image.new_from_file(`${directory}/brand/icons/jarvis-mark.svg`); image.set_pixel_size(36); row.add_prefix(image); } catch {} row.activatable = false; return row; } function accentRow(settings) { const current = normalizeAccent(settings.get_string('accent-color')); const row = new Adw.ActionRow({ title: 'Accent color', subtitle: 'Gold is the Jarvis default. This tints the HUD immediately.', }); const box = new Gtk.Box({ spacing: 6, valign: Gtk.Align.CENTER }); for (const accent of BRAND.accents) { const button = new Gtk.Button({ tooltip_text: accent.label, valign: Gtk.Align.CENTER }); button.add_css_class('circular'); button.add_css_class('jarvis-swatch'); button.add_css_class(`jarvis-swatch-${accent.id}`); button.set_size_request(24, 24); button.connect('clicked', () => settings.set_string('accent-color', accent.value)); box.append(button); } const entry = new Gtk.Entry({ text: current, width_chars: 9, valign: Gtk.Align.CENTER }); try { entry.get_buffer().set_max_length(7); } catch {} entry.connect('changed', () => { const value = normalizeAccent(entry.get_text(), ''); if (value) settings.set_string('accent-color', value); }); settings.connect('changed::accent-color', () => { const value = normalizeAccent(settings.get_string('accent-color')); if (entry.get_text() !== value) entry.set_text(value); }); row.add_suffix(box); row.add_suffix(entry); 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 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: 'Jarvis needs a temporary grant before observing or controlling the desktop.', }); 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 the configured grant duration.'; } }); }; 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 function fillSettingsWindow(window, settings, directory) { window.set_title(BRAND.product); window.set_default_size(840, 780); window.search_enabled = true; applyBrandCss(directory); const editor = new SettingsEditor(directory, settings); const intro = new Adw.PreferencesGroup(); intro.add(brandBanner(directory)); const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true, intro); window.add(voice); const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic'); window.add(listening); const desktop = editor.page(window, 'Desktop', ['Desktop access', 'Desktop images'], 'preferences-desktop-display-symbolic'); const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts', description: 'These appearance settings take effect immediately.' }); desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey')); desktopGroup.add(accentRow(settings)); desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES)); desktop.add(desktopGroup); const grantGroup = new Adw.PreferencesGroup({ title: 'Temporary desktop access' }); grantGroup.add(grantRow()); desktop.add(grantGroup); window.add(desktop); const models = editor.page(window, 'Models', ['Chat model', 'Agent limits'], 'system-run-symbolic'); const service = new Adw.PreferencesGroup({ title: 'Apply chat model changes' }); const restart = new Adw.ActionRow({ title: 'Restart Jarvis', subtitle: 'Saves your changes and restarts the user service. Ends the current request and desktop grant.' }); const restartButton = new Gtk.Button({ label: 'Save & Restart', valign: Gtk.Align.CENTER }); restartButton.connect('clicked', async () => { restartButton.sensitive = false; try { await editor.restart(); } finally { restartButton.sensitive = true; } }); restart.add_suffix(restartButton); service.add(restart); models.add(service); window.add(models); return { editor, pages: [voice, listening, desktop, models] }; }