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
@@ -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 "storage": return storagePage;
case "users": return usersPage; case "users": return usersPage;
case "sharing": return sharingPage; case "sharing": return sharingPage;
case "printers": return printersPage;
case "services": return healthPage; case "services": return healthPage;
case "about": return aboutPage; case "about": return aboutPage;
default: return homePage; default: return homePage;
@@ -164,6 +165,7 @@ Rectangle {
Component { id: storagePage; StoragePage {} } Component { id: storagePage; StoragePage {} }
Component { id: usersPage; UsersPage {} } Component { id: usersPage; UsersPage {} }
Component { id: sharingPage; SharingPage {} } Component { id: sharingPage; SharingPage {} }
Component { id: printersPage; PrintersPage {} }
Component { id: accessibilityPage; AccessibilityPage {} } Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} } Component { id: powerPage; PowerPage {} }
Component { id: dateTimePage; DateTimePage {} } Component { id: dateTimePage; DateTimePage {} }
@@ -27,6 +27,7 @@ Rectangle {
{ page: "displays", label: "Displays", icon: "\u{F0379}" }, { page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" }, { page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" }, { page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" },
@@ -11,6 +11,7 @@ DisplaysPage 1.0 DisplaysPage.qml
HomePage 1.0 HomePage.qml HomePage 1.0 HomePage.qml
NotificationsPage 1.0 NotificationsPage.qml NotificationsPage 1.0 NotificationsPage.qml
PasswordRow 1.0 PasswordRow.qml PasswordRow 1.0 PasswordRow.qml
PrintersPage 1.0 PrintersPage.qml
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
HealthPage 1.0 HealthPage.qml HealthPage 1.0 HealthPage.qml
HealthSummary 1.0 HealthSummary.qml HealthSummary 1.0 HealthSummary.qml
+286
View File
@@ -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:]))
+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
}
}
@@ -70,6 +70,10 @@ Singleton {
{ label: "Add a user", detail: "Create another account on this machine", page: "users" }, { 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: "Automatic login", detail: "Sign in without typing a password", page: "users" },
{ label: "Administrator", detail: "Which accounts can manage this machine", 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 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: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
{ label: "Network name", detail: "The name other machines see", page: "sharing" }, { label: "Network name", detail: "The name other machines see", page: "sharing" },
@@ -92,7 +92,7 @@ Singleton {
} }
function openSettings(page: string): void { 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"; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage); DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true; root.settingsOpen = true;
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Printers
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Printers in Settings.
# @vicinae.keywords ["settings", "printers", "print queue", "add a printer", "default printer"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page printers
@@ -202,12 +202,20 @@ lock screens read. Sharing covers remote login and remote desktop, and reports
file and media sharing as absent rather than offering a switch for software that file and media sharing as absent rather than offering a switch for software that
is not installed. is not installed.
**Still handed to GNOME.** Printers, Color, Wacom, and Universal Access. Each **Owned (2026-08-19, later).** Printers, driverless only. 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. That restraint is the point: 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 system-config-printer job.
**Still handed to GNOME.** Color, Wacom, and Universal Access. Each
wraps hardware-specific tooling with no D-Bus surface worth reimplementing, and wraps hardware-specific tooling with no D-Bus surface worth reimplementing, and
each launches with XDG_CURRENT_DESKTOP=GNOME because gnome-control-center refuses each launches with XDG_CURRENT_DESKTOP=GNOME because gnome-control-center refuses
to run otherwise. Digital Wellbeing is deliberately absent: it configures to run otherwise. Digital Wellbeing is deliberately absent: it configures
gnome-shell's own screen-time reporting, which does not run here. gnome-shell's own screen-time reporting, which does not run here.
**Open question.** Whether owning Printers is worth it. CUPS has a real API, but **Note.** This machine has no printer, so the page was built against a temporary
the panel is mostly driver discovery, and getting that wrong means a printer that CUPS queue that was created, exercised, and removed. The driverless add path
silently does not print. itself is verified by construction and by its refusals rather than against
hardware; the first real printer added here is worth watching.
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Printing is driverless only, and the page must not be able to become
# otherwise by accident.
#
# The reason is specific. Choosing a PPD or fetching a vendor driver is most of
# what the panel this replaces does, and a wrong choice produces a printer that
# accepts jobs, reports success, and prints nothing -- the worst failure this
# page could ship, because it looks like it worked. So the helper adds printers
# that describe their own capabilities over IPP and has no branch that selects
# anything else.
#
# The second rule is the device URI. It is handed to a CUPS backend that runs as
# root, so it is validated here rather than trusted from a settings page.
#
# Read-only: this reads printer state and exercises refusals. It never adds or
# removes a real printer.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-printers"
service="$repo_dir/config/dot/quickshell/services/Printers.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/PrintersPage.qml"
fail() {
printf 'printers contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-printers is not executable'
# ── Driverless only ─────────────────────────────────────────────────────────
grep -q 'DRIVERLESS_MODEL = "everywhere"' "$helper" \
|| fail 'the driverless model is not named in one place, so the promise cannot be checked'
# Exactly one place may set a model, and it must be that constant.
model_uses="$(grep -c 'ppdname=' "$helper")"
[[ "$model_uses" == "1" ]] \
|| fail "ppdname is set in $model_uses places; driverless printing must have exactly one"
grep -q 'ppdname=DRIVERLESS_MODEL' "$helper" \
|| fail 'the printer is added with something other than the driverless model'
# No PPD file handling at all.
grep -qE '\.ppd|ppd-name|getPPDs|ppdFile|installDriver|foomatic' "$helper" \
&& fail 'the helper reaches for PPDs or drivers, which this page deliberately does not do'
# And the page must not offer a driver choice. Comments are stripped first:
# the page explains the no-driver policy in prose, and an earlier version of
# this check failed on the explanation rather than on any behaviour.
page_code="$(grep -vE '^\s*//' "$page")"
grep -qiE 'select.*driver|choose.*driver|ppdName|driverList' <<<"$page_code" \
&& fail 'the page offers driver selection'
# It must say so, rather than leaving someone guessing why their printer is absent.
grep -qi 'driverless' "$page" \
|| fail 'the page never explains that only driverless printers are supported'
# ── Device URIs are validated, not trusted ──────────────────────────────────
grep -q 'SAFE_SCHEMES' "$helper" || fail 'device URIs are not restricted by scheme'
for scheme in file pipe; do
grep -qE "\"$scheme\"" <<<"$(sed -n '/^SAFE_SCHEMES/,/)/p' "$helper")" \
&& fail "the $scheme scheme is allowed, and it does not lead to a printer"
done
command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; }
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
# The REASON matters, not merely that something failed. Without validation these
# reach CUPS, which refuses them too -- so a test that only checks for "an error"
# passes with the validation deleted, and proves nothing about this helper.
for bad in "file:///etc/passwd" "pipe:/bin/sh" "ipp://host; rm -rf /" "/etc/passwd" ""; do
answer="$(refusal add "$bad" probe)"
[[ -n "$answer" ]] || fail "the helper accepted \"$bad\" as a printer address"
[[ "$answer" == "That address cannot be used to reach a printer." ]] \
|| fail "\"$bad\" was rejected by the printing service rather than by this helper: $answer"
done
for bad in "../escape" "has space" "a#b" ""; do
[[ -n "$(refusal remove "$bad")" ]] \
|| fail "the helper accepted \"$bad\" as a printer name"
done
[[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number'
[[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted'
# ── The snapshot describes the machine ──────────────────────────────────────
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
jq -e '(.printers | type == "array") and (.jobs | type == "array") and (.service | type == "object")' \
<<<"$snapshot" >/dev/null || fail 'the snapshot is missing printers, jobs, or service state'
jq -e '.service | has("running") and has("startsAtBoot") and has("discoveryAvailable")' \
<<<"$snapshot" >/dev/null || fail 'the service state is incomplete'
jq -e '[.printers[] | has("name") and has("state") and has("isDefault")] | all' \
<<<"$snapshot" >/dev/null || fail 'a printer is missing its name, state, or default flag'
# At most one default, or the page would show two.
[[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \
|| fail 'more than one printer is reported as the default'
# ── Removal is confirmed ────────────────────────────────────────────────────
grep -q 'confirmingRemoval' "$page" \
|| fail 'the page removes a printer without a confirmation step'
grep -q 'still queued for it is cancelled' "$page" \
|| fail 'the page does not say that removing a printer cancels its queued jobs'
# ── "On demand" is reported as normal, not as a fault ───────────────────────
# CUPS is socket-activated on this distribution; calling that a problem would
# send people to fix something that is not broken.
grep -q 'This is a normal configuration' "$page" \
|| fail 'a socket-activated printing service is presented as a problem'
printf 'printers contract: PASS (%d printer(s), %d job(s), driverless only)\n' \
"$(jq '.printers | length' <<<"$snapshot")" \
"$(jq '.jobs | length' <<<"$snapshot")"