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() { 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); } }, )); 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) { 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.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._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) => { 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; 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; 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(); 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() { 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 = Boolean(voice.capture); const tts = Boolean(voice.tts || voice.speech); this._eachView((view) => view.setVoiceStatus({ tts, input, wake: voice.wake })); } } 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)); }); } _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 {} 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(); 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 };