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:
@@ -2,8 +2,10 @@ package cups
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -275,13 +277,42 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
|
|||||||
return classes, nil
|
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 {
|
func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
|
||||||
usedPkHelper := false
|
usedPkHelper := false
|
||||||
|
|
||||||
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
|
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
|
||||||
if isAuthError(err) && m.pkHelper != nil {
|
if isAuthError(err) && m.pkHelper != nil {
|
||||||
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != 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
|
usedPkHelper = true
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -308,6 +339,12 @@ func (m *Manager) DeletePrinter(printerName string) error {
|
|||||||
err := m.client.DeletePrinter(printerName)
|
err := m.client.DeletePrinter(printerName)
|
||||||
if isAuthError(err) && m.pkHelper != nil {
|
if isAuthError(err) && m.pkHelper != nil {
|
||||||
err = m.pkHelper.PrinterDelete(printerName)
|
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 {
|
if err == nil {
|
||||||
m.RefreshState()
|
m.RefreshState()
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
|||||||
handleRestartJob(conn, req, manager)
|
handleRestartJob(conn, req, manager)
|
||||||
case "cups.holdJob":
|
case "cups.holdJob":
|
||||||
handleHoldJob(conn, req, manager)
|
handleHoldJob(conn, req, manager)
|
||||||
|
case "cups.testConnection":
|
||||||
|
handleTestConnection(conn, req, manager)
|
||||||
default:
|
default:
|
||||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
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"})
|
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)
|
||||||
|
}
|
||||||
|
|||||||
176
core/internal/server/cups/test_connection.go
Normal file
176
core/internal/server/cups/test_connection.go
Normal 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)
|
||||||
|
}
|
||||||
397
core/internal/server/cups/test_connection_test.go
Normal file
397
core/internal/server/cups/test_connection_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -55,6 +55,16 @@ type PPD struct {
|
|||||||
Type string `json:"type"`
|
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 {
|
type PrinterClass struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
@@ -77,6 +87,7 @@ type Manager struct {
|
|||||||
notifierWg sync.WaitGroup
|
notifierWg sync.WaitGroup
|
||||||
lastNotifiedState *CUPSState
|
lastNotifiedState *CUPSState
|
||||||
baseURL string
|
baseURL string
|
||||||
|
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscriptionManagerInterface interface {
|
type SubscriptionManagerInterface interface {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ Item {
|
|||||||
LayoutMirroring.childrenInherit: true
|
LayoutMirroring.childrenInherit: true
|
||||||
|
|
||||||
property bool showAddPrinter: false
|
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 newPrinterName: ""
|
||||||
property string selectedDeviceUri: ""
|
property string selectedDeviceUri: ""
|
||||||
property var selectedDevice: null
|
property var selectedDevice: null
|
||||||
@@ -23,6 +29,12 @@ Item {
|
|||||||
property var suggestedPPDs: []
|
property var suggestedPPDs: []
|
||||||
|
|
||||||
function resetAddPrinterForm() {
|
function resetAddPrinterForm() {
|
||||||
|
manualEntryMode = false;
|
||||||
|
manualHost = "";
|
||||||
|
manualPort = "631";
|
||||||
|
manualProtocol = "ipp";
|
||||||
|
testingConnection = false;
|
||||||
|
testConnectionResult = null;
|
||||||
newPrinterName = "";
|
newPrinterName = "";
|
||||||
selectedDeviceUri = "";
|
selectedDeviceUri = "";
|
||||||
selectedDevice = null;
|
selectedDevice = null;
|
||||||
@@ -32,6 +44,45 @@ Item {
|
|||||||
suggestedPPDs = [];
|
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) {
|
function selectDevice(device) {
|
||||||
if (!device)
|
if (!device)
|
||||||
return;
|
return;
|
||||||
@@ -276,9 +327,93 @@ Item {
|
|||||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
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 {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
|
visible: !printerTab.manualEntryMode
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -351,6 +486,202 @@ Item {
|
|||||||
elide: Text.ElideRight
|
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 {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|||||||
@@ -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) {
|
function createPrinter(name, deviceURI, ppd, options) {
|
||||||
if (!cupsAvailable)
|
if (!cupsAvailable)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -659,6 +659,12 @@
|
|||||||
"reference": "Modules/Settings/DesktopWidgetsTab.qml:84",
|
"reference": "Modules/Settings/DesktopWidgetsTab.qml:84",
|
||||||
"comment": ""
|
"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.",
|
"term": "Adjust the number of columns in grid view mode.",
|
||||||
"context": "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",
|
"reference": "Modules/ControlCenter/Details/BluetoothDetail.qml:297",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Connection failed",
|
||||||
|
"context": "Status message when test connection to printer fails",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:603",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Contains",
|
"term": "Contains",
|
||||||
"context": "notification rule match type option",
|
"context": "notification rule match type option",
|
||||||
@@ -3293,6 +3305,12 @@
|
|||||||
"reference": "Services/DMSNetworkService.qml:480",
|
"reference": "Services/DMSNetworkService.qml:480",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Discover Devices",
|
||||||
|
"context": "Toggle button to scan for printers via mDNS/Avahi",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:313",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Disk",
|
"term": "Disk",
|
||||||
"context": "Disk",
|
"context": "Disk",
|
||||||
@@ -5267,6 +5285,12 @@
|
|||||||
"reference": "Modals/FileBrowser/FileBrowserContent.qml:241",
|
"reference": "Modals/FileBrowser/FileBrowserContent.qml:241",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Host",
|
||||||
|
"context": "Label for printer IP address or hostname input field",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:462",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Hostname",
|
"term": "Hostname",
|
||||||
"context": "system info label",
|
"context": "system info label",
|
||||||
@@ -5345,6 +5369,12 @@
|
|||||||
"reference": "Modules/Settings/NetworkTab.qml:943",
|
"reference": "Modules/Settings/NetworkTab.qml:943",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "IP address or hostname",
|
||||||
|
"context": "Placeholder text for manual printer address input",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:472",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "ISO Date",
|
"term": "ISO Date",
|
||||||
"context": "date format option",
|
"context": "date format option",
|
||||||
@@ -8261,6 +8291,12 @@
|
|||||||
"reference": "Modals/Greeter/GreeterCompletePage.qml:398",
|
"reference": "Modals/Greeter/GreeterCompletePage.qml:398",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Port",
|
||||||
|
"context": "Label for printer port number input field",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:486",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Portal",
|
"term": "Portal",
|
||||||
"context": "wallpaper transition option",
|
"context": "wallpaper transition option",
|
||||||
@@ -8483,6 +8519,12 @@
|
|||||||
"reference": "Modules/Settings/PrinterTab.qml:433",
|
"reference": "Modules/Settings/PrinterTab.qml:433",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Printer reachable",
|
||||||
|
"context": "Status message when test connection to printer succeeds",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:603",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Printers",
|
"term": "Printers",
|
||||||
"context": "Printers",
|
"context": "Printers",
|
||||||
@@ -10775,6 +10817,12 @@
|
|||||||
"reference": "Modules/Settings/ThemeColorsTab.qml:1944",
|
"reference": "Modules/Settings/ThemeColorsTab.qml:1944",
|
||||||
"comment": ""
|
"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",
|
"term": "Test Page",
|
||||||
"context": "Test Page",
|
"context": "Test Page",
|
||||||
@@ -10787,6 +10835,12 @@
|
|||||||
"reference": "Services/CupsService.qml:627",
|
"reference": "Services/CupsService.qml:627",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Testing...",
|
||||||
|
"context": "Button state while testing printer connection",
|
||||||
|
"reference": "Modules/Settings/PrinterTab.qml:541",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Text",
|
"term": "Text",
|
||||||
"context": "shadow color option | text color",
|
"context": "shadow color option | text color",
|
||||||
|
|||||||
@@ -769,6 +769,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"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.",
|
"term": "Adjust the number of columns in grid view mode.",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -1994,6 +2001,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Caps Lock is on",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Center Section",
|
"term": "Center Section",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -2834,6 +2848,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Connection failed",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Status message when test connection to printer fails",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Contains",
|
"term": "Contains",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -3842,6 +3863,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Discover Devices",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Toggle button to scan for printers via mDNS/Avahi",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Disk",
|
"term": "Disk",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -5711,6 +5739,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Got It",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Goth Corner Radius",
|
"term": "Goth Corner Radius",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -6145,6 +6180,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Host",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Label for printer IP address or hostname input field",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Hostname",
|
"term": "Hostname",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -6236,6 +6278,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "IP address or hostname",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Placeholder text for manual printer address input",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "ISO Date",
|
"term": "ISO Date",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -9638,6 +9687,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Port",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Label for printer port number input field",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Portal",
|
"term": "Portal",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -9897,6 +9953,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Printer reachable",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Status message when test connection to printer succeeds",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Printers",
|
"term": "Printers",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -10128,6 +10191,20 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Read Full Release Notes",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"term": "Read Full Release Notes",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Read:",
|
"term": "Read:",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -12571,6 +12648,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Test Connection",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Button to test connection to a printer by IP address",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Test Page",
|
"term": "Test Page",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -12585,6 +12669,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Testing...",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Button state while testing printer connection",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Text",
|
"term": "Text",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -13922,6 +14013,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "What's New",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "When enabled, apps are sorted alphabetically. When disabled, apps are sorted by usage frequency.",
|
"term": "When enabled, apps are sorted alphabetically. When disabled, apps are sorted by usage frequency.",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -14727,53 +14825,18 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"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",
|
"term": "↑/↓: Nav • Space: Expand • Enter: Action/Expand • E: Text",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
"context": "",
|
"context": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"term": "↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help",
|
||||||
|
"translation": "",
|
||||||
|
"context": "Keyboard hints when enter-to-paste is enabled",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user