diff options
| -rw-r--r-- | desktop/modules/sbbuilder/Sbb.qml | 150 | ||||
| -rw-r--r-- | desktop/modules/sbbuilder/Store.qml | 79 |
2 files changed, 229 insertions, 0 deletions
diff --git a/desktop/modules/sbbuilder/Sbb.qml b/desktop/modules/sbbuilder/Sbb.qml new file mode 100644 index 0000000..d623423 --- /dev/null +++ b/desktop/modules/sbbuilder/Sbb.qml @@ -0,0 +1,150 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import "Toml.js" as T + +// sb-builder's two configs. The desktop one is a local file, watched; the +// GUI watches it too, so a save applies there at once. The VM one is root's +// file on the build VM, read and written only through the helper's +// config-get and config-set, which validate it: the page never writes it +// over ssh by hand. +Singleton { + id: root + + readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + + "/sb-builder/config.toml" + + // ponytail: mirrors DEFAULTS in sb-builder's sb_builder/config.py; a key + // added there needs adding here. + readonly property Store desktop: Store { + defaults: ({ + remote: { target: "buildsystem", ssh_options: [] }, + refresh: { dashboard: 30, jobs: 5 }, + notify: { enabled: true, events: ["finished", "failed", "killed", "lost", "vm-up"] }, + vm: { libvirt_uri: "qemu:///system", domain: "buildsystem", autostart: false }, + builds: { publish: true }, + hints: { nvchecker_keyfile: "~/.config/nvchecker/keys.toml" }, + repository: { path: "/repo" }, + }) + } + readonly property Store vm: Store {} + + // The VM side: reachable once a config-get answered; busy while one runs. + property bool reachable: false + property bool busy: false + property string vmPath: "" + + // From the desktop file as saved, not its draft: an unsaved target is + // not where the GUI connects. + function ssh(cmd) { + const r = T.get(root.desktop.text, "remote", "target") ?? root.desktop.defaults.remote.target; + const o = T.get(root.desktop.text, "remote", "ssh_options") ?? []; + return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", ...o, r, "sbb-remote", cmd]; + } + + function refresh() { + fv.reload(); + root.fetch(false); + } + + function saveDesktop() { + const d = root.desktop; + if (d.text !== d.base) { d.error = "changed on disk: Discard to load it, then redo"; return; } + if (d.draft === "") return; // FileView.setText("") writes nothing (AGENTS.md) + d.error = ""; + d.text = d.draft; + d.base = d.draft; + fv.setText(d.draft); + } + + // A save first re-reads the VM file: one changed since the draft began + // is refused rather than written over. + function saveVm() { + root.vm.error = ""; + root.fetch(true); + } + + function fetch(thenSave) { + get.thenSave = thenSave; + root.busy = true; + get.running = false; + get.running = true; + } + + FileView { + id: fv + path: root.path + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.desktop.loaded(text()) + onLoadFailed: root.desktop.loaded("") + onSaveFailed: err => root.desktop.error = "cannot write: " + FileViewError.toString(err) + } + + Process { + id: get + property bool thenSave: false + command: root.ssh("config-get") + stdout: StdioCollector { id: getOut } + stderr: StdioCollector { id: getErr } + onExited: code => { + let d = null; + try { d = JSON.parse(getOut.text); } catch (e) {} + if (!d || d.error !== undefined) { + root.busy = false; + root.reachable = false; + root.vm.error = d ? d.error : (getErr.text.trim() || `ssh exited ${code}`); + return; + } + const defs = Object.assign({}, d.defaults); + const top = {}; + for (const k in defs) if (k !== "manifests") top[k] = defs[k]; + root.vm.defaults = { "": top, manifests: defs.manifests || {} }; + root.vmPath = d.path; + root.reachable = true; + if (!get.thenSave) { root.busy = false; root.vm.loaded(d.text); return; } + if (d.text !== root.vm.base) { + root.busy = false; + root.vm.text = d.text; + root.vm.error = "changed on the VM: Discard to load it, then redo"; + return; + } + put.running = false; + put.running = true; + } + } + + Process { + id: put + command: root.ssh("config-set") + stdinEnabled: true + stdout: StdioCollector { id: putOut } + stderr: StdioCollector { id: putErr } + onStarted: { put.write(root.vm.draft); put.stdinEnabled = false; } + onExited: code => { + root.busy = false; + put.stdinEnabled = true; + let d = null; + try { d = JSON.parse(putOut.text); } catch (e) {} + if (code === 0 && d && d.error === undefined) { + root.vm.text = root.vm.draft; + root.vm.base = root.vm.draft; + } else root.vm.error = d && d.error ? d.error : (putErr.text.trim() || `ssh exited ${code}`); + } + } +} diff --git a/desktop/modules/sbbuilder/Store.qml b/desktop/modules/sbbuilder/Store.qml new file mode 100644 index 0000000..b4a1ba1 --- /dev/null +++ b/desktop/modules/sbbuilder/Store.qml @@ -0,0 +1,79 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "Toml.js" as T + +// One config.toml's draft, the store qtmaildir's Setting and BoolSetting +// edit. Fields hand it text; it writes the type of the key's default, the +// type sb-builder checks on load: a list is words split on spaces, a number +// is a number, a switch is true or false. +QtObject { + id: st + // The file as last read or written, the draft's start, and the draft. + property string text: "" + property string base: "" + property string draft: "" + readonly property bool dirty: st.draft !== st.base + // {section: {key: value}}, top-level keys under "". + property var defaults: ({}) + property var invalid: ({}) + readonly property bool valid: Object.keys(st.invalid).length === 0 + property string error: "" + // Assigned only when the set changes, as Qtm does: a Repeater bound to a + // fresh array per keystroke rebuilds the row being typed in. + property var manifests: [] + onDraftChanged: { + const k = T.entries(st.draft, "manifests").map(e => e.key); + if (JSON.stringify(k) !== JSON.stringify(st.manifests)) st.manifests = k; + } + + function fallback(section, key) { return (st.defaults[section] || {})[key]; } + function show(v) { return v === undefined ? undefined : Array.isArray(v) ? v.join(" ") : String(v); } + function placeholder(section, key) { return st.show(st.fallback(section, key)) ?? ""; } + + function get(section, key) { return st.show(T.get(st.draft, section, key)); } + // The effective value, typed: the file's, else the default. + function value(section, key) { return T.get(st.draft, section, key) ?? st.fallback(section, key); } + function entries(section) { return T.entries(st.draft, section); } + function put(section, key, v) { + try { st.draft = T.set(st.draft, section, key, v); } catch (e) { st.error = e.message; } + } + // A field's text; null or "" unsets. + function set(section, key, t) { + if (t === null || t === "") return st.put(section, key, null); + const d = st.fallback(section, key); + st.put(section, key, Array.isArray(d) ? t.split(/\s+/).filter(x => x !== "") + : typeof d === "number" ? Number(t) + : typeof d === "boolean" ? t === "true" : t); + } + function remove(section, key) { st.draft = T.remove(st.draft, section, key); } + + function markInvalid(id, bad) { + if (!!st.invalid[id] === bad) return; + const next = Object.assign({}, st.invalid); + if (bad) next[id] = true; else delete next[id]; + st.invalid = next; + } + + // The file as read: a clean draft follows it, a dirty one is kept and + // its save is refused. + function loaded(t) { + st.text = t; + if (!st.dirty) { st.base = t; st.draft = t; } + } + function discard() { + st.error = ""; + st.invalid = ({}); + st.base = st.text; + st.draft = st.text; + } +} |
