355 lines
18 KiB
JavaScript
355 lines
18 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 Pango from 'gi://Pango';
|
|
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 DEFAULT_ACCENT = '#F4B942';
|
|
const SIGNAL = '#4FD2FF';
|
|
|
|
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, brandDir: this.path });
|
|
this.osd = new JarvisOsd();
|
|
this.cu = new ComputerUseChrome(); this.cu.attach();
|
|
this.session = new SessionPanel({ brandDir: this.path }); this.session.attach();
|
|
this._bindSurface(this.popup);
|
|
this._bindSurface(this.session.view);
|
|
this._applyAccessibility();
|
|
this._removeShellService = installShellService();
|
|
this._assistantName = 'Jarvis';
|
|
this._muted = false;
|
|
this._muteTouched = false;
|
|
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._mark = new St.Icon({ icon_name: 'audio-input-microphone-symbolic', icon_size: 16, style_class: 'jarvis-panel-mark', y_align: Clutter.ActorAlign.CENTER });
|
|
try {
|
|
const file = Gio.File.new_for_path(`${this.path}/brand/icons/jarvis-mark.svg`);
|
|
if (file.query_exists(null)) this._mark.gicon = new Gio.FileIcon({ file });
|
|
} catch (error) { log(`Jarvis mark unavailable: ${error.message}`); }
|
|
this._mark.set_icon_size(16);
|
|
this._glyph = new St.Label({ text: 'Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle';
|
|
if (this._glyph.clutter_text) this._glyph.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
|
|
this._panelBox.add_child(this._mark);
|
|
this._panelBox.add_child(this._glyph);
|
|
this.osd.attach(this._panelBox, this._glyph);
|
|
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) { if (!this._muted) this._call('SetListening', '(b)', [true]); 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();
|
|
if (!this._muted) this._call('SetListening', '(b)', [true]);
|
|
});
|
|
} 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 (this._muted) return; if (pressed) this._call('SetListening', '(b)', [true]); else this._call('SetListening', '(b)', [false]); };
|
|
view.onListen = () => { if (this._muted) return; this._call('SetListening', '(b)', [view._state !== 'LISTENING']); };
|
|
view.onMute = (muted) => {
|
|
this._muted = Boolean(muted);
|
|
this._muteTouched = true;
|
|
this._eachView((surface) => surface.setMuted(this._muted));
|
|
this._call('SetMuted', '(b)', [this._muted]);
|
|
};
|
|
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();
|
|
else this.popup.followActive();
|
|
});
|
|
}
|
|
_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 remoteMuted = Boolean(voice.muted ?? status.muted);
|
|
if (!this._muteTouched) this._muted = remoteMuted;
|
|
const muted = Boolean(this._muted);
|
|
const input = Boolean(voice.capture) || muted;
|
|
const tts = Boolean(voice.tts || voice.speech);
|
|
this._eachView((view) => view.setVoiceStatus({ tts, input, wake: muted ? false : voice.wake, muted }));
|
|
}
|
|
const name = status.settings?.assistantName;
|
|
if (name) this._applyAssistantName(name);
|
|
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
|
}
|
|
_call(name, signature, value) {
|
|
if (!this.proxy?.owned?.()) {
|
|
if (name !== 'PushToTalk' && name !== 'SetMuted' && name !== 'SetListening') this._eachView((view) => view.setNotice(`${this._assistantName || 'Jarvis'} is starting…`));
|
|
this._connectDaemon();
|
|
return;
|
|
}
|
|
this.proxy.call(name, signature, value).catch((error) => {
|
|
log(`Jarvis ${name}: ${error.message}`);
|
|
if (name === 'PushToTalk' || name === 'SetMuted' || name === 'SetListening') return;
|
|
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
|
this._eachView((view) => view.setNotice(fallback));
|
|
});
|
|
}
|
|
_applyAssistantName(name) {
|
|
const text = safeText(name).slice(0, 32) || 'Jarvis';
|
|
this._assistantName = text;
|
|
this._eachView((view) => view.setAssistantName?.(text));
|
|
this._indicator.accessible_name = `${text} voice assistant`;
|
|
this._glyph.accessible_name = `${text} idle`;
|
|
if (this._glyph.visible !== false) this._glyph.text = text;
|
|
}
|
|
_setState(state) {
|
|
const value = safeText(state);
|
|
const name = this._assistantName || 'Jarvis';
|
|
this._glyph.text = name;
|
|
this._glyph.accessible_name = `${name} ${value.toLowerCase()}`;
|
|
this._indicator.accessible_name = `${name} ${value.toLowerCase()}`;
|
|
if (this._panelBox) {
|
|
for (const name of ['armed', 'listening', 'speaking', 'thinking', 'sleeping']) {
|
|
this._panelBox.remove_style_class_name(`jarvis-state-${name}`);
|
|
}
|
|
this._panelBox.add_style_class_name(`jarvis-state-${value.toLowerCase()}`);
|
|
}
|
|
this._eachView((view) => view.setState(value));
|
|
this.osd.setState(value);
|
|
}
|
|
_applyAccent() {
|
|
const color = this.settings.get_string('accent-color');
|
|
const accent = /^#[0-9A-Fa-f]{6}$/.test(String(color || '')) ? color : DEFAULT_ACCENT;
|
|
const style = `--jarvis-accent: ${accent}; --jarvis-signal: ${SIGNAL};`;
|
|
this.popup.root.set_style(style);
|
|
this.session.root.set_style(style);
|
|
this.osd.root.set_style(style);
|
|
this.cu.root.set_style(style);
|
|
this._panelBox?.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._mark = this.proxy = null;
|
|
}
|
|
}
|
|
|
|
export { JarvisProxy, ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel };
|