Files
gnome-jarvis/apps/gnome-extension/[email protected]/extension.js
T
snxraven e9040d110a
Rolling release / release (push) Successful in 6m40s
Updates
2026-09-12 07:22:47 -04:00

271 lines
14 KiB
JavaScript

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 { installShellService } from './shell-dbus.js';
import { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, coerceText, safeText, shortError } from './ui.js';
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' };
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;
}
}
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.popup = new ConversationView({ compact: true });
this.osd = new JarvisOsd(); this.osd.attach();
this.cu = new ComputerUseChrome(); this.cu.attach();
this.session = new SessionPanel(); this.session.attach();
this._bindSurface(this.popup);
this._bindSurface(this.session.view);
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._mountPopup();
this._indicator.connect('button-press-event', (_actor, event) => {
const button = event.get_button();
if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; }
return Clutter.EVENT_PROPAGATE;
});
this._keyName = 'hotkey';
try {
Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => {
this._openPopup();
this._call('Arm');
});
} catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent());
this._styleChanged = this.settings.connect('changed::overlay-style', () => this._applyLayout());
this._applyAccent();
this._applyLayout();
this._connectDaemon();
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this._closeShell(); this._call('ComputerRevoke'); } }); } catch {}
}
_bindSurface(view) {
view.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
view.onStop = () => this._call('Cancel');
view.onReset = () => { this._eachView((surface) => { surface.clear(); surface.setNotice('New conversation'); }); this._call('ResetContext'); };
view.onAsk = (text) => { this._eachView((surface) => surface.addRow('U', text)); this._call('Ask', '(s)', [text]); };
view.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
view.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
view.onConfirmShown = () => this._openPopup();
view.onExpand = () => this.session.show(true);
view.onSettings = () => this._openSettings();
view.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
}
_eachView(fn) { if (this.popup) fn(this.popup); if (this.session?.view) fn(this.session.view); }
_mountPopup() {
const menu = this._indicator.menu;
try {
const section = new PopupMenu.PopupMenuSection();
section.actor?.add_style_class_name?.('jarvis-menu-item');
section.actor.add_child(this.popup.root);
menu.addMenuItem(section);
} catch {
menu.box.add_child(this.popup.root);
}
menu.actor?.add_style_class_name?.('jarvis-menu');
try {
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const grantItem = new PopupMenu.PopupMenuItem('Grant desktop');
grantItem.connect('activate', () => this._call('ComputerGrant', '(b)', [true]));
menu.addMenuItem(grantItem);
const revokeItem = new PopupMenu.PopupMenuItem('Revoke desktop');
revokeItem.connect('activate', () => this._call('ComputerRevoke'));
menu.addMenuItem(revokeItem);
const settingsItem = new PopupMenu.PopupMenuItem('Settings');
settingsItem.connect('activate', () => this._openSettings());
menu.addMenuItem(settingsItem);
} catch {}
this._menuState = menu.connect('open-state-changed', (_menu, open) => { if (!open) this.popup.endTalk(); });
}
_openPopup() { try { this._indicator.menu.open(); } catch {} }
_openSettings() {
const uuid = this.uuid;
try { this._indicator.menu.close(); } catch {}
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._launchPreferences(uuid);
return GLib.SOURCE_REMOVE;
});
}
_launchPreferences(uuid) {
try {
if (Main.extensionManager?.openExtensionPrefs) {
Main.extensionManager.openExtensionPrefs(uuid, '', {});
return;
}
} catch (error) { log(`Jarvis preferences manager: ${error.message}`); }
try {
if (typeof this.openPreferences === 'function') {
this.openPreferences();
return;
}
} catch (error) { log(`Jarvis preferences: ${error.message}`); }
Gio.DBus.session.call(
'org.gnome.Shell.Extensions',
'/org/gnome/Shell/Extensions',
'org.gnome.Shell.Extensions',
'OpenExtensionPrefs',
new GLib.Variant('(ssa{sv})', [uuid, '', {}]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(_source, result) => {
try { Gio.DBus.session.call_finish(result); }
catch (error) {
log(`Jarvis preferences dbus: ${error.message}`);
try { Gio.Subprocess.new(['gnome-extensions', 'prefs', uuid], Gio.SubprocessFlags.NONE); }
catch (err) { this.popup?.setNotice(`Could not open settings: ${shortError(err.message)}`); }
}
},
);
}
_closeShell() {
try { this._indicator.menu.close(); } catch {}
this.popup.endTalk();
this.session.hide();
this.osd.hide();
this.cu.hide();
}
_applyLayout() {
const expanded = this.settings.get_string('overlay-style') === 'expanded';
if (expanded) this.session.show(true);
else this.session.hide();
}
async _connectDaemon() {
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(); };
await this._syncDaemon();
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _syncDaemon() {
if (!this.proxy) return;
if (!this.proxy.owned()) { this._eachView((view) => view.setConnectionStatus('offline')); return; }
try {
const result = await this.proxy.call('GetState'); if (!this.popup) return;
this._setState(result.deep_unpack()[0]);
await this._refreshVoiceStatus();
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _refreshVoiceStatus() {
if (!this.proxy?.owned?.() || !this.popup) return;
try {
const runtime = await this.proxy.call('GetRuntimeStatus');
if (!this.popup) return;
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
const voice = status.voice;
this._eachView((view) => view.setConnectionStatus(voice ? 'local' : 'voice-unavailable'));
if (voice) {
const input = voice.asr && voice.capture;
this._eachView((view) => view.setVoiceStatus({ tts: voice.tts, input, wake: voice.wake }));
}
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
}
_call(name, signature, value) {
this.proxy.call(name, signature, value).catch((error) => {
log(`Jarvis ${name}: ${error.message}`);
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
this._eachView((view) => view.setNotice(fallback));
});
}
_setState(state) {
const value = safeText(state);
this._glyph.text = `${GLYPHS[value] || '◯'} Jarvis`;
this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`;
this._eachView((view) => view.setState(value));
this.osd.setState(value);
}
_applyAccent() {
const style = `--jarvis-accent: ${this.settings.get_string('accent-color')};`;
this.popup.root.set_style(style);
this.session.root.set_style(style);
this.osd.root.set_style(style);
this.cu.root.set_style(style);
}
_applyAccessibility() {
try {
const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
this.popup.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.popup.root.add_style_class_name('jarvis-high-contrast');
this.session.root.add_style_class_name('jarvis-high-contrast');
}
} catch {}
}
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);
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
this.proxy?.close();
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
this._indicator?.destroy();
if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} }
this.popup = this.session = this.osd = this.cu = this._indicator = this._glyph = this.proxy = null;
}
}
export { JarvisProxy, ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel };