diff --git a/config/dot/quickshell/modules/settings/PrintersPage.qml b/config/dot/quickshell/modules/settings/PrintersPage.qml new file mode 100644 index 0000000..f95dc57 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PrintersPage.qml @@ -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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 1ea9050..45f01e7 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -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 {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 5a5493d..5c237a1 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -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}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 135af0d..03a2602 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-printers b/config/dot/quickshell/scripts/panama-printers new file mode 100755 index 0000000..1bdaa28 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-printers @@ -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:])) diff --git a/config/dot/quickshell/services/Printers.qml b/config/dot/quickshell/services/Printers.qml new file mode 100644 index 0000000..7aa0b20 --- /dev/null +++ b/config/dot/quickshell/services/Printers.qml @@ -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 + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 04d30a7..e7edfae 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 455a70a..a75189c 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -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; diff --git a/config/local/share/vicinae/scripts/settings-printers.sh b/config/local/share/vicinae/scripts/settings-printers.sh new file mode 100755 index 0000000..1e638c0 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-printers.sh @@ -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 diff --git a/docs/superpowers/plans/2026-08-19-desktop-integration.md b/docs/superpowers/plans/2026-08-19-desktop-integration.md index 5c599b9..ea72fdc 100644 --- a/docs/superpowers/plans/2026-08-19-desktop-integration.md +++ b/docs/superpowers/plans/2026-08-19-desktop-integration.md @@ -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 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 each launches with XDG_CURRENT_DESKTOP=GNOME because gnome-control-center refuses to run otherwise. Digital Wellbeing is deliberately absent: it configures 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 -the panel is mostly driver discovery, and getting that wrong means a printer that -silently does not print. +**Note.** This machine has no printer, so the page was built against a temporary +CUPS queue that was created, exercised, and removed. The driverless add path +itself is verified by construction and by its refusals rather than against +hardware; the first real printer added here is worth watching. diff --git a/tests/quickshell/printers-contract.sh b/tests/quickshell/printers-contract.sh new file mode 100755 index 0000000..bbc7963 --- /dev/null +++ b/tests/quickshell/printers-contract.sh @@ -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")"