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 } }