diff --git a/core/internal/server/cups/actions.go b/core/internal/server/cups/actions.go index 01b95f70..2f9b98bf 100644 --- a/core/internal/server/cups/actions.go +++ b/core/internal/server/cups/actions.go @@ -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() diff --git a/core/internal/server/cups/handlers.go b/core/internal/server/cups/handlers.go index b70bfa97..57b45d55 100644 --- a/core/internal/server/cups/handlers.go +++ b/core/internal/server/cups/handlers.go @@ -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) +} diff --git a/core/internal/server/cups/test_connection.go b/core/internal/server/cups/test_connection.go new file mode 100644 index 00000000..d0203e9e --- /dev/null +++ b/core/internal/server/cups/test_connection.go @@ -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) +} diff --git a/core/internal/server/cups/test_connection_test.go b/core/internal/server/cups/test_connection_test.go new file mode 100644 index 00000000..b8350281 --- /dev/null +++ b/core/internal/server/cups/test_connection_test.go @@ -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) +} diff --git a/core/internal/server/cups/types.go b/core/internal/server/cups/types.go index 5e17dbee..52e9d2cd 100644 --- a/core/internal/server/cups/types.go +++ b/core/internal/server/cups/types.go @@ -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 { diff --git a/quickshell/Modules/Settings/PrinterTab.qml b/quickshell/Modules/Settings/PrinterTab.qml index bd9ebc39..437dc472 100644 --- a/quickshell/Modules/Settings/PrinterTab.qml +++ b/quickshell/Modules/Settings/PrinterTab.qml @@ -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 diff --git a/quickshell/Services/CupsService.qml b/quickshell/Services/CupsService.qml index cb575e9b..a88655d2 100644 --- a/quickshell/Services/CupsService.qml +++ b/quickshell/Services/CupsService.qml @@ -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; diff --git a/quickshell/translations/en.json b/quickshell/translations/en.json index 39325807..e95414a1 100644 --- a/quickshell/translations/en.json +++ b/quickshell/translations/en.json @@ -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", diff --git a/quickshell/translations/template.json b/quickshell/translations/template.json index d0ffaf60..3ab39832 100644 --- a/quickshell/translations/template.json +++ b/quickshell/translations/template.json @@ -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": "" } ]