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 "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
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: "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;