pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // Dictation: the two pieces that have to be present before holding the key does // anything, and the download of the one that is not a package. // // Neither piece is installed by ./install, deliberately. The model is half a // gigabyte that no repository carries, and the speech server is a container // image compiled from upstream -- both are slow, both want the network, and an // installer that quietly does either is the surprise the interview exists to // prevent. So this reports what is missing and offers the part it can do. // // Status is read from the helper rather than inferred. Whether an image exists // and whether a server is answering are facts about the machine, and a page // that guessed at them would be confidently wrong on exactly the machine where // somebody is trying to work out why dictation is silent. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root readonly property string helper: Quickshell.shellDir + "/scripts/panama-dictate" property bool modelInstalled: false property bool imageBuilt: false property bool serverReady: false property bool typingAvailable: false property int modelBytes: 0 property bool downloading: false property int downloadedBytes: 0 property int downloadTotalBytes: 0 property string lastError: "" readonly property bool ready: root.modelInstalled && root.imageBuilt readonly property real downloadFraction: root.downloadTotalBytes > 0 ? Math.min(1, root.downloadedBytes / root.downloadTotalBytes) : 0 // What is missing, in the order it has to be fixed. The image first: the // model is useless without something to run it, and telling somebody to // download half a gigabyte before mentioning they also need to build an // image would be two trips. readonly property string missing: { if (!root.imageBuilt) return "image"; if (!root.modelInstalled) return "model"; return ""; } Process { id: statusRun command: [root.helper, "status"] stdout: StdioCollector { onStreamFinished: { try { const state = JSON.parse(this.text); root.modelInstalled = state.modelInstalled === true; root.imageBuilt = state.imageBuilt === true; root.serverReady = state.serverReady === true; root.typingAvailable = state.typingAvailable === true; root.modelBytes = state.modelBytes ?? 0; } catch (error) { root.lastError = "Could not read dictation status."; } } } } Process { id: downloadRun command: [root.helper, "download"] stdout: SplitParser { // One JSON object per line, roughly once a second, so the progress // bar moves without the helper flooding a pipe nobody drains. onRead: line => { try { const update = JSON.parse(line); if (update.ok === false) { root.lastError = "The model could not be downloaded."; return; } if (update.bytes !== undefined) root.downloadedBytes = update.bytes; if (update.total !== undefined) root.downloadTotalBytes = update.total; } catch (error) { // A line that is not JSON is not worth failing a download // over; the exit status is what decides. } } } onExited: (exitCode, exitStatus) => { root.downloading = false; if (exitCode !== 0 && root.lastError === "") root.lastError = "The model could not be downloaded."; root.refresh(); } } function refresh(): void { if (!statusRun.running) statusRun.running = true; } function download(): void { if (downloadRun.running) return; root.lastError = ""; root.downloadedBytes = 0; root.downloadTotalBytes = 0; root.downloading = true; downloadRun.running = true; } Component.onCompleted: root.refresh() }