1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 12:52:06 -04:00

feat(cups): add manual printer addition by IP/hostname (#1868)

Add a new "Add by Address" flow in the printer settings that allows
users to manually add printers by IP address or hostname, enabling
printing to devices not visible via mDNS/Avahi discovery (e.g.,
printers behind Tailscale subnet routers, VPNs, or across network
boundaries).

Go backend:
- New cups.testConnection IPC method that probes remote printers via
  IPP Get-Printer-Attributes with /ipp/print then / fallback
- Input validation with host sanitization and protocol allowlist
- Auth-aware probing (HTTP 401/403 reported as reachable)
- lpadmin CLI fallback for CreatePrinter/DeletePrinter when
  cups-pk-helper polkit authorization fails

QML frontend:
- "Add by Address" toggle alongside existing device discovery
- Manual entry form with host, port, protocol fields
- Test Connection button with loading state and result display
- Smart PPD auto-selection by probed makeModel with driverless fallback
- All strings use I18n.tr() with translator context

Includes 20+ unit tests covering validation, probe delegation, TLS
flag propagation, auth error detection, and handler routing.
This commit is contained in:
Giorgio De Trane
2026-03-01 02:36:16 +01:00
committed by GitHub
parent 9cb0d8baf2
commit 20d383d4ab
9 changed files with 1148 additions and 43 deletions

View File

@@ -14,6 +14,12 @@ Item {
LayoutMirroring.childrenInherit: true
property bool showAddPrinter: false
property bool manualEntryMode: false
property string manualHost: ""
property string manualPort: "631"
property string manualProtocol: "ipp"
property bool testingConnection: false
property var testConnectionResult: null
property string newPrinterName: ""
property string selectedDeviceUri: ""
property var selectedDevice: null
@@ -23,6 +29,12 @@ Item {
property var suggestedPPDs: []
function resetAddPrinterForm() {
manualEntryMode = false;
manualHost = "";
manualPort = "631";
manualProtocol = "ipp";
testingConnection = false;
testConnectionResult = null;
newPrinterName = "";
selectedDeviceUri = "";
selectedDevice = null;
@@ -32,6 +44,45 @@ Item {
suggestedPPDs = [];
}
Connections {
target: CupsService
function onPpdsChanged() {
if (printerTab.manualEntryMode && printerTab.testConnectionResult?.success)
printerTab.selectDriverlessPPD();
}
}
function selectDriverlessPPD() {
if (printerTab.selectedPpd || CupsService.ppds.length === 0)
return;
const probeModel = printerTab.testConnectionResult?.data?.makeModel || "";
let suggested = [];
// Try to find a model-specific PPD match
if (probeModel) {
const normalizedModel = probeModel.toLowerCase().replace(/[^a-z0-9]/g, "");
const modelMatches = CupsService.ppds.filter(p => {
const normalizedPPD = (p.makeModel || "").toLowerCase().replace(/[^a-z0-9]/g, "");
return normalizedPPD.includes(normalizedModel) || normalizedModel.includes(normalizedPPD);
});
if (modelMatches.length > 0)
suggested = suggested.concat(modelMatches);
}
// Always include driverless as an option
const driverless = CupsService.ppds.filter(p => p.name === "driverless" || p.name === "everywhere");
for (const d of driverless) {
if (!suggested.find(s => s.name === d.name))
suggested.push(d);
}
if (suggested.length > 0) {
printerTab.selectedPpd = suggested[0].name;
printerTab.suggestedPPDs = suggested;
}
}
function selectDevice(device) {
if (!device)
return;
@@ -276,9 +327,93 @@ Item {
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
Row {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: discoverRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: !printerTab.manualEntryMode ? Theme.primary : (discoverArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: discoverRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "search"
size: 16
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Discover Devices", "Toggle button to scan for printers via mDNS/Avahi")
font.pixelSize: Theme.fontSizeSmall
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: discoverArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = false;
printerTab.testConnectionResult = null;
printerTab.testingConnection = false;
}
}
}
Rectangle {
width: manualRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: printerTab.manualEntryMode ? Theme.primary : (manualArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: manualRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "edit"
size: 16
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Add by Address", "Toggle button to manually add a printer by IP or hostname")
font.pixelSize: Theme.fontSizeSmall
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: manualArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = true;
printerTab.selectedDevice = null;
printerTab.selectedDeviceUri = "";
if (CupsService.ppds.length === 0)
CupsService.getPPDs();
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: !printerTab.manualEntryMode
Row {
width: parent.width
@@ -351,6 +486,202 @@ Item {
elide: Text.ElideRight
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: printerTab.manualEntryMode
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Host", "Label for printer IP address or hostname input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("IP address or hostname", "Placeholder text for manual printer address input")
text: printerTab.manualHost
onTextEdited: {
printerTab.manualHost = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Port", "Label for printer port number input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: 80
placeholderText: "631"
text: printerTab.manualPort
onTextEdited: {
printerTab.manualPort = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Protocol", "Label for printer protocol selector, e.g. ipp, ipps, lpd, socket")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankDropdown {
id: protocolDropdown
dropdownWidth: 120
popupWidth: 120
currentValue: printerTab.manualProtocol
options: ["ipp", "ipps", "lpd", "socket"]
onValueChanged: value => {
printerTab.manualProtocol = value;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
DankButton {
text: printerTab.testingConnection ? I18n.tr("Testing...", "Button state while testing printer connection") : I18n.tr("Test Connection", "Button to test connection to a printer by IP address")
iconName: printerTab.testingConnection ? "sync" : "lan"
buttonHeight: 36
enabled: printerTab.manualHost.length > 0 && !printerTab.testingConnection
onClicked: {
printerTab.testingConnection = true;
printerTab.testConnectionResult = null;
const port = parseInt(printerTab.manualPort) || 631;
CupsService.testConnection(printerTab.manualHost, port, printerTab.manualProtocol, response => {
printerTab.testingConnection = false;
if (response.error) {
printerTab.testConnectionResult = {
"success": false,
"error": response.error
};
} else if (response.result) {
printerTab.testConnectionResult = {
"success": response.result.reachable === true,
"data": response.result
};
if (response.result.reachable) {
if (response.result.uri)
printerTab.selectedDeviceUri = response.result.uri;
if (response.result.name && !printerTab.newPrinterName)
printerTab.newPrinterName = response.result.name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 32) || "Printer";
// Load PPDs if not loaded yet, then select driverless
if (CupsService.ppds.length === 0) {
CupsService.getPPDs();
}
selectDriverlessPPD();
}
}
});
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: printerTab.testConnectionResult !== null
Row {
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
StyledText {
text: printerTab.testConnectionResult?.success ? I18n.tr("Printer reachable", "Status message when test connection to printer succeeds") : I18n.tr("Connection failed", "Status message when test connection to printer fails")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
}
Row {
spacing: Theme.spacingS
visible: printerTab.testConnectionResult?.success && (printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info)
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
Row {
spacing: Theme.spacingS
visible: !printerTab.testConnectionResult?.success && printerTab.testConnectionResult?.data?.error
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.error || printerTab.testConnectionResult?.error || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.parent.width - 80 - Theme.spacingS
wrapMode: Text.WordWrap
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
Row {
width: parent.width

View File

@@ -479,6 +479,21 @@ Singleton {
});
}
function testConnection(host, port, protocol, callback) {
if (!cupsAvailable)
return;
const params = {
"host": host,
"port": port,
"protocol": protocol
};
DMSService.sendRequest("cups.testConnection", params, response => {
if (callback)
callback(response);
});
}
function createPrinter(name, deviceURI, ppd, options) {
if (!cupsAvailable)
return;

View File

@@ -659,6 +659,12 @@
"reference": "Modules/Settings/DesktopWidgetsTab.qml:84",
"comment": ""
},
{
"term": "Add by Address",
"context": "Toggle button to manually add a printer by IP or hostname",
"reference": "Modules/Settings/PrinterTab.qml:351",
"comment": ""
},
{
"term": "Adjust the number of columns in grid view mode.",
"context": "Adjust the number of columns in grid view mode.",
@@ -2429,6 +2435,12 @@
"reference": "Modules/ControlCenter/Details/BluetoothDetail.qml:297",
"comment": ""
},
{
"term": "Connection failed",
"context": "Status message when test connection to printer fails",
"reference": "Modules/Settings/PrinterTab.qml:603",
"comment": ""
},
{
"term": "Contains",
"context": "notification rule match type option",
@@ -3293,6 +3305,12 @@
"reference": "Services/DMSNetworkService.qml:480",
"comment": ""
},
{
"term": "Discover Devices",
"context": "Toggle button to scan for printers via mDNS/Avahi",
"reference": "Modules/Settings/PrinterTab.qml:313",
"comment": ""
},
{
"term": "Disk",
"context": "Disk",
@@ -5267,6 +5285,12 @@
"reference": "Modals/FileBrowser/FileBrowserContent.qml:241",
"comment": ""
},
{
"term": "Host",
"context": "Label for printer IP address or hostname input field",
"reference": "Modules/Settings/PrinterTab.qml:462",
"comment": ""
},
{
"term": "Hostname",
"context": "system info label",
@@ -5345,6 +5369,12 @@
"reference": "Modules/Settings/NetworkTab.qml:943",
"comment": ""
},
{
"term": "IP address or hostname",
"context": "Placeholder text for manual printer address input",
"reference": "Modules/Settings/PrinterTab.qml:472",
"comment": ""
},
{
"term": "ISO Date",
"context": "date format option",
@@ -8261,6 +8291,12 @@
"reference": "Modals/Greeter/GreeterCompletePage.qml:398",
"comment": ""
},
{
"term": "Port",
"context": "Label for printer port number input field",
"reference": "Modules/Settings/PrinterTab.qml:486",
"comment": ""
},
{
"term": "Portal",
"context": "wallpaper transition option",
@@ -8483,6 +8519,12 @@
"reference": "Modules/Settings/PrinterTab.qml:433",
"comment": ""
},
{
"term": "Printer reachable",
"context": "Status message when test connection to printer succeeds",
"reference": "Modules/Settings/PrinterTab.qml:603",
"comment": ""
},
{
"term": "Printers",
"context": "Printers",
@@ -10775,6 +10817,12 @@
"reference": "Modules/Settings/ThemeColorsTab.qml:1944",
"comment": ""
},
{
"term": "Test Connection",
"context": "Button to test connection to a printer by IP address",
"reference": "Modules/Settings/PrinterTab.qml:541",
"comment": ""
},
{
"term": "Test Page",
"context": "Test Page",
@@ -10787,6 +10835,12 @@
"reference": "Services/CupsService.qml:627",
"comment": ""
},
{
"term": "Testing...",
"context": "Button state while testing printer connection",
"reference": "Modules/Settings/PrinterTab.qml:541",
"comment": ""
},
{
"term": "Text",
"context": "shadow color option | text color",

View File

@@ -769,6 +769,13 @@
"reference": "",
"comment": ""
},
{
"term": "Add by Address",
"translation": "",
"context": "Toggle button to manually add a printer by IP or hostname",
"reference": "",
"comment": ""
},
{
"term": "Adjust the number of columns in grid view mode.",
"translation": "",
@@ -1994,6 +2001,13 @@
"reference": "",
"comment": ""
},
{
"term": "Caps Lock is on",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Center Section",
"translation": "",
@@ -2834,6 +2848,13 @@
"reference": "",
"comment": ""
},
{
"term": "Connection failed",
"translation": "",
"context": "Status message when test connection to printer fails",
"reference": "",
"comment": ""
},
{
"term": "Contains",
"translation": "",
@@ -3842,6 +3863,13 @@
"reference": "",
"comment": ""
},
{
"term": "Discover Devices",
"translation": "",
"context": "Toggle button to scan for printers via mDNS/Avahi",
"reference": "",
"comment": ""
},
{
"term": "Disk",
"translation": "",
@@ -5711,6 +5739,13 @@
"reference": "",
"comment": ""
},
{
"term": "Got It",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Goth Corner Radius",
"translation": "",
@@ -6145,6 +6180,13 @@
"reference": "",
"comment": ""
},
{
"term": "Host",
"translation": "",
"context": "Label for printer IP address or hostname input field",
"reference": "",
"comment": ""
},
{
"term": "Hostname",
"translation": "",
@@ -6236,6 +6278,13 @@
"reference": "",
"comment": ""
},
{
"term": "IP address or hostname",
"translation": "",
"context": "Placeholder text for manual printer address input",
"reference": "",
"comment": ""
},
{
"term": "ISO Date",
"translation": "",
@@ -9638,6 +9687,13 @@
"reference": "",
"comment": ""
},
{
"term": "Port",
"translation": "",
"context": "Label for printer port number input field",
"reference": "",
"comment": ""
},
{
"term": "Portal",
"translation": "",
@@ -9897,6 +9953,13 @@
"reference": "",
"comment": ""
},
{
"term": "Printer reachable",
"translation": "",
"context": "Status message when test connection to printer succeeds",
"reference": "",
"comment": ""
},
{
"term": "Printers",
"translation": "",
@@ -10128,6 +10191,20 @@
"reference": "",
"comment": ""
},
{
"term": "Read Full Release Notes",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Read Full Release Notes",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Read:",
"translation": "",
@@ -12571,6 +12648,13 @@
"reference": "",
"comment": ""
},
{
"term": "Test Connection",
"translation": "",
"context": "Button to test connection to a printer by IP address",
"reference": "",
"comment": ""
},
{
"term": "Test Page",
"translation": "",
@@ -12585,6 +12669,13 @@
"reference": "",
"comment": ""
},
{
"term": "Testing...",
"translation": "",
"context": "Button state while testing printer connection",
"reference": "",
"comment": ""
},
{
"term": "Text",
"translation": "",
@@ -13922,6 +14013,13 @@
"reference": "",
"comment": ""
},
{
"term": "What's New",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "When enabled, apps are sorted alphabetically. When disabled, apps are sorted by usage frequency.",
"translation": "",
@@ -14727,53 +14825,18 @@
"reference": "",
"comment": ""
},
{
"term": "↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help",
"translation": "",
"context": "Keyboard hints when enter-to-paste is enabled",
"reference": "",
"comment": ""
},
{
"term": "What's New",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Read Full Release Notes",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Read Full Release Notes",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Got It",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "Caps Lock is on",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "↑/↓: Nav • Space: Expand • Enter: Action/Expand • E: Text",
"translation": "",
"context": "",
"reference": "",
"comment": ""
},
{
"term": "↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help",
"translation": "",
"context": "Keyboard hints when enter-to-paste is enabled",
"reference": "",
"comment": ""
}
]