269 lines
9.8 KiB
QML
269 lines
9.8 KiB
QML
pragma Singleton
|
|
|
|
// Printers, through CUPS' own API.
|
|
//
|
|
// Driverless only: this adds printers that describe their own capabilities over
|
|
// IPP. Choosing PPDs and downloading vendor drivers is most of what the panel
|
|
// this replaces does, and getting it wrong yields a printer that accepts jobs
|
|
// and silently prints nothing -- so the page says a printer needs a driver
|
|
// rather than guessing one.
|
|
//
|
|
// Discovery is separate from the snapshot because it takes seconds of network
|
|
// waiting, and a settings page should open immediately.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-printers"
|
|
|
|
property var printers: []
|
|
property var jobs: []
|
|
property var service: ({})
|
|
property var discovered: []
|
|
property bool scanned: false
|
|
property bool searching: false
|
|
property bool searched: false
|
|
property string lastError: ""
|
|
|
|
// Guards read the Process objects directly. A derived binding is stale
|
|
// inside the handler that changes it, which silently drops the refresh
|
|
// after a successful write. See DefaultApps.qml.
|
|
readonly property bool busy: query.running || mutation.running
|
|
|
|
readonly property var defaultPrinter: {
|
|
for (const printer of root.printers) {
|
|
if (printer.isDefault)
|
|
return printer;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
readonly property bool anyPrinters: root.printers.length > 0
|
|
|
|
// Printers found on the network that are not already set up here.
|
|
readonly property var addable: root.discovered.filter(entry => {
|
|
for (const printer of root.printers) {
|
|
if (String(printer.uri ?? "") === String(entry.uri ?? ""))
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
|
|
function stateSummary(printer: var): string {
|
|
const message = String(printer?.stateMessage ?? "").trim();
|
|
const state = String(printer?.state ?? "");
|
|
if (state === "stopped")
|
|
return message !== "" ? "Paused — " + message : "Paused";
|
|
if (state === "printing")
|
|
return message !== "" ? "Printing — " + message : "Printing";
|
|
if (printer?.accepting === false)
|
|
return "Not accepting jobs";
|
|
return message !== "" ? message : "Ready";
|
|
}
|
|
|
|
function jobsFor(printerName: string): int {
|
|
let count = 0;
|
|
for (const job of root.jobs) {
|
|
if (job.printer === printerName)
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// printer name -> the helper's get-options shape. Cached because a pair of
|
|
// dropdowns asks for the same printer on every repaint, and each answer is
|
|
// a round trip to CUPS.
|
|
property var options: ({})
|
|
property var pendingOptions: []
|
|
|
|
function refresh(): void {
|
|
if (query.running)
|
|
return;
|
|
query.command = [root.helperPath, "snapshot"];
|
|
query.running = true;
|
|
}
|
|
|
|
// Paper size and two-sided, as the printer reports them. Returns the cached
|
|
// answer, or null while the first one is on its way -- and asks for it, so
|
|
// a dropdown that binds to this fills itself in.
|
|
function optionsFor(name: string): var {
|
|
if (name === "")
|
|
return null;
|
|
if (root.options[name] !== undefined)
|
|
return root.options[name];
|
|
root.requestOptions(name);
|
|
return null;
|
|
}
|
|
|
|
function requestOptions(name: string): void {
|
|
if (name === "" || root.pendingOptions.indexOf(name) >= 0)
|
|
return;
|
|
root.pendingOptions = root.pendingOptions.concat([name]);
|
|
root.drainOptions();
|
|
}
|
|
|
|
function refreshOptions(name: string): void { root.requestOptions(name); }
|
|
|
|
function drainOptions(): void {
|
|
if (optionsQuery.running || root.pendingOptions.length === 0)
|
|
return;
|
|
optionsQuery.subject = root.pendingOptions[0];
|
|
optionsQuery.command = [root.helperPath, "get-options", optionsQuery.subject];
|
|
optionsQuery.running = true;
|
|
}
|
|
|
|
// Reassigned rather than mutated: QML does not notice a property change
|
|
// made inside a var object.
|
|
function absorbOptions(parsed: var): void {
|
|
const name = String(parsed?.printer ?? "");
|
|
if (name === "")
|
|
return;
|
|
const next = Object.assign({}, root.options);
|
|
next[name] = parsed;
|
|
root.options = next;
|
|
if (String(parsed.error ?? "") !== "")
|
|
root.lastError = String(parsed.error);
|
|
}
|
|
|
|
function search(): void {
|
|
if (root.searching)
|
|
return;
|
|
root.searching = true;
|
|
discovery.command = [root.helperPath, "discover"];
|
|
discovery.running = true;
|
|
}
|
|
|
|
function absorb(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
root.printers = Array.isArray(parsed.printers) ? parsed.printers : [];
|
|
root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
|
|
root.service = parsed.service ?? ({});
|
|
root.lastError = String(parsed.error ?? "");
|
|
// set-option answers with the snapshot AND the printer it touched,
|
|
// re-read -- so the dropdown that made the change updates from the
|
|
// reply rather than from a second round trip.
|
|
if (parsed.options)
|
|
root.absorbOptions(parsed.options);
|
|
} catch (error) {
|
|
root.lastError = "Could not read the printing service's answer.";
|
|
console.warn("Printers: could not parse helper output:", error);
|
|
}
|
|
root.scanned = true;
|
|
}
|
|
|
|
function run(arguments: var): void {
|
|
if (mutation.running)
|
|
return;
|
|
root.lastError = "";
|
|
mutation.command = [root.helperPath].concat(arguments);
|
|
mutation.running = true;
|
|
}
|
|
|
|
function add(uri: string, name: string): void { root.run(["add", uri, name]); }
|
|
function remove(name: string): void { root.run(["remove", name]); }
|
|
function setDefault(name: string): void { root.run(["set-default", name]); }
|
|
function pause(name: string): void { root.run(["pause", name]); }
|
|
function resume(name: string): void { root.run(["resume", name]); }
|
|
function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); }
|
|
function testPage(name: string): void { root.run(["test-page", name]); }
|
|
|
|
// Holding keeps the job in the queue; cancelling throws it away. The
|
|
// difference matters because "stop this print" and "reprint fifty pages"
|
|
// are not meant to be the same button.
|
|
function hold(jobId: int): void { root.run(["hold", String(jobId)]); }
|
|
function release(jobId: int): void { root.run(["release", String(jobId)]); }
|
|
|
|
// One printer default, from the helper's closed vocabulary: media is
|
|
// Letter, A4 or Legal; sides is one-sided or one of the two two-sided
|
|
// bindings. Anything else the helper refuses -- there is no passthrough to
|
|
// lpadmin here.
|
|
function setOption(name: string, key: string, value: string): void {
|
|
root.run(["set-option", name, key, value]);
|
|
}
|
|
|
|
// Whether a job is held, from the state the helper reports.
|
|
function isHeld(job: var): bool { return String(job?.state ?? "") === "held"; }
|
|
|
|
// A queue name CUPS will accept, derived from what the printer calls itself.
|
|
function suggestedName(label: string): string {
|
|
const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
return cleaned === "" ? "printer" : cleaned.slice(0, 60);
|
|
}
|
|
|
|
Process {
|
|
id: query
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: mutation
|
|
// Answers with the fresh state, so the page updates from the change
|
|
// itself rather than asking again afterwards.
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
// Kept out of `busy`: reading a printer's defaults changes nothing, so it
|
|
// must not disable the controls that do.
|
|
Process {
|
|
id: optionsQuery
|
|
|
|
property string subject: ""
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
root.absorbOptions(JSON.parse(this.text));
|
|
} catch (error) {
|
|
root.lastError = "Could not read that printer's settings.";
|
|
console.warn("Printers: could not parse get-options output:", error);
|
|
}
|
|
}
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
onExited: {
|
|
root.pendingOptions = root.pendingOptions.filter(
|
|
name => name !== optionsQuery.subject);
|
|
optionsDrain.restart();
|
|
}
|
|
}
|
|
|
|
// One tick later, because `running` has not gone false inside onExited and
|
|
// the queue would stall on its own guard.
|
|
Timer {
|
|
id: optionsDrain
|
|
interval: 0
|
|
onTriggered: root.drainOptions()
|
|
}
|
|
|
|
Process {
|
|
id: discovery
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const parsed = JSON.parse(this.text);
|
|
root.discovered = Array.isArray(parsed.found) ? parsed.found : [];
|
|
if (String(parsed.error ?? "") !== "")
|
|
root.lastError = String(parsed.error);
|
|
} catch (error) {
|
|
root.lastError = "Could not read the discovery result.";
|
|
}
|
|
root.searched = true;
|
|
}
|
|
}
|
|
onExited: root.searching = false
|
|
}
|
|
}
|