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
354 lines
14 KiB
QML
354 lines
14 KiB
QML
// Printers, and one queue across all of them.
|
|
//
|
|
// The list answers "which printers exist"; the queue answers "where is my
|
|
// document", which is the question that actually brings someone here. They are
|
|
// separate cards because they are separate questions -- a job that has not come
|
|
// out is not necessarily a problem with the printer it was sent to.
|
|
//
|
|
// Driverless only. A printer old enough to need a PPD is named as such rather
|
|
// than offered and then failing at the moment it is added.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "printers"
|
|
title: "Printers"
|
|
lede: "Printers this machine can use, and what they are waiting on."
|
|
|
|
property string expandedPrinter: ""
|
|
property string confirmingRemoval: ""
|
|
property bool addingByAddress: false
|
|
property string manualUri: ""
|
|
property string manualName: ""
|
|
|
|
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
|
|
&& root.manualName.trim() !== ""
|
|
|
|
Component.onCompleted: Printers.refresh()
|
|
|
|
TextRow {
|
|
visible: Printers.lastError !== ""
|
|
label: "Printing needs attention"
|
|
detail: Printers.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
// ── Which printers exist ─────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
visible: Printers.anyPrinters
|
|
title: "Printers"
|
|
subtitle: "Open one to change what it does by default."
|
|
|
|
Repeater {
|
|
model: Printers.printers
|
|
|
|
delegate: Column {
|
|
id: printerBlock
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property string name: String(printerBlock.modelData.name ?? "")
|
|
readonly property bool open: root.expandedPrinter === printerBlock.name
|
|
readonly property bool paused: String(printerBlock.modelData.state ?? "") === "stopped"
|
|
|
|
width: parent.width
|
|
|
|
SettingRow {
|
|
width: printerBlock.width
|
|
icon: printerBlock.paused ? "\u{F0026}" : "\u{F042A}"
|
|
label: String(printerBlock.modelData.description ?? printerBlock.name)
|
|
+ (printerBlock.modelData.isDefault ? " · Default" : "")
|
|
detail: Printers.stateSummary(printerBlock.modelData)
|
|
+ " · " + String(printerBlock.modelData.uri ?? "")
|
|
value: Printers.jobsFor(printerBlock.name) + " job"
|
|
+ (Printers.jobsFor(printerBlock.name) === 1 ? "" : "s")
|
|
activatable: !Printers.busy
|
|
divider: !printerBlock.open
|
|
&& printerBlock.index < Printers.printers.length - 1
|
|
onActivated: {
|
|
root.confirmingRemoval = "";
|
|
root.expandedPrinter = printerBlock.open ? "" : printerBlock.name;
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: printerBlock.width
|
|
visible: printerBlock.open
|
|
|
|
TextRow {
|
|
width: printerBlock.width
|
|
label: "Model"
|
|
detail: "Reported by the printer itself"
|
|
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
|
|
}
|
|
|
|
ActionRow {
|
|
width: printerBlock.width
|
|
visible: !printerBlock.modelData.isDefault
|
|
label: "Use by default"
|
|
detail: "Applications print here unless they are told otherwise"
|
|
action: "Make default"
|
|
enabled: !Printers.busy
|
|
onTriggered: Printers.setDefault(printerBlock.name)
|
|
}
|
|
|
|
ActionRow {
|
|
width: printerBlock.width
|
|
label: printerBlock.paused ? "Resume printing" : "Pause printing"
|
|
detail: printerBlock.paused
|
|
? "Queued jobs start again"
|
|
: "Jobs keep queuing, but nothing is sent until it resumes"
|
|
action: printerBlock.paused ? "Resume" : "Pause"
|
|
enabled: !Printers.busy
|
|
onTriggered: printerBlock.paused
|
|
? Printers.resume(printerBlock.name)
|
|
: Printers.pause(printerBlock.name)
|
|
}
|
|
|
|
ActionRow {
|
|
width: printerBlock.width
|
|
label: "Print a test page"
|
|
detail: "Confirms the printer answers and puts ink on paper"
|
|
action: "Print"
|
|
enabled: !Printers.busy && !printerBlock.paused
|
|
onTriggered: Printers.testPage(printerBlock.name)
|
|
}
|
|
|
|
SettingRow {
|
|
width: printerBlock.width
|
|
label: "Remove this printer"
|
|
detail: root.confirmingRemoval === printerBlock.name
|
|
? "Anything still queued for it is cancelled."
|
|
: "It can be added again later"
|
|
controlWidth: 200
|
|
divider: printerBlock.index < Printers.printers.length - 1
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
text: root.confirmingRemoval === printerBlock.name ? "Keep" : "Remove…"
|
|
enabled: !Printers.busy
|
|
onClicked: root.confirmingRemoval =
|
|
root.confirmingRemoval === printerBlock.name ? "" : printerBlock.name
|
|
}
|
|
|
|
SettingsButton {
|
|
visible: root.confirmingRemoval === printerBlock.name
|
|
text: "Remove"
|
|
tone: "danger"
|
|
enabled: !Printers.busy
|
|
onClicked: {
|
|
root.confirmingRemoval = "";
|
|
root.expandedPrinter = "";
|
|
Printers.remove(printerBlock.name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Where my document is ─────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
visible: Printers.anyPrinters
|
|
title: "Print queue"
|
|
subtitle: Printers.jobs.length === 0
|
|
? "Nothing is waiting."
|
|
: "Everything waiting, across every printer."
|
|
|
|
Repeater {
|
|
model: Printers.jobs
|
|
|
|
delegate: ActionRow {
|
|
id: jobRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: parent.width
|
|
label: String(jobRow.modelData.name ?? "Untitled")
|
|
detail: String(jobRow.modelData.printer ?? "") + " · "
|
|
+ String(jobRow.modelData.state ?? "")
|
|
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
|
? " · " + jobRow.modelData.pages + " pages" : "")
|
|
action: "Cancel"
|
|
enabled: !Printers.busy
|
|
divider: jobRow.index < Printers.jobs.length - 1
|
|
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: Printers.jobs.length === 0
|
|
label: "Queue is empty"
|
|
detail: "Jobs appear here while they wait, and can be cancelled from here"
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── Nothing set up yet ───────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
visible: Printers.scanned && !Printers.anyPrinters
|
|
title: "No printers yet"
|
|
subtitle: "Printers that announce themselves on your network appear here on their own."
|
|
|
|
ActionRow {
|
|
label: "Search the network"
|
|
detail: Printers.searching
|
|
? "Listening for printers that announce themselves…"
|
|
: (Printers.searched
|
|
? Printers.addable.length + " found"
|
|
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
|
action: Printers.searching ? "Searching…" : "Search"
|
|
enabled: !Printers.searching
|
|
divider: false
|
|
onTriggered: Printers.search()
|
|
}
|
|
}
|
|
|
|
// ── Adding ───────────────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Add a printer"
|
|
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
|
|
|
ActionRow {
|
|
visible: Printers.anyPrinters
|
|
label: "Search the network"
|
|
detail: Printers.searching
|
|
? "Listening for printers that announce themselves…"
|
|
: (Printers.searched
|
|
? Printers.addable.length + " printer"
|
|
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
|
|
: "Looks for printers over mDNS")
|
|
action: Printers.searching ? "Searching…" : "Search"
|
|
enabled: !Printers.searching
|
|
onTriggered: Printers.search()
|
|
}
|
|
|
|
Repeater {
|
|
model: Printers.addable
|
|
|
|
delegate: ActionRow {
|
|
id: foundRow
|
|
|
|
required property var modelData
|
|
|
|
width: parent.width
|
|
label: String(foundRow.modelData.name ?? "")
|
|
detail: foundRow.modelData.driverless === true
|
|
? String(foundRow.modelData.uri ?? "") + " · driverless"
|
|
: String(foundRow.modelData.uri ?? "") + " · needs a manufacturer driver"
|
|
action: foundRow.modelData.driverless === true ? "Add" : "Not supported"
|
|
enabled: !Printers.busy && foundRow.modelData.driverless === true
|
|
onTriggered: Printers.add(String(foundRow.modelData.uri ?? ""),
|
|
Printers.suggestedName(String(foundRow.modelData.name ?? "")))
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: Printers.searched && Printers.addable.length === 0 && !Printers.searching
|
|
label: "Nothing found"
|
|
detail: "No printer announced itself. It may be asleep, on another network, or may not support network discovery."
|
|
value: ""
|
|
}
|
|
|
|
ActionRow {
|
|
label: "Add by address"
|
|
detail: "For a printer that does not announce itself"
|
|
action: root.addingByAddress ? "Cancel" : "Enter…"
|
|
enabled: !Printers.busy
|
|
divider: root.addingByAddress
|
|
onTriggered: {
|
|
root.addingByAddress = !root.addingByAddress;
|
|
root.manualUri = "";
|
|
root.manualName = "";
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
visible: root.addingByAddress
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
label: "Address"
|
|
detail: "ipp://, ipps://, or socket:// followed by the printer's host"
|
|
placeholder: "ipp://printer.local/ipp/print"
|
|
text: root.manualUri
|
|
onAccepted: value => root.manualUri = value
|
|
}
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
label: "Name"
|
|
detail: "What this printer is called on this machine"
|
|
placeholder: "office-printer"
|
|
text: root.manualName
|
|
onAccepted: value => root.manualName = value
|
|
}
|
|
|
|
ActionRow {
|
|
width: parent.width
|
|
label: "Add this printer"
|
|
detail: "The printer is asked what it can do; if it cannot answer, it is not added"
|
|
action: "Add"
|
|
enabled: root.manualReady && !Printers.busy
|
|
divider: false
|
|
onTriggered: {
|
|
Printers.add(root.manualUri, Printers.suggestedName(root.manualName));
|
|
root.addingByAddress = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── The service underneath ───────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Printing service"
|
|
subtitle: "What has to be running for any of this to work."
|
|
|
|
TextRow {
|
|
label: "CUPS"
|
|
detail: Printers.service?.running === true
|
|
? "Running, so a printer added here works immediately"
|
|
: "Not running, so nothing can print"
|
|
value: Printers.service?.running === true ? "Running" : "Stopped"
|
|
}
|
|
|
|
TextRow {
|
|
label: "Starts at boot"
|
|
detail: Printers.service?.startsAtBoot === true
|
|
? "Started with the system"
|
|
: "Started on demand, when something prints. This is a normal configuration."
|
|
value: Printers.service?.startsAtBoot === true ? "Yes" : "On demand"
|
|
}
|
|
|
|
TextRow {
|
|
label: "Network discovery"
|
|
detail: Printers.service?.discoveryAvailable === true
|
|
? "Avahi is running, which is what finds printers that announce themselves"
|
|
: "Avahi is not running, so network printers cannot be discovered"
|
|
value: Printers.service?.discoveryAvailable === true ? "Available" : "Unavailable"
|
|
divider: false
|
|
}
|
|
}
|
|
}
|