1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
|
// 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 reloadDesktop() { fv.reload(); }
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);
}
// One ssh at a time: a second refresh, or one during a save, is dropped.
function fetch(thenSave) {
if (root.busy) return;
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;
root.vm.error = "";
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;
}
// The draft as sent: typing while the save runs must not be
// recorded as saved.
put.sent = root.vm.draft;
put.running = false;
put.running = true;
}
}
Process {
id: put
property string sent: ""
command: root.ssh("config-set")
stdinEnabled: true
stdout: StdioCollector { id: putOut }
stderr: StdioCollector { id: putErr }
onStarted: { put.write(put.sent); 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 = put.sent;
root.vm.base = put.sent;
} else root.vm.error = d && d.error ? d.error : (putErr.text.trim() || `ssh exited ${code}`);
}
}
}
|