The fourth panel this desktop handed to GNOME Settings, and the last one worth owning. Driverless only, deliberately. Adding a printer that describes its own capabilities over IPP is supported; choosing a PPD or fetching a vendor driver is not, and the page says so rather than pretending. That restraint is the whole design: a wrong driver produces a printer that accepts jobs, reports success, and prints nothing, which is the worst failure this page could ship because it looks like it worked. A printer old enough to need a PPD stays a job for the system printer tool. Printers and the queue are separate cards because they answer separate questions. Which printers exist is one; where a document went is the other, and it is the one that actually brings someone here -- so the queue is a single list across every printer. Device URIs are validated by scheme before reaching CUPS, whose backends run as root. file: and pipe: do not lead to a printer and are refused here rather than further down. This machine has no printer, so the page was built against a temporary CUPS queue that was created, exercised through the service, and removed; the service was confirmed to observe the removal rather than merely perform it. Discovery and the driverless add path are verified by their refusals rather than against hardware. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
164 lines
5.7 KiB
QML
164 lines
5.7 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;
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (query.running)
|
|
return;
|
|
query.command = [root.helperPath, "snapshot"];
|
|
query.running = true;
|
|
}
|
|
|
|
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 ?? "");
|
|
} 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]); }
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|