Files
gnome-jarvis/apps/gnome-extension/[email protected]/extension.js
T
snxraven a78708f8ff
Rolling release / release (push) Failing after 1m24s
Updates
2026-09-11 19:36:12 -04:00

224 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import Shell from 'gi://Shell';
import Meta from 'gi://Meta';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Pango from 'gi://Pango';
import { installShellService } from './shell-dbus.js';
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const OVERLAY_WIDTH = 720;
const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
const coerceText = (value) => {
if (value == null) return '';
if (typeof value === 'string') return value === '[object Object]' ? '' : value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') {
if (typeof value.text === 'string') return value.text;
if (typeof value.message === 'string') return value.message;
if (typeof value.deep_unpack === 'function') return coerceText(value.deep_unpack());
}
const fallback = String(value);
return fallback === '[object Object]' ? '' : fallback;
};
const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
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,
(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;
}
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
call(name, signature = null, value = null) {
if (!this.proxy) return Promise.reject(new Error('Jarvis daemon is unavailable'));
return new Promise((resolve, reject) => this.proxy.call(name, signature ? new GLib.Variant(signature, value) : null, Gio.DBusCallFlags.NONE, -1, null,
(source, result) => { try { resolve(source.call_finish(result)); } catch (error) { reject(error); } }));
}
on(name, handler) { return this.proxy?.connect('g-signal', (_p, _sender, signal, params) => { if (signal === name) handler(...params.deep_unpack()); }); }
close() {
this._closed = true; this.onOwnerChanged = null;
if (this._ownerSignal && this.proxy) { try { this.proxy.disconnect(this._ownerSignal); } catch {} }
this.proxy?.run_dispose(); this.proxy = null;
}
}
class ArcOverlay {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-arc', vertical: true, reactive: true, can_focus: true, track_hover: true, x_expand: true });
this.root.accessible_name = 'Jarvis ARC voice assistant';
this.header = new St.BoxLayout({ style_class: 'jarvis-arc-header', x_expand: true });
this.title = new St.Label({ text: '◉ JARVIS', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status';
if (this.title.clutter_text) this.title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status';
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status);
this.statusLine = new St.Label({ text: 'Super+Shift+J · Hold Talk to speak', style_class: 'jarvis-status-line' });
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true, visible: false }); this.job.accessible_name = 'Jarvis job progress';
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true, visible: false }); this.target.accessible_name = 'Computer use target';
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false, visible: false }); this.cursor.accessible_name = 'Visible computer use cursor';
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true, y_expand: true });
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript';
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave', visible: false }); this.bars = [];
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
}
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chips);
this.controls = new St.BoxLayout({ style_class: 'jarvis-chips' });
this.talk = new St.Button({ label: 'Talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
this.talk.accessible_name = 'Hold Space or Enter to talk';
this.talk.connect('key-press-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(true); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-release-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(false); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-focus-out', () => this.onTalk?.(false));
this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
this.controls.add_child(this.talk);
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Close', () => this.hide()]]) {
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
button.connect('clicked', action); this.controls.add_child(button);
}
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } });
this.root.add_child(this.entry); this.root.add_child(this.controls);
this.reducedMotion = false;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); }
show() {
const monitor = Main.layoutManager.primaryMonitor;
if (monitor) {
const width = Math.min(OVERLAY_WIDTH, monitor.width - 80);
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 48);
this.scroll.set_height(Math.max(100, Math.min(280, monitor.height - 360)));
}
const wasVisible = this.root.visible;
this.root.visible = true;
if (!wasVisible) this.entry.grab_key_focus();
}
hide() { this.onTalk?.(false); this.root.visible = false; }
toggle() { this.root.visible ? this.hide() : this.show(); }
clear() { this.transcript.destroy_all_children(); }
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this.show(); return row; }
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!row || !String(row.style_class || '').includes('jarvis-row-jarvis')) { row = this.addRow('J', ''); } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); }
finalizeReply(text) {
const spoken = safeText(text);
let row = this.transcript.get_last_child?.();
if (row && String(row.style_class || '').includes('jarvis-row-jarvis')) {
const body = String(row.text || '').replace(/^J\s+/, '');
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.show(); return;
}
if (spoken) this.addRow('J', spoken);
}
setConnectionStatus(kind) {
const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' };
this.status.text = labels[kind] || labels.local;
this.status.accessible_name = this.status.text;
}
setState(state) {
const value = STATES.has(state) ? state : 'ARMED';
this.statusLine.text = `${value.toLowerCase()} · Super+Shift+J · Hold Talk to speak`;
this.title.text = `${value === 'SLEEPING' ? '' : '◉'} JARVIS`;
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
this.wave.visible = value === 'LISTENING' || value === 'SPEAKING';
if (value === 'LISTENING') this.showHalo(); if (value !== 'ARMED') this.show();
}
level(rms) { this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this.show(); }
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this.show(); }
showHalo() { if (!this.halo) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
destroy() { if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = 0; this.halo?.destroy(); this.halo = null; this.root.destroy(); }
}
export default class JarvisExtension extends Extension {
enable() {
this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); }
this.settings = this.getSettings(); this.proxy = new JarvisProxy(); this.overlay = new ArcOverlay(); this.overlay.attach(); 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);
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
this.overlay.onStop = () => this._call('Cancel'); this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]); this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.toggle()); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show();
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
}
async _connectDaemon() {
const proxy = this.proxy;
try {
await proxy.connect(); if (this.proxy !== proxy) return;
this._signals = [
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
['WakeHeard', () => this.overlay.show()],
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
['Token', (text) => this.overlay.token(text)],
['Reply', (text) => { this.overlay.finalizeReply(text); this.overlay.show(); }],
['SpeakingLevel', (rms) => this.overlay.level(rms)],
['ListeningLevel', (rms) => this.overlay.level(rms)],
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
['ComputerStep', (json) => this.overlay.addStep(json)],
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
['Error', (code, message) => { this.overlay.addRow('J', message); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.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.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _syncDaemon() {
if (!this.proxy) return;
if (!this.proxy.owned()) { this.overlay?.setConnectionStatus('offline'); return; }
try {
const result = await this.proxy.call('GetState'); if (!this.overlay) return;
this._setState(result.deep_unpack()[0]);
await this._refreshVoiceStatus();
} catch (error) { this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _refreshVoiceStatus() {
if (!this.proxy?.owned?.() || !this.overlay) return;
try {
const runtime = await this.proxy.call('GetRuntimeStatus');
if (!this.overlay) return;
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
const voice = status.voice;
this.overlay.setConnectionStatus(voice ? 'local' : 'voice-unavailable');
if (voice) {
const input = voice.asr && voice.capture;
this.overlay.status.text = `${voice.tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (voice.wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`;
this.overlay.status.accessible_name = this.overlay.status.text;
this.overlay.talk.reactive = Boolean(input); this.overlay.talk.can_focus = Boolean(input);
this.overlay.talk.label = input ? 'Hold to talk' : 'Mic unavailable';
}
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
}
_call(name, signature, value) { this.proxy.call(name, signature, value).catch((error) => { log(`Jarvis ${name}: ${error.message}`); this.overlay?.addRow('J', `${name} failed: ${error.message}`); }); }
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show()], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
}