1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-03 20:32:07 -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

@@ -2,8 +2,10 @@ package cups
import (
"errors"
"fmt"
"net"
"net/url"
"os/exec"
"strings"
"time"
@@ -275,13 +277,42 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
return classes, nil
}
func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error {
args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd}
if information != "" {
args = append(args, "-D", information)
}
if location != "" {
args = append(args, "-L", location)
}
out, err := exec.Command("lpadmin", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func deletePrinterViaLpadmin(name string) error {
out, err := exec.Command("lpadmin", "-x", name).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
usedPkHelper := false
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
if isAuthError(err) && m.pkHelper != nil {
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil {
return err
// pkHelper failed (e.g., no polkit agent), try lpadmin as last resort.
// lpadmin -E enables the printer, so no further setup needed.
if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil {
return err
}
m.RefreshState()
return nil
}
usedPkHelper = true
} else if err != nil {
@@ -308,6 +339,12 @@ func (m *Manager) DeletePrinter(printerName string) error {
err := m.client.DeletePrinter(printerName)
if isAuthError(err) && m.pkHelper != nil {
err = m.pkHelper.PrinterDelete(printerName)
if err != nil {
// pkHelper failed, try lpadmin as last resort
if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil {
err = nil
}
}
}
if err == nil {
m.RefreshState()

View File

@@ -70,6 +70,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleRestartJob(conn, req, manager)
case "cups.holdJob":
handleHoldJob(conn, req, manager)
case "cups.testConnection":
handleTestConnection(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
@@ -464,3 +466,22 @@ func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"})
}
func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) {
host, err := params.StringNonEmpty(req.Params, "host")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
port := params.IntOpt(req.Params, "port", 631)
protocol := params.StringOpt(req.Params, "protocol", "ipp")
result, err := manager.TestRemotePrinter(host, port, protocol)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}

View File

@@ -0,0 +1,176 @@
package cups
import (
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
)
var validProtocols = map[string]bool{
"ipp": true,
"ipps": true,
"lpd": true,
"socket": true,
}
func validateTestConnectionParams(host string, port int, protocol string) error {
if host == "" {
return errors.New("host is required")
}
if strings.ContainsAny(host, " \t\n\r/\\") {
return errors.New("host contains invalid characters")
}
if port < 1 || port > 65535 {
return errors.New("port must be between 1 and 65535")
}
if protocol != "" && !validProtocols[protocol] {
return errors.New("protocol must be one of: ipp, ipps, lpd, socket")
}
return nil
}
const probeTimeout = 10 * time.Second
func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Fast fail: TCP reachability check
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
// Create a temporary IPP client pointing at the remote host.
// The TCP dial above provides fast-fail for unreachable hosts.
// The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers.
client := ipp.NewIPPClient(host, port, "", "", useTLS)
// Try /ipp/print first (modern driverless printers), then / (legacy)
info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print")
if err != nil {
// If we got an auth error, the printer exists but requires credentials.
// Report it as reachable with the URI that triggered the auth challenge.
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
info, err = probeIPPEndpoint(client, host, port, useTLS, "/")
}
if err != nil {
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/", proto, host, port),
Info: "authentication required",
}, nil
}
// TCP reachable but not an IPP printer
return &RemotePrinterInfo{
Reachable: true,
Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()),
}, nil
}
return info, nil
}
func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath)
httpProto := "http"
if useTLS {
httpProto = "https"
}
httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath)
req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1)
req.OperationAttributes[ipp.AttributePrinterURI] = printerURI
req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{
ipp.AttributePrinterName,
ipp.AttributePrinterMakeAndModel,
ipp.AttributePrinterState,
ipp.AttributePrinterInfo,
ipp.AttributePrinterUriSupported,
}
resp, err := client.SendRequest(httpURL, req, nil)
if err != nil {
return nil, err
}
if len(resp.PrinterAttributes) == 0 {
return nil, errors.New("no printer attributes returned")
}
attrs := resp.PrinterAttributes[0]
return &RemotePrinterInfo{
Reachable: true,
MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
Name: getStringAttr(attrs, ipp.AttributePrinterName),
Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
State: parsePrinterState(attrs),
URI: printerURI,
}, nil
}
// TestRemotePrinter validates inputs and probes a remote printer via IPP.
// For lpd/socket protocols, only TCP reachability is tested.
func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) {
if protocol == "" {
protocol = "ipp"
}
if err := validateTestConnectionParams(host, port, protocol); err != nil {
return nil, err
}
// For non-IPP protocols, only check TCP reachability
if protocol == "lpd" || protocol == "socket" {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d", protocol, host, port),
}, nil
}
useTLS := protocol == "ipps"
probeFn := m.probeRemoteFn
if probeFn == nil {
probeFn = probeRemotePrinter
}
return probeFn(host, port, useTLS)
}

View File

