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:
@@ -0,0 +1,353 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ Rectangle {
|
||||
case "storage": return storagePage;
|
||||
case "users": return usersPage;
|
||||
case "sharing": return sharingPage;
|
||||
case "printers": return printersPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -164,6 +165,7 @@ Rectangle {
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: usersPage; UsersPage {} }
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
Component { id: dateTimePage; DateTimePage {} }
|
||||
|
||||
@@ -27,6 +27,7 @@ Rectangle {
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
|
||||
@@ -11,6 +11,7 @@ DisplaysPage 1.0 DisplaysPage.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
NotificationsPage 1.0 NotificationsPage.qml
|
||||
PasswordRow 1.0 PasswordRow.qml
|
||||
PrintersPage 1.0 PrintersPage.qml
|
||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||
HealthPage 1.0 HealthPage.qml
|
||||
HealthSummary 1.0 HealthSummary.qml
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Printers, through CUPS' own API rather than by parsing lpstat.
|
||||
|
||||
Driverless only, on purpose. This adds printers that describe their own
|
||||
capabilities over IPP -- IPP Everywhere, which is every printer sold in roughly
|
||||
the last decade -- and does not choose PPDs or download vendor drivers. Driver
|
||||
selection is most of what the panel this replaces does, and getting it wrong
|
||||
produces a printer that accepts jobs and silently prints nothing. A printer old
|
||||
enough to need a PPD is better served by system-config-printer, and the page
|
||||
says so rather than pretending.
|
||||
|
||||
panama-printers snapshot
|
||||
panama-printers discover
|
||||
panama-printers add URI NAME
|
||||
panama-printers remove NAME
|
||||
panama-printers set-default NAME
|
||||
panama-printers pause NAME | resume NAME
|
||||
panama-printers cancel JOB_ID
|
||||
panama-printers test-page NAME
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# What may be printed to. Everything else -- file:, pipe:, a shell fragment --
|
||||
# is refused: this validates the URI rather than trusting a settings page,
|
||||
# because a device URI is handed to a backend that runs as root.
|
||||
SAFE_SCHEMES = ("ipp", "ipps", "socket", "dnssd", "http", "https")
|
||||
URI = re.compile(r"^(%s)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%%-]+$" % "|".join(SAFE_SCHEMES))
|
||||
|
||||
# CUPS queue names: no spaces, slashes, or '#'.
|
||||
QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
|
||||
|
||||
# The only model this adds. Naming it in one place means the driverless promise
|
||||
# is checkable rather than scattered.
|
||||
DRIVERLESS_MODEL = "everywhere"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or CUPS failure."""
|
||||
|
||||
|
||||
def connect():
|
||||
try:
|
||||
import cups
|
||||
|
||||
return cups, cups.Connection()
|
||||
except Exception as error: # noqa: BLE001 - no cups is a legitimate state
|
||||
raise BoundaryError("The printing service is not answering.") from error
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def service_state() -> dict:
|
||||
active = run(["systemctl", "is-active", "cups.service"]).stdout.strip()
|
||||
enabled = run(["systemctl", "is-enabled", "cups.service"]).stdout.strip()
|
||||
avahi = run(["systemctl", "is-active", "avahi-daemon.service"]).stdout.strip()
|
||||
return {
|
||||
"running": active == "active",
|
||||
# "on demand" is a real answer and a reasonable configuration, not a
|
||||
# problem to report: socket activation starts CUPS when something prints.
|
||||
"startsAtBoot": enabled == "enabled",
|
||||
"startMode": enabled or "unknown",
|
||||
"discoveryAvailable": avahi == "active",
|
||||
}
|
||||
|
||||
|
||||
def printer_state(attributes: dict) -> str:
|
||||
"""CUPS reports state as a number; people read words."""
|
||||
return {3: "idle", 4: "printing", 5: "stopped"}.get(
|
||||
int(attributes.get("printer-state", 0)), "unknown")
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cups, connection = connect()
|
||||
try:
|
||||
raw = connection.getPrinters()
|
||||
default = connection.getDefault()
|
||||
jobs = connection.getJobs(which_jobs="not-completed", my_jobs=False,
|
||||
requested_attributes=[
|
||||
"job-id", "job-name", "job-printer-uri",
|
||||
"job-state", "job-originating-user-name",
|
||||
"job-impressions", "time-at-creation"])
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("The printing service could not be read.") from error
|
||||
|
||||
printers = []
|
||||
for name, attributes in raw.items():
|
||||
printers.append({
|
||||
"name": name,
|
||||
"description": str(attributes.get("printer-info") or name),
|
||||
"location": str(attributes.get("printer-location") or ""),
|
||||
"makeAndModel": str(attributes.get("printer-make-and-model") or ""),
|
||||
"uri": str(attributes.get("device-uri") or ""),
|
||||
"state": printer_state(attributes),
|
||||
"stateMessage": str(attributes.get("printer-state-message") or ""),
|
||||
"accepting": bool(attributes.get("printer-is-accepting-jobs", True)),
|
||||
"shared": bool(attributes.get("printer-is-shared", False)),
|
||||
"isDefault": name == default,
|
||||
})
|
||||
printers.sort(key=lambda entry: (not entry["isDefault"], entry["name"].lower()))
|
||||
|
||||
queue = []
|
||||
for job in jobs.values() if isinstance(jobs, dict) else []:
|
||||
printer_uri = str(job.get("job-printer-uri") or "")
|
||||
queue.append({
|
||||
"id": int(job.get("job-id") or 0),
|
||||
"name": str(job.get("job-name") or "Untitled"),
|
||||
"printer": printer_uri.rsplit("/", 1)[-1] if printer_uri else "",
|
||||
"state": {3: "pending", 4: "held", 5: "processing",
|
||||
6: "stopped", 7: "cancelled"}.get(int(job.get("job-state", 0)), "unknown"),
|
||||
"user": str(job.get("job-originating-user-name") or ""),
|
||||
"pages": int(job.get("job-impressions") or 0),
|
||||
"createdAt": int(job.get("time-at-creation") or 0),
|
||||
})
|
||||
queue.sort(key=lambda entry: entry["createdAt"])
|
||||
|
||||
return {"printers": printers, "jobs": queue, "service": service_state(), "error": ""}
|
||||
|
||||
|
||||
def discover() -> dict:
|
||||
"""Printers announcing themselves on the network, driverless ones only.
|
||||
|
||||
A printer that does not advertise IPP Everywhere is reported as found but
|
||||
not addable, rather than offered and then failing at the point of adding.
|
||||
"""
|
||||
if not shutil.which("avahi-browse"):
|
||||
return {"found": [], "error": "Network discovery is not available."}
|
||||
|
||||
found: dict[str, dict] = {}
|
||||
for service in ("_ipps._tcp", "_ipp._tcp"):
|
||||
result = run(["avahi-browse", "-rtp", service], timeout=20)
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.split(";")
|
||||
if len(parts) < 10:
|
||||
continue
|
||||
name, host, port, text = parts[3], parts[6], parts[8], parts[9]
|
||||
scheme = "ipps" if service == "_ipps._tcp" else "ipp"
|
||||
resource = ""
|
||||
for field in re.findall(r"\"([^\"]*)\"", text):
|
||||
if field.startswith("rp="):
|
||||
resource = field[3:]
|
||||
# Only IPP Everywhere. The attribute a driverless printer publishes
|
||||
# is its PDF/JPEG support; without it, CUPS would need a driver.
|
||||
driverless = "application/pdf" in text or "URF=" in text or "urf=" in text
|
||||
uri = f"{scheme}://{host}:{port}/{resource}" if resource else f"{scheme}://{host}:{port}/"
|
||||
found[uri] = {
|
||||
"name": name.replace("\\032", " "),
|
||||
"uri": uri,
|
||||
"host": host,
|
||||
"driverless": driverless,
|
||||
}
|
||||
return {"found": sorted(found.values(), key=lambda entry: entry["name"]), "error": ""}
|
||||
|
||||
|
||||
def require_queue(name: str) -> str:
|
||||
if not QUEUE.fullmatch(name or ""):
|
||||
raise BoundaryError("That is not a printer name.")
|
||||
return name
|
||||
|
||||
|
||||
def add(uri: str, name: str) -> None:
|
||||
if not URI.fullmatch(uri or ""):
|
||||
raise BoundaryError("That address cannot be used to reach a printer.")
|
||||
require_queue(name)
|
||||
|
||||
cups, connection = connect()
|
||||
try:
|
||||
# ppdname is the driverless model and nothing else. There is no branch
|
||||
# here that selects a PPD, which is what keeps the promise checkable.
|
||||
connection.addPrinter(name, device=uri, ppdname=DRIVERLESS_MODEL)
|
||||
connection.enablePrinter(name)
|
||||
connection.acceptJobs(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error)
|
||||
if "device-error" in message or "1284" in message:
|
||||
raise BoundaryError(
|
||||
"The printer did not answer, or does not support driverless printing.") from error
|
||||
if "not-authorized" in message or "forbidden" in message.lower():
|
||||
raise BoundaryError("Adding a printer was not authorized.") from error
|
||||
raise BoundaryError("That printer could not be added.") from error
|
||||
|
||||
|
||||
def remove(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.deletePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be removed.") from error
|
||||
|
||||
|
||||
def set_default(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.setDefault(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be made the default.") from error
|
||||
|
||||
|
||||
def set_paused(name: str, paused: bool) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
if paused:
|
||||
connection.disablePrinter(name)
|
||||
else:
|
||||
connection.enablePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be changed.") from error
|
||||
|
||||
|
||||
def cancel(job_id: str) -> None:
|
||||
if not job_id.isdigit():
|
||||
raise BoundaryError("That is not a job.")
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.cancelJob(int(job_id))
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That job could not be cancelled.") from error
|
||||
|
||||
|
||||
def test_page(name: str) -> None:
|
||||
require_queue(name)
|
||||
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError((result.stderr.strip() or "The test page could not be sent.")[:200])
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if arguments == ["discover"]:
|
||||
print(json.dumps(discover(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "add":
|
||||
add(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "remove":
|
||||
remove(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "set-default":
|
||||
set_default(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "pause":
|
||||
set_paused(arguments[1], True)
|
||||
elif len(arguments) == 2 and arguments[0] == "resume":
|
||||
set_paused(arguments[1], False)
|
||||
elif len(arguments) == 2 and arguments[0] == "cancel":
|
||||
cancel(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "test-page":
|
||||
test_page(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
|
||||
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"printers": [], "jobs": [], "service": service_state()}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@ Singleton {
|
||||
{ label: "Add a user", detail: "Create another account on this machine", page: "users" },
|
||||
{ label: "Automatic login", detail: "Sign in without typing a password", page: "users" },
|
||||
{ label: "Administrator", detail: "Which accounts can manage this machine", page: "users" },
|
||||
{ label: "Printers", detail: "Add a printer and see what is queued", page: "printers" },
|
||||
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
|
||||
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
|
||||
{ label: "Default printer", detail: "Where applications print unless told otherwise", page: "printers" },
|
||||
{ label: "Remote login", detail: "Sign in to this machine over SSH", page: "sharing" },
|
||||
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
||||
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
||||
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function openSettings(page: string): void {
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "users", "sharing", "services", "about"];
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "users", "sharing", "printers", "services", "about"];
|
||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
|
||||
Reference in New Issue
Block a user