Check Point
Rolling release / release (push) Successful in 6m36s

This commit is contained in:
2026-09-12 09:07:09 -04:00
parent e9040d110a
commit a4073b9020
63 changed files with 2459 additions and 632 deletions
@@ -18,14 +18,23 @@ const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎
class JarvisProxy {
async connect() {
const proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
if (this._closed) return this;
if (this.proxy) return this;
if (this._connecting) {
await this._connecting.catch(() => {});
return this;
}
this._connecting = new Promise((resolve, reject) => Gio.DBusProxy.new(
Gio.DBus.session, Gio.DBusProxyFlags.NONE, null, BUS, PATH, IFACE, null,
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
));
if (this._closed) { proxy.run_dispose(); return this; }
this.proxy = proxy;
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
return this;
try {
const proxy = await this._connecting;
if (this._closed) { proxy.run_dispose(); return this; }
this.proxy = proxy;
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
return this;
} finally { this._connecting = null; }
}
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
call(name, signature = null, value = null) {
@@ -47,7 +56,7 @@ export default class JarvisExtension extends Extension {
this.settings = this.getSettings();
this.proxy = new JarvisProxy();
this.popup = new ConversationView({ compact: true });
this.osd = new JarvisOsd(); this.osd.attach();
this.osd = new JarvisOsd();
this.cu = new ComputerUseChrome(); this.cu.attach();
this.session = new SessionPanel(); this.session.attach();
this._bindSurface(this.popup);
@@ -55,7 +64,11 @@ export default class JarvisExtension extends Extension {
this._applyAccessibility();
this._removeShellService = installShellService();
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
this._panelBox = new St.BoxLayout({ style_class: 'jarvis-panel-box', y_align: Clutter.ActorAlign.CENTER });
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle';
this._panelBox.add_child(this._glyph);
this.osd.attach(this._panelBox);
this._indicator.add_child(this._panelBox);
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
this._mountPopup();
this._indicator.connect('button-press-event', (_actor, event) => {
@@ -173,28 +186,41 @@ export default class JarvisExtension extends Extension {
const proxy = this.proxy;
try {
await proxy.connect(); if (this.proxy !== proxy) return;
this._signals = [
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
['WakeHeard', () => this.osd.showWake()],
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
['Token', (text) => this._eachView((view) => view.token(text))],
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
['SpeakingLevel', () => {}],
['ListeningLevel', () => {}],
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
['ComputerHighlight', (json) => this.cu.setTarget(json)],
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
].map(([name, handler]) => proxy.on(name, handler));
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
if (!this._signals) {
this._signals = [
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
['WakeHeard', () => this.osd.showWake()],
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
['Token', (text) => this._eachView((view) => view.token(text))],
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
['SpeakingLevel', () => {}],
['ListeningLevel', () => {}],
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
['ComputerHighlight', (json) => this.cu.setTarget(json)],
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
].map(([name, handler]) => proxy.on(name, handler));
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
}
await this._syncDaemon();
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
if (this.proxy !== proxy) return;
this._keepSyncing();
}
_keepSyncing() {
if (this._syncTimer) return;
this._syncTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 2000, () => {
if (!this.proxy || this.proxy._closed) { this._syncTimer = 0; return GLib.SOURCE_REMOVE; }
if (!this.proxy.proxy) this._connectDaemon();
else this._syncDaemon();
return GLib.SOURCE_CONTINUE;
});
}
async _syncDaemon() {
if (!this.proxy) return;
@@ -203,6 +229,9 @@ export default class JarvisExtension extends Extension {
const result = await this.proxy.call('GetState'); if (!this.popup) return;
this._setState(result.deep_unpack()[0]);
await this._refreshVoiceStatus();
this._eachView((view) => {
if (/daemon is unavailable|Jarvis is starting/i.test(view.notice?.text || '')) view.setNotice('');
});
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _refreshVoiceStatus() {
@@ -220,8 +249,14 @@ export default class JarvisExtension extends Extension {
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
}
_call(name, signature, value) {
if (!this.proxy?.owned?.()) {
if (name !== 'PushToTalk') this._eachView((view) => view.setNotice('Jarvis is starting…'));
this._connectDaemon();
return;
}
this.proxy.call(name, signature, value).catch((error) => {
log(`Jarvis ${name}: ${error.message}`);
if (name === 'PushToTalk') return;
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
this._eachView((view) => view.setNotice(fallback));
});
@@ -258,6 +293,7 @@ export default class JarvisExtension extends Extension {
if (this._settingsChanged) this.settings.disconnect(this._settingsChanged);
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
if (this._syncTimer) { try { GLib.Source.remove(this._syncTimer); } catch {} this._syncTimer = 0; }
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
this.proxy?.close();
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
@@ -1,181 +1,6 @@
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;
}
import { fillSettingsWindow } from './settings-window.js';
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));
}
}
fillPreferencesWindow(window) { fillSettingsWindow(window, this.getSettings(), this.path); }
}
@@ -0,0 +1,745 @@
{
"version": 1,
"fields": [
{
"key": "ttsEnabled",
"title": "Spoken replies",
"group": "Speech",
"default": true,
"type": "boolean",
"description": "Read replies aloud using local speech synthesis.",
"aliases": [
"tts_enabled"
]
},
{
"key": "ttsPreset",
"title": "Speech model",
"group": "Speech",
"default": "supertonic-en",
"type": "choice",
"description": "Models download on first use. Larger models need more memory.",
"options": [
{
"value": "supertonic-en",
"label": "Supertonic \u00b7 English \u00b7 lightweight"
},
{
"value": "supertonic3",
"label": "Supertonic 3 \u00b7 multilingual"
},
{
"value": "chatterbox",
"label": "Chatterbox Turbo \u00b7 English \u00b7 reference voice"
},
{
"value": "parler",
"label": "Parler Mini \u00b7 English \u00b7 describe a voice"
}
]
},
{
"key": "voiceId",
"title": "Voice",
"group": "Speech",
"default": "F1",
"type": "choice",
"description": "Try the same preview with different voices. Available voices depend on the model bundle.",
"options": [
{
"value": "F1",
"label": "F1"
},
{
"value": "F2",
"label": "F2"
},
{
"value": "F3",
"label": "F3"
},
{
"value": "F4",
"label": "F4"
},
{
"value": "F5",
"label": "F5"
},
{
"value": "M1",
"label": "M1"
},
{
"value": "M2",
"label": "M2"
},
{
"value": "M3",
"label": "M3"
},
{
"value": "M4",
"label": "M4"
},
{
"value": "M5",
"label": "M5"
}
],
"aliases": [
"voice_id",
"voice-id"
],
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsLanguage",
"title": "Speech language",
"group": "Speech",
"default": "en",
"type": "choice",
"description": "The English models always use English. Choose Supertonic 3 for other languages.",
"options": [
{
"value": "en",
"label": "English"
},
{
"value": "ko",
"label": "Korean"
},
{
"value": "ja",
"label": "Japanese"
},
{
"value": "ar",
"label": "Arabic"
},
{
"value": "bg",
"label": "Bulgarian"
},
{
"value": "cs",
"label": "Czech"
},
{
"value": "da",
"label": "Danish"
},
{
"value": "de",
"label": "German"
},
{
"value": "el",
"label": "Greek"
},
{
"value": "es",
"label": "Spanish"
},
{
"value": "et",
"label": "Estonian"
},
{
"value": "fi",
"label": "Finnish"
},
{
"value": "fr",
"label": "French"
},
{
"value": "hi",
"label": "Hindi"
},
{
"value": "hr",
"label": "Croatian"
},
{
"value": "hu",
"label": "Hungarian"
},
{
"value": "id",
"label": "Indonesian"
},
{
"value": "it",
"label": "Italian"
},
{
"value": "lt",
"label": "Lithuanian"
},
{
"value": "lv",
"label": "Latvian"
},
{
"value": "nl",
"label": "Dutch"
},
{
"value": "pl",
"label": "Polish"
},
{
"value": "pt",
"label": "Portuguese"
},
{
"value": "ro",
"label": "Romanian"
},
{
"value": "ru",
"label": "Russian"
},
{
"value": "sk",
"label": "Slovak"
},
{
"value": "sl",
"label": "Slovenian"
},
{
"value": "sv",
"label": "Swedish"
},
{
"value": "tr",
"label": "Turkish"
},
{
"value": "uk",
"label": "Ukrainian"
},
{
"value": "vi",
"label": "Vietnamese"
}
],
"aliases": [
"tts_language"
],
"when": {
"ttsPreset": [
"supertonic3"
]
}
},
{
"key": "ttsSpeed",
"title": "Speaking speed",
"group": "Speech",
"default": 1.05,
"type": "number",
"description": "1 is normal speed; lower is slower.",
"min": 0.25,
"max": 4,
"step": 0.05,
"aliases": [
"tts_speed"
],
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsSteps",
"title": "Voice quality steps",
"group": "Speech",
"default": 5,
"type": "number",
"description": "More steps can improve quality but take longer.",
"min": 1,
"max": 30,
"step": 1,
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsVolume",
"title": "Reply volume (%)",
"group": "Speech",
"default": 100,
"type": "number",
"description": "Relative to the system speaker volume. Does not change other apps.",
"min": 0,
"max": 100,
"step": 5
},
{
"key": "ttsReferenceAudio",
"title": "Reference voice recording",
"group": "Voice design",
"default": "",
"type": "file",
"description": "Choose a clear mono WAV recording of at least 5 seconds. Leave empty for the model\u2019s default voice.",
"when": {
"ttsPreset": [
"chatterbox"
]
}
},
{
"key": "ttsCfmSteps",
"title": "Synthesis quality steps",
"group": "Voice design",
"default": 2,
"type": "number",
"description": "Chatterbox: fewer steps respond faster.",
"min": 1,
"max": 10,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox"
]
}
},
{
"key": "ttsDescription",
"title": "Describe the voice",
"group": "Voice design",
"default": "A clear, warm voice speaks at a natural pace in a quiet room.",
"type": "string",
"description": "Parler: describe tone, pace, pitch, and recording style in English.",
"when": {
"ttsPreset": [
"parler"
]
}
},
{
"key": "ttsTemperature",
"title": "Voice variation",
"group": "Voice design",
"default": 1,
"type": "number",
"description": "Parler: higher values produce more variation.",
"min": 0,
"max": 2,
"step": 0.05,
"when": {
"ttsPreset": [
"parler"
]
}
},
{
"key": "ttsSeed",
"title": "Voice seed",
"group": "Voice design",
"default": 42,
"type": "number",
"description": "Use a fixed seed for repeatable speech generation.",
"min": 0,
"max": 2147483647,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox",
"parler"
]
}
},
{
"key": "ttsThreads",
"title": "Speech CPU threads",
"group": "Voice design",
"default": 4,
"type": "number",
"description": "Limit CPU work for Chatterbox and Parler.",
"min": 1,
"max": 32,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox",
"parler"
]
}
},
{
"key": "ttsUseGpu",
"title": "Accelerate speech with GPU",
"group": "Voice design",
"default": false,
"type": "boolean",
"description": "Use a supported GPU backend for speech synthesis. May increase GPU memory use."
},
{
"key": "ttsModel",
"title": "Custom speech model",
"group": "Voice design",
"default": "",
"type": "string",
"description": "Advanced: registry name or local GGUF matching the selected speech model. Empty uses the bundled preset.",
"aliases": [
"tts_model"
]
},
{
"key": "previewText",
"title": "Preview text",
"group": "Speech",
"default": "Hello. I am Jarvis. This is how I will sound with your settings.",
"type": "string",
"description": "Use the same sentence to compare voices."
},
{
"key": "microphoneEnabled",
"title": "Microphone input",
"group": "Listening",
"default": true,
"type": "boolean",
"description": "Turn off for typed chat and speech output only.",
"aliases": [
"microphone_enabled"
]
},
{
"key": "asrModel",
"title": "Recognition model",
"group": "Listening",
"default": "WHISPER_TINY",
"type": "choice",
"description": "Larger models need more memory and download on first use.",
"options": [
{
"value": "WHISPER_TINY",
"label": "Whisper Tiny \u00b7 fastest"
},
{
"value": "WHISPER_BASE_Q8_0",
"label": "Whisper Base \u00b7 balanced"
},
{
"value": "WHISPER_SMALL_Q8_0",
"label": "Whisper Small \u00b7 more accurate"
}
],
"aliases": [
"asr_model"
]
},
{
"key": "asrLanguage",
"title": "Recognition language",
"group": "Listening",
"default": "en",
"type": "string",
"description": "Whisper language code such as en, es, fr, or auto.",
"aliases": [
"asr_language",
"language"
]
},
{
"key": "wakePhrase",
"title": "Wake phrase",
"group": "Wake and privacy",
"default": "hey jarvis",
"type": "string",
"description": "Must match a phrase supported by your local wake detector.",
"aliases": [
"wake_phrase"
]
},
{
"key": "aliases",
"title": "Wake aliases",
"group": "Wake and privacy",
"default": [
"jarvis",
"okay jarvis"
],
"type": "list",
"description": "Additional detector phrases, separated by commas."
},
{
"key": "wakeCommand",
"title": "Wake detector command",
"group": "Wake and privacy",
"default": "",
"type": "string",
"description": "Advanced: local program that receives microphone audio. Leave empty to use Hold Talk.",
"aliases": [
"wake_command"
]
},
{
"key": "listeningMode",
"title": "Listening behavior",
"group": "Wake and privacy",
"default": "conversation",
"type": "choice",
"description": "Hold Talk only disables the wake detector and automatic follow-up listening.",
"options": [
{
"value": "conversation",
"label": "Continue listening after replies"
},
{
"value": "single",
"label": "One request per wake"
},
{
"value": "ptt",
"label": "Hold Talk only"
}
],
"aliases": [
"listening_mode"
]
},
{
"key": "idleMinutes",
"title": "Idle sleep delay (minutes)",
"group": "Wake and privacy",
"default": 30,
"type": "number",
"description": "Sleep after this much listening inactivity.",
"min": 1,
"max": 240,
"step": 1
},
{
"key": "vadThreshold",
"title": "Speech detection threshold",
"group": "Detection tuning",
"default": 0.6,
"type": "number",
"description": "Lower picks up quieter speech; higher rejects more background noise.",
"min": 0.05,
"max": 1,
"step": 0.05
},
{
"key": "vadMinSpeechMs",
"title": "Minimum speech (ms)",
"group": "Detection tuning",
"default": 300,
"type": "number",
"description": "Ignore very short sounds during automatic listening.",
"min": 100,
"max": 2000,
"step": 50
},
{
"key": "vadSilenceMs",
"title": "Pause before sending (ms)",
"group": "Detection tuning",
"default": 700,
"type": "number",
"description": "Wait this long after speech before sending your request.",
"min": 200,
"max": 3000,
"step": 50
},
{
"key": "vadMaxSpeechSeconds",
"title": "Maximum recording (seconds)",
"group": "Detection tuning",
"default": 15,
"type": "number",
"description": "Limit each recorded utterance, including pauses.",
"min": 3,
"max": 120,
"step": 1
},
{
"key": "playbackCooldownMs",
"title": "Echo protection after replies (ms)",
"group": "Detection tuning",
"default": 400,
"type": "number",
"description": "Delay microphone processing after speech output ends.",
"min": 0,
"max": 2000,
"step": 50
},
{
"key": "inputTarget",
"title": "Microphone device",
"group": "Audio routing",
"default": "",
"type": "device",
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
},
{
"key": "outputTarget",
"title": "Speaker device",
"group": "Audio routing",
"default": "",
"type": "device",
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
},
{
"key": "computerMode",
"title": "Desktop access mode",
"group": "Desktop access",
"default": "act",
"type": "choice",
"description": "A temporary Allow now grant is always required. Changing this revokes existing access.",
"options": [
{
"value": "off",
"label": "Disabled"
},
{
"value": "observe",
"label": "Observe only"
},
{
"value": "act",
"label": "Observe and control"
}
],
"aliases": [
"computer_mode"
]
},
{
"key": "computerSteps",
"title": "Actions per grant",
"group": "Desktop access",
"default": 20,
"type": "number",
"description": "Maximum input actions before another grant is needed.",
"min": 1,
"max": 100,
"step": 1,
"aliases": [
"computer_step_budget"
]
},
{
"key": "computerGrantMinutes",
"title": "Grant duration (minutes)",
"group": "Desktop access",
"default": 3,
"type": "number",
"description": "Desktop access expires automatically.",
"min": 1,
"max": 15,
"step": 1
},
{
"key": "screenshotMaxEdge",
"title": "Screenshot maximum edge (pixels)",
"group": "Desktop images",
"default": 1280,
"type": "number",
"description": "Larger screenshots preserve detail but take more memory.",
"min": 640,
"max": 2560,
"step": 160
},
{
"key": "screenshotQuality",
"title": "Screenshot quality (%)",
"group": "Desktop images",
"default": 70,
"type": "number",
"description": "Higher WebP quality preserves more text detail.",
"min": 30,
"max": 95,
"step": 5
},
{
"key": "modelProfile",
"title": "Chat model profile",
"group": "Chat model",
"default": "laptop-16gb",
"type": "choice",
"description": "Requires a daemon restart. GPU inference remains required.",
"options": [
{
"value": "laptop-8gb",
"label": "Small \u00b7 Qwen3 1.7B"
},
{
"value": "laptop-16gb",
"label": "Balanced \u00b7 Qwen3.5 4B"
},
{
"value": "desktop-gpu",
"label": "Large \u00b7 Qwen3.5 9B"
}
],
"aliases": [
"model_profile"
],
"restart": true
},
{
"key": "chatModel",
"title": "Custom chat model",
"group": "Chat model",
"default": "",
"type": "string",
"description": "Advanced: overrides the profile. Empty uses the profile model. Requires restart.",
"aliases": [
"model"
],
"restart": true
},
{
"key": "maxTurns",
"title": "Maximum reasoning turns",
"group": "Agent limits",
"default": 6,
"type": "number",
"description": "Requires restart. Limits how long the assistant works on one request.",
"min": 1,
"max": 30,
"step": 1,
"restart": true
},
{
"key": "maxShellCalls",
"title": "Shell commands per request",
"group": "Agent limits",
"default": 1,
"type": "number",
"description": "Requires restart. Commands still require normal permissions.",
"min": 1,
"max": 10,
"step": 1,
"restart": true
},
{
"key": "maxToolRounds",
"title": "Tool rounds per request",
"group": "Agent limits",
"default": 4,
"type": "number",
"description": "Requires restart. Limits repeated tool use.",
"min": 1,
"max": 20,
"step": 1,
"restart": true
}
]
}
@@ -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(); }
}
}
@@ -0,0 +1,43 @@
// Shared by GNOME preferences and the daemon; no platform-specific imports.
export function normalizeSettings(source, fields, { strict = false } = {}) {
const config = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
const result = {};
for (const field of fields) {
let value = config[field.key];
if (value == null) {
for (const alias of field.aliases || []) if (config[alias] != null) { value = config[alias]; break; }
}
if (value == null) value = field.default;
if (field.type === 'boolean' && typeof value === 'string') {
const flag = value.trim().toLowerCase();
if (['false', '0', 'off', 'no'].includes(flag)) value = false;
else if (['true', '1', 'on', 'yes'].includes(flag)) value = true;
}
if (field.type === 'number' && typeof value === 'string' && value.trim()) value = Number(value);
if (field.type === 'list' && typeof value === 'string') value = value.split(',').map(v => v.trim()).filter(Boolean);
if (field.key === 'asrLanguage' && typeof value === 'string') value = value.toLowerCase().split(/[-_]/)[0];
if (['string', 'file'].includes(field.type) && typeof value === 'string') value = value.trim();
const valid = field.type === 'boolean' ? typeof value === 'boolean'
: field.type === 'number' ? Number.isFinite(value) && value >= field.min && value <= field.max && (field.step < 1 || Number.isInteger(value))
: field.type === 'choice' ? field.options.some(option => option.value === value)
: field.type === 'list' ? Array.isArray(value) && value.every(item => typeof item === 'string')
: typeof value === 'string' && value.length <= 4096;
if (!valid && strict) throw new Error(`Invalid value for ${field.title}`);
result[field.key] = valid ? value : field.default;
}
if (result.vadMinSpeechMs > result.vadMaxSpeechSeconds * 1000) throw new Error('Minimum speech must be shorter than the maximum recording');
return result;
}
export function mergeSettings(source, changes, fields) {
const merged = { ...source, ...changes };
// Remove obsolete aliases only for settings actually changed by this window.
for (const field of fields) if (Object.hasOwn(changes, field.key)) {
for (const alias of field.aliases || []) delete merged[alias];
}
return merged;
}
export function settingVisible(field, values) {
return !field.when || Object.entries(field.when).every(([key, choices]) => choices.includes(values[key]));
}
@@ -0,0 +1,136 @@
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import { SettingsEditor } from './settings-editor.js';
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const OVERLAY_STYLES = ['tray', 'expanded'];
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 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('Jarvis QVAC');
window.set_default_size(840, 780);
window.search_enabled = true;
const editor = new SettingsEditor(directory, settings);
const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true);
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(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));
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] };
}
@@ -1,4 +1,7 @@
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 16px; }
.jarvis-panel-box { spacing: 6px; }
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 14px; }
.jarvis-panel-state { padding: 1px 8px; border-radius: 999px; background-color: rgba(244, 185, 66, .22); }
.jarvis-panel-state-label { color: var(--jarvis-accent, #F4B942); font-size: 12px; font-weight: bold; }
.jarvis-menu { max-width: 360px; }
.jarvis-menu-item { padding: 0; }
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
@@ -33,8 +36,6 @@
.jarvis-settings { font-size: 12px; padding: 4px 8px; }
.jarvis-popup StEntry, .jarvis-session StEntry { border-radius: 10px; padding: 8px 10px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: #F4B942; }
.jarvis-osd { padding: 8px 16px; border-radius: 999px; background-color: rgba(11, 14, 20, .9); border: 1px solid rgba(244, 185, 66, .45); }
.jarvis-osd-label { color: #f6f7fb; font-size: 13px; font-weight: bold; }
.jarvis-cu { padding: 8px 12px; spacing: 4px; border-radius: 12px; background-color: rgba(11, 14, 20, .82); border: 1px solid rgba(79, 210, 255, .4); }
.jarvis-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; }
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
+37 -28
View File
@@ -250,41 +250,50 @@ export class ConversationView {
destroy() { this.root.destroy(); }
}
const PANEL_STATES = {
LISTENING: 'Listening',
SPEAKING: 'Speaking',
THINKING: 'Thinking',
SLEEPING: 'Privacy',
};
export class JarvisOsd {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-osd', visible: false });
this.label = new St.Label({ text: '', style_class: 'jarvis-osd-label' });
this.root = new St.BoxLayout({ style_class: 'jarvis-panel-state', visible: false, y_align: Clutter.ActorAlign.CENTER });
this.label = new St.Label({ text: '', style_class: 'jarvis-panel-state-label', y_align: Clutter.ActorAlign.CENTER });
this.root.add_child(this.label);
this.root.accessible_name = 'Jarvis status overlay';
this._sticky = false;
this.root.accessible_name = 'Jarvis status';
this._state = 'ARMED';
this._wake = false;
this._timeout = 0;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const width = 220;
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 36);
}
show(text, { sticky = false, ms = 2000 } = {}) {
this.label.text = safeText(text);
this._sticky = sticky;
this._place();
this.root.visible = true;
if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; }
const limit = sticky ? Math.min(ms || 8000, 8000) : ms;
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit, () => { this._timeout = 0; this.hide(); return GLib.SOURCE_REMOVE; });
}
attach(parent) { parent?.add_child?.(this.root); this.hide(); }
setState(state) {
if (state === 'LISTENING') this.show('Listening', { sticky: true, ms: 8000 });
else if (state === 'SPEAKING') this.show('Speaking', { sticky: true, ms: 8000 });
else if (state === 'SLEEPING') this.show('Privacy mode', { sticky: false, ms: 1600 });
else this.hide();
this._state = STATES.has(state) ? state : 'ARMED';
this._wake = false;
this._clearTimer();
this._render();
}
showWake() { this.show('Wake', { sticky: false, ms: 1200 }); }
hide() { this.root.visible = false; this._sticky = false; if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
destroy() { this.hide(); this.root.destroy(); }
showWake() {
this._wake = true;
this._clearTimer();
this._render();
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1200, () => {
this._timeout = 0;
this._wake = false;
this._render();
return GLib.SOURCE_REMOVE;
});
}
_render() {
const text = this._wake ? 'Wake' : (PANEL_STATES[this._state] || '');
this.label.text = text;
this.root.visible = Boolean(text);
this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status';
}
_clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
hide() { this._wake = false; this._clearTimer(); this.root.visible = false; }
destroy() { this._clearTimer(); this.root.destroy(); }
}
export class ComputerUseChrome {