import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import St from 'gi://St'; 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'; const BUS_NAME = 'org.parley.Transcription1'; const OBJECT_PATH = '/org/parley/Transcription1'; const Interface = ` `; const ParleyProxy = Gio.DBusProxy.makeProxyWrapper(Interface); const PRESENTATION = { idle: ['audio-input-microphone-symbolic', 'Parley is ready', 'system-status-icon'], recording: ['media-record-symbolic', 'Recording', 'system-status-icon parley-recording'], transcribing: ['emblem-synchronizing-symbolic', 'Transcribing locally', 'system-status-icon parley-processing'], inserting: ['edit-paste-symbolic', 'Inserting transcript', 'system-status-icon parley-processing'], error: ['dialog-error-symbolic', 'Parley error', 'system-status-icon parley-error'], }; const Indicator = GObject.registerClass( class Indicator extends PanelMenu.Button { _init(extension) { super._init(0.0, 'Parley Dictation'); this._extension = extension; this._destroyed = false; this._proxy = null; this._dbusSignals = []; this._gobjectSignals = []; this._icon = new St.Icon({ icon_name: 'audio-input-microphone-symbolic', style_class: 'system-status-icon', }); this.add_child(this._icon); this._statusItem = new PopupMenu.PopupMenuItem('Connecting to Parley…', { reactive: false, }); this.menu.addMenuItem(this._statusItem); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this._toggleItem = new PopupMenu.PopupMenuItem('Start recording'); this._toggleItem.connect('activate', () => this._call('Toggle')); this.menu.addMenuItem(this._toggleItem); this._cancelItem = new PopupMenu.PopupMenuItem('Cancel'); this._cancelItem.connect('activate', () => this._call('Cancel')); this.menu.addMenuItem(this._cancelItem); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this._copyItem = new PopupMenu.PopupMenuItem('Copy last transcript'); this._copyItem.connect('activate', () => this._call('CopyLastTranscript')); this.menu.addMenuItem(this._copyItem); this._openItem = new PopupMenu.PopupMenuItem('Open transcript folder'); this._openItem.connect('activate', () => this._call('OpenTranscriptFolder')); this.menu.addMenuItem(this._openItem); this._setUnavailable('Connecting to Parley…'); this._connectProxy(); } _connectProxy() { this._proxy = new ParleyProxy( Gio.DBus.session, BUS_NAME, OBJECT_PATH, (proxy, error) => { if (this._destroyed) return; if (error) { console.error(`Parley D-Bus connection failed: ${error.message}`); this._setUnavailable('Parley service unavailable'); return; } this._dbusSignals.push( proxy.connectSignal('StateChanged', () => this._refresh()), proxy.connectSignal('TranscriptReady', () => this._refresh()), proxy.connectSignal('Error', (_proxy, _sender, [code, message]) => { console.error(`Parley ${code}: ${message}`); this._refresh(); }) ); this._gobjectSignals.push( proxy.connect('g-properties-changed', () => this._refresh()), proxy.connect('notify::g-name-owner', () => this._refresh()) ); this._refresh(); } ); } _refresh() { if (!this._proxy?.g_name_owner) { this._setUnavailable('Parley service unavailable'); return; } const state = this._proxy.State ?? 'idle'; const [iconName, defaultStatus, styleClass] = PRESENTATION[state] ?? PRESENTATION.error; let status = defaultStatus; if (state === 'error' && this._proxy.LastError) status = `Error: ${this._proxy.LastError}`; this._icon.icon_name = iconName; this._icon.set_style_class_name(styleClass); this.accessible_name = status; this._statusItem.label.text = status; this._toggleItem.label.text = state === 'recording' ? 'Stop and transcribe' : 'Start recording'; this._toggleItem.setSensitive(['idle', 'recording', 'error'].includes(state)); this._cancelItem.setSensitive(['recording', 'transcribing'].includes(state)); this._copyItem.setSensitive(Boolean(this._proxy.LastTranscriptPath)); this._openItem.setSensitive(true); } _setUnavailable(message) { this._icon.icon_name = 'microphone-disabled-symbolic'; this._icon.set_style_class_name('system-status-icon parley-error'); this.accessible_name = message; this._statusItem.label.text = message; this._toggleItem?.setSensitive(false); this._cancelItem?.setSensitive(false); this._copyItem?.setSensitive(false); this._openItem?.setSensitive(false); } _call(method) { if (!this._proxy?.g_name_owner) return; this._proxy[`${method}Remote`]((_result, error) => { if (error) { console.error(`Parley ${method} failed: ${error.message}`); } }); } destroy() { this._destroyed = true; if (this._proxy) { for (const signal of this._dbusSignals) this._proxy.disconnectSignal(signal); for (const signal of this._gobjectSignals) this._proxy.disconnect(signal); } this._dbusSignals = []; this._gobjectSignals = []; this._proxy = null; super.destroy(); } }); export default class ParleyExtension extends Extension { enable() { this._indicator = new Indicator(this); Main.panel.addToStatusArea(this.uuid, this._indicator); } disable() { this._indicator?.destroy(); this._indicator = null; } }