@@ -0,0 +1,397 @@
package cups
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
"github.com/stretchr/testify/assert"
)
func TestValidateTestConnectionParams(t *testing.T) {
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "valid ipp",
host: "192.168.0.5",
port: 631,
protocol: "ipp",
wantErr: "",
},
{
name: "valid ipps",
host: "printer.local",
port: 443,
protocol: "ipps",
wantErr: "",
},
{
name: "valid lpd",
host: "10.0.0.1",
port: 515,
protocol: "lpd",
wantErr: "",
},
{
name: "valid socket",
host: "10.0.0.1",
port: 9100,
protocol: "socket",
wantErr: "",
},
{
name: "empty host",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "port too low",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "port too high",
host: "192.168.0.5",
port: 70000,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
{
name: "empty protocol treated as ipp",
host: "192.168.0.5",
port: 631,
protocol: "",
wantErr: "",
},
{
name: "host with slash",
host: "192.168.0.5/admin",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with space",
host: "192.168.0.5 ",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with newline",
host: "192.168.0.5\n",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTestConnectionParams(tt.host, tt.port, tt.protocol)
if tt.wantErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErr)
}
})
}
}
func TestManager_TestRemotePrinter_Validation(t *testing.T) {
m := NewTestManager(nil, nil)
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "empty host returns error",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "invalid port returns error",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol returns error",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol)
assert.EqualError(t, err, tt.wantErr)
})
}
}
func TestManager_TestRemotePrinter_IPP(t *testing.T) {
tests := []struct {
name string
protocol string
probeRet *RemotePrinterInfo
probeErr error
wantTLS bool
wantReach bool
wantModel string
}{
{
name: "successful ipp probe",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
},
wantTLS: false,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "successful ipps probe",
protocol: "ipps",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
URI: "ipps://192.168.0.5:631/ipp/print",
},
wantTLS: true,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "unreachable host",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: false,
Error: "cannot reach 192.168.0.5:631: connection refused",
},
wantReach: false,
},
{
name: "empty protocol defaults to ipp",
protocol: "",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "Test Printer",
},
wantTLS: false,
wantReach: true,
wantModel: "Test Printer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedTLS bool
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
capturedTLS = useTLS
return tt.probeRet, tt.probeErr
}
result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol)
if tt.probeErr != nil {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantReach, result.Reachable)
assert.Equal(t, tt.wantModel, result.MakeModel)
assert.Equal(t, tt.wantTLS, capturedTLS)
})
}
}
func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// Simulate what happens when the printer returns HTTP 401
return probeRemotePrinterWithAuthError(host, port, useTLS)
}
result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp")
assert.NoError(t, err)
assert.True(t, result.Reachable)
assert.Equal(t, "authentication required", result.Info)
assert.Contains(t, result.URI, "ipp://192.168.0.107:631")
}
// probeRemotePrinterWithAuthError simulates a probe where the printer
// returns HTTP 401 on both endpoints.
func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// This simulates what probeRemotePrinter does when both endpoints
// return auth errors. We test the auth detection logic directly.
err := ipp.HTTPError{Code: 401}
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
return nil, err
}
func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
probeCalled := false
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
probeCalled = true
return nil, nil
}
// These will fail at TCP dial (no real server), but the important
// thing is that probeRemoteFn is NOT called for lpd/socket.
m.TestRemotePrinter("192.168.0.5", 9100, "socket")
assert.False(t, probeCalled, "probe function should not be called for socket protocol")
m.TestRemotePrinter("192.168.0.5", 515, "lpd")
assert.False(t, probeCalled, "probe function should not be called for lpd protocol")
}
func TestHandleTestConnection_Success(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel)
}
func TestHandleTestConnection_MissingHost(t *testing.T) {
m := NewTestManager(nil, nil)
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{},
}
handleTestConnection(conn, req, m)
var resp models.Response[any]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.Nil(t, resp.Result)
assert.NotNil(t, resp.Error)
}
func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
assert.Equal(t, 9631, port)
assert.True(t, useTLS)
return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
"port": float64(9631),
"protocol": "ipps",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}
func TestHandleRequest_TestConnection(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{Reachable: true}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{"host": "192.168.0.5"},
}
HandleRequest(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}

View File

@@ -55,6 +55,16 @@ type PPD struct {
Type string `json:"type"`
}
type RemotePrinterInfo struct {
Reachable bool `json:"reachable"`
MakeModel string `json:"makeModel"`
Name string `json:"name"`
Info string `json:"info"`
State string `json:"state"`
URI string `json:"uri"`
Error string `json:"error,omitempty"`
}
type PrinterClass struct {
Name string `json:"name"`
URI string `json:"uri"`
@@ -77,6 +87,7 @@ type Manager struct {
notifierWg sync.WaitGroup
lastNotifiedState *CUPSState
baseURL string
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
}
type SubscriptionManagerInterface interface {

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": ""
}
]