Own printers, driverless only

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
This commit is contained in:
Gabriel Brown
2026-08-19 15:07:17 -04:00
parent 914d58f52b
commit e9d567aa72
11 changed files with 944 additions and 5 deletions
+163
View File
@@ -0,0 +1,163 @@
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
}
}