summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go479
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go235
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go450
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go487
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go711
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go390
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go363
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go184
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go95
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go245
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go295
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go176
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go397
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go13
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go132
15 files changed, 4652 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go
new file mode 100644
index 0000000..2f9b98b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go
@@ -0,0 +1,479 @@
+package cups
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+func isAuthError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var httpErr ipp.HTTPError
+ if errors.As(err, &httpErr) {
+ return httpErr.Code == 401 || httpErr.Code == 403
+ }
+
+ var ippErr ipp.IPPError
+ if errors.As(err, &ippErr) {
+ return ippErr.Status == ipp.StatusErrorForbidden ||
+ ippErr.Status == ipp.StatusErrorNotAuthenticated ||
+ ippErr.Status == ipp.StatusErrorNotAuthorized
+ }
+
+ return false
+}
+
+func (m *Manager) GetPrinters() ([]Printer, error) {
+ attributes := []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterUriSupported,
+ ipp.AttributePrinterState,
+ ipp.AttributePrinterStateReasons,
+ ipp.AttributePrinterLocation,
+ ipp.AttributePrinterInfo,
+ ipp.AttributePrinterMakeAndModel,
+ ipp.AttributePrinterIsAcceptingJobs,
+ }
+
+ printerAttrs, err := m.client.GetPrinters(attributes)
+ if err != nil {
+ if isNoPrintersError(err) {
+ return []Printer{}, nil
+ }
+ return nil, err
+ }
+
+ printers := make([]Printer, 0, len(printerAttrs))
+ for _, attrs := range printerAttrs {
+ printer := Printer{
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported),
+ State: parsePrinterState(attrs),
+ StateReason: getStringAttr(attrs, ipp.AttributePrinterStateReasons),
+ Location: getStringAttr(attrs, ipp.AttributePrinterLocation),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
+ Accepting: getBoolAttr(attrs, ipp.AttributePrinterIsAcceptingJobs),
+ }
+
+ if printer.Name != "" {
+ printers = append(printers, printer)
+ }
+ }
+
+ return printers, nil
+}
+
+func (m *Manager) GetJobs(printerName string, whichJobs string) ([]Job, error) {
+ attributes := []string{
+ ipp.AttributeJobID,
+ ipp.AttributeJobName,
+ ipp.AttributeJobState,
+ ipp.AttributeJobPrinterURI,
+ ipp.AttributeJobOriginatingUserName,
+ ipp.AttributeJobKilobyteOctets,
+ "time-at-creation",
+ }
+
+ jobAttrs, err := m.client.GetJobs(printerName, "", whichJobs, false, 0, 0, attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ jobs := make([]Job, 0, len(jobAttrs))
+ for _, attrs := range jobAttrs {
+ job := Job{
+ ID: getIntAttr(attrs, ipp.AttributeJobID),
+ Name: getStringAttr(attrs, ipp.AttributeJobName),
+ State: parseJobState(attrs),
+ User: getStringAttr(attrs, ipp.AttributeJobOriginatingUserName),
+ Size: getIntAttr(attrs, ipp.AttributeJobKilobyteOctets) * 1024,
+ }
+
+ if uri := getStringAttr(attrs, ipp.AttributeJobPrinterURI); uri != "" {
+ parts := strings.Split(uri, "/")
+ if len(parts) > 0 {
+ job.Printer = parts[len(parts)-1]
+ }
+ }
+
+ if ts := getIntAttr(attrs, "time-at-creation"); ts > 0 {
+ job.TimeCreated = time.Unix(int64(ts), 0)
+ }
+
+ if job.ID != 0 {
+ jobs = append(jobs, job)
+ }
+ }
+
+ return jobs, nil
+}
+
+func (m *Manager) CancelJob(jobID int) error {
+ err := m.client.CancelJob(jobID, false)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobCancelPurge(jobID, false)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PausePrinter(printerName string) error {
+ err := m.client.PausePrinter(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetEnabled(printerName, false)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) ResumePrinter(printerName string) error {
+ err := m.client.ResumePrinter(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetEnabled(printerName, true)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PurgeJobs(printerName string) error {
+ err := m.client.CancelAllJob(printerName, true)
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func resolveIPFromURI(uri string) string {
+ parsed, err := url.Parse(uri)
+ if err != nil {
+ return ""
+ }
+ host := parsed.Hostname()
+ if host == "" {
+ return ""
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ return ip.String()
+ }
+ addrs, err := net.LookupIP(host)
+ if err != nil || len(addrs) == 0 {
+ return ""
+ }
+ for _, addr := range addrs {
+ if v4 := addr.To4(); v4 != nil {
+ return v4.String()
+ }
+ }
+ return addrs[0].String()
+}
+
+func (m *Manager) GetDevices() ([]Device, error) {
+ if m.pkHelper != nil {
+ devices, err := m.pkHelper.DevicesGet(10, 0, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ for i := range devices {
+ if devices[i].Class == "network" {
+ devices[i].IP = resolveIPFromURI(devices[i].URI)
+ }
+ }
+ return devices, nil
+ }
+
+ deviceAttrs, err := m.client.GetDevices()
+ if err != nil {
+ return nil, err
+ }
+
+ devices := make([]Device, 0, len(deviceAttrs))
+ for uri, attrs := range deviceAttrs {
+ device := Device{
+ URI: uri,
+ Class: getStringAttr(attrs, "device-class"),
+ Info: getStringAttr(attrs, "device-info"),
+ MakeModel: getStringAttr(attrs, "device-make-and-model"),
+ ID: getStringAttr(attrs, "device-id"),
+ Location: getStringAttr(attrs, "device-location"),
+ }
+ if device.Class == "network" {
+ device.IP = resolveIPFromURI(uri)
+ }
+ devices = append(devices, device)
+ }
+
+ return devices, nil
+}
+
+func (m *Manager) GetPPDs() ([]PPD, error) {
+ ppdAttrs, err := m.client.GetPPDs()
+ if err != nil {
+ return nil, err
+ }
+
+ ppds := make([]PPD, 0, len(ppdAttrs))
+ for name, attrs := range ppdAttrs {
+ ppd := PPD{
+ Name: name,
+ NaturalLanguage: getStringAttr(attrs, "ppd-natural-language"),
+ MakeModel: getStringAttr(attrs, ipp.AttributePPDMakeAndModel),
+ DeviceID: getStringAttr(attrs, "ppd-device-id"),
+ Product: getStringAttr(attrs, "ppd-product"),
+ PSVersion: getStringAttr(attrs, "ppd-psversion"),
+ Type: getStringAttr(attrs, "ppd-type"),
+ }
+ ppds = append(ppds, ppd)
+ }
+
+ return ppds, nil
+}
+
+func (m *Manager) GetClasses() ([]PrinterClass, error) {
+ attributes := []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterUriSupported,
+ ipp.AttributePrinterState,
+ ipp.AttributeMemberURIs,
+ ipp.AttributeMemberNames,
+ ipp.AttributePrinterLocation,
+ ipp.AttributePrinterInfo,
+ }
+
+ classAttrs, err := m.client.GetClasses(attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ classes := make([]PrinterClass, 0, len(classAttrs))
+ for _, attrs := range classAttrs {
+ class := PrinterClass{
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported),
+ State: parsePrinterState(attrs),
+ Location: getStringAttr(attrs, ipp.AttributePrinterLocation),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ Members: getStringSliceAttr(attrs, ipp.AttributeMemberNames),
+ }
+ classes = append(classes, class)
+ }
+
+ 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 {
+ // 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 {
+ return err
+ }
+
+ if usedPkHelper {
+ m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck
+ m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck
+ } else {
+ if err := m.client.ResumePrinter(name); isAuthError(err) && m.pkHelper != nil {
+ m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck
+ }
+ if err := m.client.AcceptJobs(name); isAuthError(err) && m.pkHelper != nil {
+ m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck
+ }
+ }
+
+ m.RefreshState()
+ return nil
+}
+
+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()
+ }
+ return err
+}
+
+func (m *Manager) AcceptJobs(printerName string) error {
+ err := m.client.AcceptJobs(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetAcceptJobs(printerName, true, "")
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RejectJobs(printerName string) error {
+ err := m.client.RejectJobs(printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetAcceptJobs(printerName, false, "")
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterShared(printerName string, shared bool) error {
+ err := m.client.SetPrinterIsShared(printerName, shared)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetShared(printerName, shared)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterLocation(printerName, location string) error {
+ err := m.client.SetPrinterLocation(printerName, location)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetLocation(printerName, location)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) SetPrinterInfo(printerName, info string) error {
+ err := m.client.SetPrinterInformation(printerName, info)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.PrinterSetInfo(printerName, info)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) MoveJob(jobID int, destPrinter string) error {
+ err := m.client.MoveJob(jobID, destPrinter)
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) PrintTestPage(printerName string) (int, error) {
+ jobID, err := m.client.PrintTestPage(printerName, strings.NewReader(config.TestPage), len(config.TestPage))
+ if err == nil {
+ m.RefreshState()
+ }
+ return jobID, err
+}
+
+func (m *Manager) AddPrinterToClass(className, printerName string) error {
+ err := m.client.AddPrinterToClass(className, printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassAddPrinter(className, printerName)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RemovePrinterFromClass(className, printerName string) error {
+ err := m.client.DeletePrinterFromClass(className, printerName)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassDeletePrinter(className, printerName)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) DeleteClass(className string) error {
+ err := m.client.DeleteClass(className)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.ClassDelete(className)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) RestartJob(jobID int) error {
+ err := m.client.RestartJob(jobID)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobRestart(jobID)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
+
+func (m *Manager) HoldJob(jobID int, holdUntil string) error {
+ err := m.client.HoldJobUntil(jobID, holdUntil)
+ if isAuthError(err) && m.pkHelper != nil {
+ err = m.pkHelper.JobSetHoldUntil(jobID, holdUntil)
+ }
+ if err == nil {
+ m.RefreshState()
+ }
+ return err
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go
new file mode 100644
index 0000000..ad16a8a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go
@@ -0,0 +1,235 @@
+package cups_test
+
+import (
+ "testing"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ mocks_pkhelper "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups_pkhelper"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func authErr() error {
+ return ipp.IPPError{Status: ipp.StatusErrorForbidden}
+}
+
+func TestManager_CancelJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobCancelPurge(1, false).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.CancelJob(1))
+}
+
+func TestManager_CancelJob_PkHelperError(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(authErr())
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobCancelPurge(1, false).Return(assert.AnError)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.Error(t, m.CancelJob(1))
+}
+
+func TestManager_PausePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetEnabled("printer1", false).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.PausePrinter("printer1"))
+}
+
+func TestManager_ResumePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetEnabled("printer1", true).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.ResumePrinter("printer1"))
+}
+
+func TestManager_GetDevices_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return([]cups.Device{
+ {URI: "usb://HP/LaserJet", Class: "direct"},
+ }, nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ got, err := m.GetDevices()
+ assert.NoError(t, err)
+ assert.Len(t, got, 1)
+ assert.Equal(t, "usb://HP/LaserJet", got[0].URI)
+}
+
+func TestManager_GetDevices_PkHelperError(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return(nil, assert.AnError)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ _, err := m.GetDevices()
+ assert.Error(t, err)
+}
+
+func TestManager_CreatePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterAdd("newprinter", "usb://HP", "generic.ppd", "info", "location").Return(nil)
+ mockPk.EXPECT().PrinterSetEnabled("newprinter", true).Return(nil)
+ mockPk.EXPECT().PrinterSetAcceptJobs("newprinter", true, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location"))
+}
+
+func TestManager_DeletePrinter_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterDelete("printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.DeletePrinter("printer1"))
+}
+
+func TestManager_AcceptJobs_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetAcceptJobs("printer1", true, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.AcceptJobs("printer1"))
+}
+
+func TestManager_RejectJobs_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetAcceptJobs("printer1", false, "").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RejectJobs("printer1"))
+}
+
+func TestManager_SetPrinterShared_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetShared("printer1", true).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterShared("printer1", true))
+}
+
+func TestManager_SetPrinterLocation_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetLocation("printer1", "Office").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterLocation("printer1", "Office"))
+}
+
+func TestManager_SetPrinterInfo_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().PrinterSetInfo("printer1", "Main Printer").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer"))
+}
+
+func TestManager_AddPrinterToClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassAddPrinter("office", "printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.AddPrinterToClass("office", "printer1"))
+}
+
+func TestManager_RemovePrinterFromClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassDeletePrinter("office", "printer1").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RemovePrinterFromClass("office", "printer1"))
+}
+
+func TestManager_DeleteClass_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().ClassDelete("office").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.DeleteClass("office"))
+}
+
+func TestManager_RestartJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobRestart(1).Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.RestartJob(1))
+}
+
+func TestManager_HoldJob_WithPkHelper(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(authErr())
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ mockPk := mocks_pkhelper.NewMockPkHelper(t)
+ mockPk.EXPECT().JobSetHoldUntil(1, "indefinite").Return(nil)
+
+ m := cups.NewTestManager(mockClient, mockPk)
+ assert.NoError(t, m.HoldJob(1, "indefinite"))
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go
new file mode 100644
index 0000000..70f4f21
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go
@@ -0,0 +1,450 @@
+package cups
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_GetPrinters(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterStateReasons: []ipp.Attribute{{Value: "none"}},
+ ipp.AttributePrinterLocation: []ipp.Attribute{{Value: "Office"}},
+ ipp.AttributePrinterInfo: []ipp.Attribute{{Value: "Test Printer"}},
+ ipp.AttributePrinterMakeAndModel: []ipp.Attribute{{Value: "Generic"}},
+ ipp.AttributePrinterIsAcceptingJobs: []ipp.Attribute{{Value: true}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetPrinters()
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, "printer1", got[0].Name)
+ assert.Equal(t, "idle", got[0].State)
+ assert.Equal(t, "Office", got[0].Location)
+ assert.True(t, got[0].Accepting)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_GetJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[int]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "test-job"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ ipp.AttributeJobPrinterURI: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributeJobOriginatingUserName: []ipp.Attribute{{Value: "testuser"}},
+ ipp.AttributeJobKilobyteOctets: []ipp.Attribute{{Value: 10}},
+ "time-at-creation": []ipp.Attribute{{Value: 1609459200}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetJobs("printer1", "not-completed")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, 1, got[0].ID)
+ assert.Equal(t, "test-job", got[0].Name)
+ assert.Equal(t, "processing", got[0].State)
+ assert.Equal(t, "testuser", got[0].User)
+ assert.Equal(t, "printer1", got[0].Printer)
+ assert.Equal(t, 10240, got[0].Size)
+ assert.Equal(t, time.Unix(1609459200, 0), got[0].TimeCreated)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_CancelJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.CancelJob(1))
+}
+
+func TestManager_PausePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.PausePrinter("printer1"))
+}
+
+func TestManager_ResumePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.ResumePrinter("printer1"))
+}
+
+func TestManager_PurgeJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(tt.mockErr)
+ if !tt.wantErr {
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+ }
+
+ m := NewTestManager(mockClient, nil)
+
+ err := m.PurgeJobs("printer1")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestManager_GetDevices(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{
+ "usb://HP/LaserJet": {
+ "device-class": []ipp.Attribute{{Value: "direct"}},
+ "device-info": []ipp.Attribute{{Value: "HP LaserJet"}},
+ "device-make-and-model": []ipp.Attribute{{Value: "HP LaserJet 1020"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ got, err := m.GetDevices()
+ assert.NoError(t, err)
+ assert.Len(t, got, 1)
+ assert.Equal(t, "usb://HP/LaserJet", got[0].URI)
+ assert.Equal(t, "direct", got[0].Class)
+}
+
+func TestManager_GetPPDs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "drv:///sample.drv/generic.ppd": {
+ "ppd-make-and-model": []ipp.Attribute{{Value: "Generic PostScript"}},
+ "ppd-type": []ipp.Attribute{{Value: "ppd"}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPPDs().Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{client: mockClient}
+
+ got, err := m.GetPPDs()
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ })
+ }
+}
+
+func TestManager_GetClasses(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "office": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributeMemberNames: []ipp.Attribute{{Value: "printer1"}, {Value: "printer2"}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetClasses(mock.Anything).Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{client: mockClient}
+
+ got, err := m.GetClasses()
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, "office", got[0].Name)
+ assert.Equal(t, 2, len(got[0].Members))
+ }
+ })
+ }
+}
+
+func TestManager_CreatePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(nil)
+ mockClient.EXPECT().ResumePrinter("newprinter").Return(nil)
+ mockClient.EXPECT().AcceptJobs("newprinter").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location"))
+}
+
+func TestManager_DeletePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.DeletePrinter("printer1"))
+}
+
+func TestManager_AcceptJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.AcceptJobs("printer1"))
+}
+
+func TestManager_RejectJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RejectJobs("printer1"))
+}
+
+func TestManager_SetPrinterShared(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterShared("printer1", true))
+}
+
+func TestManager_SetPrinterLocation(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterLocation("printer1", "Office"))
+}
+
+func TestManager_SetPrinterInfo(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer"))
+}
+
+func TestManager_MoveJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().MoveJob(1, "printer2").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ err := m.MoveJob(1, "printer2")
+ assert.NoError(t, err)
+}
+
+func TestManager_PrintTestPage(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ jobID, err := m.PrintTestPage("printer1")
+ assert.NoError(t, err)
+ assert.Equal(t, 42, jobID)
+}
+
+func TestManager_AddPrinterToClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.AddPrinterToClass("office", "printer1"))
+}
+
+func TestManager_RemovePrinterFromClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RemovePrinterFromClass("office", "printer1"))
+}
+
+func TestManager_DeleteClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.DeleteClass("office"))
+}
+
+func TestManager_RestartJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.RestartJob(1))
+}
+
+func TestManager_HoldJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ assert.NoError(t, m.HoldJob(1, "indefinite"))
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go
new file mode 100644
index 0000000..57b45d5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go
@@ -0,0 +1,487 @@
+package cups
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type CUPSEvent struct {
+ Type string `json:"type"`
+ Data CUPSState `json:"data"`
+}
+
+type TestPageResult struct {
+ Success bool `json:"success"`
+ JobID int `json:"jobId"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "cups.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "cups.getPrinters":
+ handleGetPrinters(conn, req, manager)
+ case "cups.getJobs":
+ handleGetJobs(conn, req, manager)
+ case "cups.pausePrinter":
+ handlePausePrinter(conn, req, manager)
+ case "cups.resumePrinter":
+ handleResumePrinter(conn, req, manager)
+ case "cups.cancelJob":
+ handleCancelJob(conn, req, manager)
+ case "cups.purgeJobs":
+ handlePurgeJobs(conn, req, manager)
+ case "cups.getDevices":
+ handleGetDevices(conn, req, manager)
+ case "cups.getPPDs":
+ handleGetPPDs(conn, req, manager)
+ case "cups.getClasses":
+ handleGetClasses(conn, req, manager)
+ case "cups.createPrinter":
+ handleCreatePrinter(conn, req, manager)
+ case "cups.deletePrinter":
+ handleDeletePrinter(conn, req, manager)
+ case "cups.acceptJobs":
+ handleAcceptJobs(conn, req, manager)
+ case "cups.rejectJobs":
+ handleRejectJobs(conn, req, manager)
+ case "cups.setPrinterShared":
+ handleSetPrinterShared(conn, req, manager)
+ case "cups.setPrinterLocation":
+ handleSetPrinterLocation(conn, req, manager)
+ case "cups.setPrinterInfo":
+ handleSetPrinterInfo(conn, req, manager)
+ case "cups.moveJob":
+ handleMoveJob(conn, req, manager)
+ case "cups.printTestPage":
+ handlePrintTestPage(conn, req, manager)
+ case "cups.addPrinterToClass":
+ handleAddPrinterToClass(conn, req, manager)
+ case "cups.removePrinterFromClass":
+ handleRemovePrinterFromClass(conn, req, manager)
+ case "cups.deleteClass":
+ handleDeleteClass(conn, req, manager)
+ case "cups.restartJob":
+ 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))
+ }
+}
+
+func handleGetPrinters(conn net.Conn, req models.Request, manager *Manager) {
+ printers, err := manager.GetPrinters()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, printers)
+}
+
+func handleGetJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ jobs, err := manager.GetJobs(printerName, "not-completed")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, jobs)
+}
+
+func handlePausePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PausePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paused"})
+}
+
+func handleResumePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ResumePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "resumed"})
+}
+
+func handleCancelJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.CancelJob(jobID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job canceled"})
+}
+
+func handlePurgeJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.String(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PurgeJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "jobs canceled"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleGetDevices(conn net.Conn, req models.Request, manager *Manager) {
+ devices, err := manager.GetDevices()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, devices)
+}
+
+func handleGetPPDs(conn net.Conn, req models.Request, manager *Manager) {
+ ppds, err := manager.GetPPDs()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, ppds)
+}
+
+func handleGetClasses(conn net.Conn, req models.Request, manager *Manager) {
+ classes, err := manager.GetClasses()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, classes)
+}
+
+func handleCreatePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ name, err := params.StringNonEmpty(req.Params, "name")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ deviceURI, err := params.StringNonEmpty(req.Params, "deviceURI")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ ppd, err := params.StringNonEmpty(req.Params, "ppd")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ shared := params.BoolOpt(req.Params, "shared", false)
+ errorPolicy := params.StringOpt(req.Params, "errorPolicy", "")
+ information := params.StringOpt(req.Params, "information", "")
+ location := params.StringOpt(req.Params, "location", "")
+
+ if err := manager.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer created"})
+}
+
+func handleDeletePrinter(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DeletePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer deleted"})
+}
+
+func handleAcceptJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.AcceptJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "accepting jobs"})
+}
+
+func handleRejectJobs(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RejectJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "rejecting jobs"})
+}
+
+func handleSetPrinterShared(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ shared, err := params.Bool(req.Params, "shared")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterShared(printerName, shared); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sharing updated"})
+}
+
+func handleSetPrinterLocation(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ location, err := params.String(req.Params, "location")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterLocation(printerName, location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location updated"})
+}
+
+func handleSetPrinterInfo(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ info, err := params.String(req.Params, "info")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPrinterInfo(printerName, info); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "info updated"})
+}
+
+func handleMoveJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ destPrinter, err := params.StringNonEmpty(req.Params, "destPrinter")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.MoveJob(jobID, destPrinter); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job moved"})
+}
+
+func handlePrintTestPage(conn net.Conn, req models.Request, manager *Manager) {
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ jobID, err := manager.PrintTestPage(printerName)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, TestPageResult{Success: true, JobID: jobID, Message: "test page queued"})
+}
+
+func handleAddPrinterToClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.AddPrinterToClass(className, printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer added to class"})
+}
+
+func handleRemovePrinterFromClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ printerName, err := params.StringNonEmpty(req.Params, "printerName")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RemovePrinterFromClass(className, printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer removed from class"})
+}
+
+func handleDeleteClass(conn net.Conn, req models.Request, manager *Manager) {
+ className, err := params.StringNonEmpty(req.Params, "className")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DeleteClass(className); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "class deleted"})
+}
+
+func handleRestartJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RestartJob(jobID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job restarted"})
+}
+
+func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
+ jobID, err := params.Int(req.Params, "jobID")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ holdUntil := params.StringOpt(req.Params, "holdUntil", "indefinite")
+
+ if err := manager.HoldJob(jobID, holdUntil); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ 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/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go
new file mode 100644
index 0000000..b73931e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go
@@ -0,0 +1,711 @@
+package cups
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+type mockConn struct {
+ *bytes.Buffer
+}
+
+func (m *mockConn) Close() error { return nil }
+func (m *mockConn) LocalAddr() net.Addr { return nil }
+func (m *mockConn) RemoteAddr() net.Addr { return nil }
+func (m *mockConn) SetDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
+
+func TestHandleGetPrinters(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(conn, req, m)
+
+ var resp models.Response[[]Printer]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetPrinters_Error(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(nil, errors.New("test error"))
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(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 TestHandleGetJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "job1"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handleGetJobs(conn, req, m)
+
+ var resp models.Response[[]Job]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetJobs_MissingParam(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]any{},
+ }
+
+ handleGetJobs(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 TestHandlePausePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.pausePrinter",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePausePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleResumePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.resumePrinter",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handleResumePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleCancelJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.cancelJob",
+ Params: map[string]any{
+ "jobID": float64(1),
+ },
+ }
+
+ handleCancelJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandlePurgeJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.purgeJobs",
+ Params: map[string]any{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePurgeJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRequest_UnknownMethod(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.unknownMethod",
+ }
+
+ HandleRequest(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 TestHandleGetDevices(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{
+ "usb://HP/LaserJet": {
+ "device-class": []ipp.Attribute{{Value: "direct"}},
+ "device-info": []ipp.Attribute{{Value: "HP LaserJet"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getDevices"}
+ handleGetDevices(conn, req, m)
+
+ var resp models.Response[[]Device]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetPPDs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPPDs().Return(map[string]ipp.Attributes{
+ "generic.ppd": {
+ "ppd-make-and-model": []ipp.Attribute{{Value: "Generic"}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getPPDs"}
+ handleGetPPDs(conn, req, m)
+
+ var resp models.Response[[]PPD]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetClasses(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetClasses(mock.Anything).Return(map[string]ipp.Attributes{
+ "office": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ },
+ }, nil)
+
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.getClasses"}
+ handleGetClasses(conn, req, m)
+
+ var resp models.Response[[]PrinterClass]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleCreatePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", false, "", "", "").Return(nil)
+ mockClient.EXPECT().ResumePrinter("newprinter").Return(nil)
+ mockClient.EXPECT().AcceptJobs("newprinter").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.createPrinter",
+ Params: map[string]any{
+ "name": "newprinter",
+ "deviceURI": "usb://HP",
+ "ppd": "generic.ppd",
+ },
+ }
+ handleCreatePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleCreatePrinter_MissingParams(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ m := &Manager{client: mockClient}
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{ID: 1, Method: "cups.createPrinter", Params: map[string]any{}}
+ handleCreatePrinter(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 TestHandleDeletePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinter("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.deletePrinter",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleDeletePrinter(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleAcceptJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AcceptJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.acceptJobs",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleAcceptJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRejectJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RejectJobs("printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.rejectJobs",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handleRejectJobs(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterShared(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterShared",
+ Params: map[string]any{"printerName": "printer1", "shared": true},
+ }
+ handleSetPrinterShared(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterLocation(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterLocation",
+ Params: map[string]any{"printerName": "printer1", "location": "Office"},
+ }
+ handleSetPrinterLocation(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleSetPrinterInfo(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.setPrinterInfo",
+ Params: map[string]any{"printerName": "printer1", "info": "Main Printer"},
+ }
+ handleSetPrinterInfo(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleMoveJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().MoveJob(1, "printer2").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.moveJob",
+ Params: map[string]any{"jobID": float64(1), "destPrinter": "printer2"},
+ }
+ handleMoveJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandlePrintTestPage(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.printTestPage",
+ Params: map[string]any{"printerName": "printer1"},
+ }
+ handlePrintTestPage(conn, req, m)
+
+ var resp models.Response[TestPageResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, 42, resp.Result.JobID)
+}
+
+func TestHandleAddPrinterToClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.addPrinterToClass",
+ Params: map[string]any{"className": "office", "printerName": "printer1"},
+ }
+ handleAddPrinterToClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRemovePrinterFromClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.removePrinterFromClass",
+ Params: map[string]any{"className": "office", "printerName": "printer1"},
+ }
+ handleRemovePrinterFromClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleDeleteClass(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().DeleteClass("office").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.deleteClass",
+ Params: map[string]any{"className": "office"},
+ }
+ handleDeleteClass(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRestartJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().RestartJob(1).Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.restartJob",
+ Params: map[string]any{"jobID": float64(1)},
+ }
+ handleRestartJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleHoldJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.holdJob",
+ Params: map[string]any{"jobID": float64(1)},
+ }
+ handleHoldJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleHoldJob_WithHoldUntil(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().HoldJobUntil(1, "no-hold").Return(nil)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil)
+
+ m := NewTestManager(mockClient, nil)
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := models.Request{
+ ID: 1,
+ Method: "cups.holdJob",
+ Params: map[string]any{"jobID": float64(1), "holdUntil": "no-hold"},
+ }
+ handleHoldJob(conn, req, m)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go
new file mode 100644
index 0000000..e5b7e8c
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go
@@ -0,0 +1,390 @@
+package cups
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+func NewManager() (*Manager, error) {
+ host := os.Getenv("DMS_IPP_HOST")
+ if host == "" {
+ host = "localhost"
+ }
+
+ portStr := os.Getenv("DMS_IPP_PORT")
+ port := 631
+ if portStr != "" {
+ if p, err := strconv.Atoi(portStr); err == nil {
+ port = p
+ }
+ }
+
+ username := os.Getenv("DMS_IPP_USERNAME")
+ password := os.Getenv("DMS_IPP_PASSWORD")
+
+ client := ipp.NewCUPSClient(host, port, username, password, false)
+ baseURL := fmt.Sprintf("http://%s:%d", host, port)
+
+ var pkHelper PkHelper
+ if isLocalCUPS(host) {
+ var err error
+ pkHelper, err = NewPkHelper()
+ if err != nil {
+ log.Warnf("[CUPS] Failed to initialize pkhelper: %v", err)
+ }
+ }
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: client,
+ pkHelper: pkHelper,
+ baseURL: baseURL,
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ if err := m.updateState(); err != nil {
+ return nil, err
+ }
+
+ if isLocalCUPS(host) {
+ m.subscription = NewDBusSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using D-Bus notifications for local CUPS")
+ } else {
+ m.subscription = NewSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using IPPGET notifications for remote CUPS")
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func isLocalCUPS(host string) bool {
+ switch host {
+ case "localhost", "127.0.0.1", "::1", "":
+ return true
+ }
+ return false
+}
+
+func (m *Manager) eventHandler() {
+ defer m.eventWG.Done()
+
+ if m.subscription == nil {
+ return
+ }
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event, ok := <-m.subscription.Events():
+ if !ok {
+ return
+ }
+ log.Debugf("[CUPS] Received event: %s (printer: %s, job: %d)",
+ event.EventName, event.PrinterName, event.JobID)
+
+ if err := m.updateState(); err != nil {
+ log.Warnf("[CUPS] Failed to update state after event: %v", err)
+ } else {
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) updateState() error {
+ printers, err := m.GetPrinters()
+ if err != nil {
+ if isNoPrintersError(err) {
+ m.stateMutex.Lock()
+ m.state.Printers = make(map[string]*Printer)
+ m.stateMutex.Unlock()
+ return nil
+ }
+ return err
+ }
+
+ printerMap := make(map[string]*Printer, len(printers))
+ for _, printer := range printers {
+ jobs, err := m.GetJobs(printer.Name, "not-completed")
+ if err != nil {
+ return err
+ }
+
+ printer.Jobs = jobs
+ printerMap[printer.Name] = &printer
+ }
+
+ m.stateMutex.Lock()
+ m.state.Printers = printerMap
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func isNoPrintersError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var ippErr ipp.IPPError
+ if errors.As(err, &ippErr) {
+ return ippErr.Status == 1030
+ }
+
+ return false
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) RefreshState() {
+ if err := m.updateState(); err != nil {
+ log.Warnf("[CUPS] Failed to refresh state: %v", err)
+ return
+ }
+ m.notifySubscribers()
+}
+
+func (m *Manager) GetState() CUPSState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() CUPSState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := CUPSState{
+ Printers: make(map[string]*Printer, len(m.state.Printers)),
+ }
+ for name, printer := range m.state.Printers {
+ printerCopy := *printer
+ s.Printers[name] = &printerCopy
+ }
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan CUPSState {
+ ch := make(chan CUPSState, 64)
+
+ wasEmpty := true
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ wasEmpty = false
+ return false
+ })
+
+ m.subscribers.Store(id, ch)
+
+ if wasEmpty && m.subscription != nil {
+ if err := m.subscription.Start(); err != nil {
+ log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
+ } else {
+ m.eventWG.Add(1)
+ go m.eventHandler()
+ }
+ }
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+
+ isEmpty := true
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ isEmpty = false
+ return false
+ })
+
+ if isEmpty && m.subscription != nil {
+ m.subscription.Stop()
+ m.eventWG.Wait()
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ if m.subscription != nil {
+ m.subscription.Stop()
+ }
+
+ m.eventWG.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
+
+func stateChanged(old, new *CUPSState) bool {
+ if len(old.Printers) != len(new.Printers) {
+ return true
+ }
+ for name, oldPrinter := range old.Printers {
+ newPrinter, exists := new.Printers[name]
+ if !exists {
+ return true
+ }
+ if oldPrinter.State != newPrinter.State ||
+ oldPrinter.StateReason != newPrinter.StateReason ||
+ oldPrinter.Accepting != newPrinter.Accepting ||
+ len(oldPrinter.Jobs) != len(newPrinter.Jobs) {
+ return true
+ }
+ }
+ return false
+}
+
+func parsePrinterState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributePrinterState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "idle"
+ case 4:
+ return "processing"
+ case 5:
+ return "stopped"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func parseJobState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributeJobState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "pending"
+ case 4:
+ return "pending-held"
+ case 5:
+ return "processing"
+ case 6:
+ return "processing-stopped"
+ case 7:
+ return "canceled"
+ case 8:
+ return "aborted"
+ case 9:
+ return "completed"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func getStringAttr(attrs ipp.Attributes, key string) string {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ return val
+ }
+ return fmt.Sprintf("%v", attr[0].Value)
+ }
+ return ""
+}
+
+func getIntAttr(attrs ipp.Attributes, key string) int {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ return val
+ }
+ }
+ return 0
+}
+
+func getBoolAttr(attrs ipp.Attributes, key string) bool {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(bool); ok {
+ return val
+ }
+ }
+ return false
+}
+
+func getStringSliceAttr(attrs ipp.Attributes, key string) []string {
+ attr, ok := attrs[key]
+ if !ok {
+ return nil
+ }
+
+ result := make([]string, 0, len(attr))
+ for _, a := range attr {
+ if val, ok := a.Value.(string); ok {
+ result = append(result, val)
+ }
+ }
+ return result
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go
new file mode 100644
index 0000000..7f09e67
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go
@@ -0,0 +1,363 @@
+package cups
+
+import (
+ "testing"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewManager(t *testing.T) {
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: nil,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ assert.NotNil(t, m)
+ assert.NotNil(t, m.state)
+}
+
+func TestManager_GetState(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: map[string]*Printer{
+ "test-printer": {
+ Name: "test-printer",
+ State: "idle",
+ },
+ },
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ state := m.GetState()
+ assert.Equal(t, 1, len(state.Printers))
+ assert.Equal(t, "test-printer", state.Printers["test-printer"].Name)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ ch := m.Subscribe("test-client")
+ assert.NotNil(t, ch)
+
+ count := 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+
+ m.Unsubscribe("test-client")
+ count = 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestManager_Close(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+
+ m.eventWG.Add(1)
+ go func() {
+ defer m.eventWG.Done()
+ <-m.stopChan
+ }()
+
+ m.notifierWg.Add(1)
+ go func() {
+ defer m.notifierWg.Done()
+ <-m.stopChan
+ }()
+
+ m.Close()
+ count := 0
+ m.subscribers.Range(func(key string, ch chan CUPSState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestStateChanged(t *testing.T) {
+ tests := []struct {
+ name string
+ oldState *CUPSState
+ newState *CUPSState
+ want bool
+ }{
+ {
+ name: "no change",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "state changed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "processing"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer added",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer removed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stateChanged(tt.oldState, tt.newState)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParsePrinterState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "idle",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "idle",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 4}},
+ },
+ want: "processing",
+ },
+ {
+ name: "stopped",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "stopped",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parsePrinterState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParseJobState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "pending",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "pending",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "processing",
+ },
+ {
+ name: "completed",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 9}},
+ },
+ want: "completed",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseJobState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetStringAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want string
+ }{
+ {
+ name: "string value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: "test-value"}},
+ },
+ key: "test-key",
+ want: "test-value",
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getStringAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetIntAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want int
+ }{
+ {
+ name: "int value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: 42}},
+ },
+ key: "test-key",
+ want: 42,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getIntAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetBoolAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want bool
+ }{
+ {
+ name: "true value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: true}},
+ },
+ key: "test-key",
+ want: true,
+ },
+ {
+ name: "false value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: false}},
+ },
+ key: "test-key",
+ want: false,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getBoolAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go
new file mode 100644
index 0000000..81dd458
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go
@@ -0,0 +1,184 @@
+package cups
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ pkHelperDest = "org.opensuse.CupsPkHelper.Mechanism"
+ pkHelperPath = "/"
+ pkHelperInterface = "org.opensuse.CupsPkHelper.Mechanism"
+)
+
+type PkHelper interface {
+ DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error)
+ PrinterAdd(name, uri, ppd, info, location string) error
+ PrinterDelete(name string) error
+ PrinterSetEnabled(name string, enabled bool) error
+ PrinterSetAcceptJobs(name string, enabled bool, reason string) error
+ PrinterSetInfo(name, info string) error
+ PrinterSetLocation(name, location string) error
+ PrinterSetShared(name string, shared bool) error
+ ClassAddPrinter(className, printerName string) error
+ ClassDeletePrinter(className, printerName string) error
+ ClassDelete(className string) error
+ JobCancelPurge(jobID int, purge bool) error
+ JobRestart(jobID int) error
+ JobSetHoldUntil(jobID int, holdUntil string) error
+}
+
+type DBusPkHelper struct {
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+func NewPkHelper() (*DBusPkHelper, error) {
+ conn, err := dbus.SystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ return &DBusPkHelper{
+ conn: conn,
+ obj: conn.Object(pkHelperDest, pkHelperPath),
+ }, nil
+}
+
+func (p *DBusPkHelper) DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error) {
+ if includeSchemes == nil {
+ includeSchemes = []string{}
+ }
+ if excludeSchemes == nil {
+ excludeSchemes = []string{}
+ }
+
+ var errStr string
+ var devicesMap map[string]string
+
+ call := p.obj.Call(pkHelperInterface+".DevicesGet", 0, int32(timeout), int32(limit), includeSchemes, excludeSchemes)
+ if call.Err != nil {
+ return nil, call.Err
+ }
+ if err := call.Store(&errStr, &devicesMap); err != nil {
+ return nil, err
+ }
+ if errStr != "" {
+ return nil, fmt.Errorf("%s", errStr)
+ }
+
+ return parseDevicesMap(devicesMap), nil
+}
+
+func parseDevicesMap(devicesMap map[string]string) []Device {
+ devicesByIndex := make(map[string]*Device)
+
+ for key, value := range devicesMap {
+ idx := strings.LastIndex(key, ":")
+ if idx == -1 {
+ continue
+ }
+
+ attr := key[:idx]
+ index := key[idx+1:]
+
+ dev, ok := devicesByIndex[index]
+ if !ok {
+ dev = &Device{}
+ devicesByIndex[index] = dev
+ }
+
+ switch attr {
+ case "device-uri":
+ dev.URI = value
+ case "device-class":
+ dev.Class = value
+ case "device-info":
+ dev.Info = value
+ case "device-make-and-model":
+ dev.MakeModel = value
+ case "device-id":
+ dev.ID = value
+ case "device-location":
+ dev.Location = value
+ }
+ }
+
+ devices := make([]Device, 0, len(devicesByIndex))
+ for _, dev := range devicesByIndex {
+ if dev.URI != "" {
+ devices = append(devices, *dev)
+ }
+ }
+ return devices
+}
+
+func (p *DBusPkHelper) PrinterAdd(name, uri, ppd, info, location string) error {
+ return p.callSimple("PrinterAdd", name, uri, ppd, info, location)
+}
+
+func (p *DBusPkHelper) PrinterDelete(name string) error {
+ return p.callSimple("PrinterDelete", name)
+}
+
+func (p *DBusPkHelper) PrinterSetEnabled(name string, enabled bool) error {
+ return p.callSimple("PrinterSetEnabled", name, enabled)
+}
+
+func (p *DBusPkHelper) PrinterSetAcceptJobs(name string, enabled bool, reason string) error {
+ return p.callSimple("PrinterSetAcceptJobs", name, enabled, reason)
+}
+
+func (p *DBusPkHelper) PrinterSetInfo(name, info string) error {
+ return p.callSimple("PrinterSetInfo", name, info)
+}
+
+func (p *DBusPkHelper) PrinterSetLocation(name, location string) error {
+ return p.callSimple("PrinterSetLocation", name, location)
+}
+
+func (p *DBusPkHelper) PrinterSetShared(name string, shared bool) error {
+ return p.callSimple("PrinterSetShared", name, shared)
+}
+
+func (p *DBusPkHelper) ClassAddPrinter(className, printerName string) error {
+ return p.callSimple("ClassAddPrinter", className, printerName)
+}
+
+func (p *DBusPkHelper) ClassDeletePrinter(className, printerName string) error {
+ return p.callSimple("ClassDeletePrinter", className, printerName)
+}
+
+func (p *DBusPkHelper) ClassDelete(className string) error {
+ return p.callSimple("ClassDelete", className)
+}
+
+func (p *DBusPkHelper) JobCancelPurge(jobID int, purge bool) error {
+ return p.callSimple("JobCancelPurge", int32(jobID), purge)
+}
+
+func (p *DBusPkHelper) JobRestart(jobID int) error {
+ return p.callSimple("JobRestart", int32(jobID))
+}
+
+func (p *DBusPkHelper) JobSetHoldUntil(jobID int, holdUntil string) error {
+ return p.callSimple("JobSetHoldUntil", int32(jobID), holdUntil)
+}
+
+func (p *DBusPkHelper) callSimple(method string, args ...any) error {
+ var errStr string
+
+ call := p.obj.Call(pkHelperInterface+"."+method, 0, args...)
+ if call.Err != nil {
+ return call.Err
+ }
+ if err := call.Store(&errStr); err != nil {
+ return err
+ }
+ if errStr != "" {
+ return fmt.Errorf("%s", errStr)
+ }
+ return nil
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go
new file mode 100644
index 0000000..596f1d0
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go
@@ -0,0 +1,95 @@
+package cups
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseDevicesMap(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]string
+ wantLen int
+ wantURIs []string
+ }{
+ {
+ name: "empty",
+ input: map[string]string{},
+ wantLen: 0,
+ wantURIs: nil,
+ },
+ {
+ name: "single_device",
+ input: map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-make-and-model:0": "HP LaserJet 1020",
+ "device-id:0": "MFG:HP;MDL:LaserJet",
+ },
+ wantLen: 1,
+ wantURIs: []string{"usb://HP/LaserJet"},
+ },
+ {
+ name: "multiple_devices",
+ input: map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-uri:1": "socket://192.168.1.100",
+ "device-class:1": "network",
+ "device-info:1": "Network Printer",
+ },
+ wantLen: 2,
+ wantURIs: []string{"usb://HP/LaserJet", "socket://192.168.1.100"},
+ },
+ {
+ name: "malformed_key",
+ input: map[string]string{
+ "no-colon-here": "value",
+ },
+ wantLen: 0,
+ wantURIs: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseDevicesMap(tt.input)
+ assert.Len(t, got, tt.wantLen)
+
+ if tt.wantURIs != nil {
+ gotURIs := make(map[string]bool)
+ for _, d := range got {
+ gotURIs[d.URI] = true
+ }
+ for _, uri := range tt.wantURIs {
+ assert.True(t, gotURIs[uri], "expected URI %s not found", uri)
+ }
+ }
+ })
+ }
+}
+
+func TestParseDevicesMap_Attributes(t *testing.T) {
+ input := map[string]string{
+ "device-uri:0": "usb://HP/LaserJet",
+ "device-class:0": "direct",
+ "device-info:0": "HP LaserJet",
+ "device-make-and-model:0": "HP LaserJet 1020",
+ "device-id:0": "MFG:HP;MDL:LaserJet",
+ "device-location:0": "USB Port",
+ }
+
+ got := parseDevicesMap(input)
+ assert.Len(t, got, 1)
+
+ dev := got[0]
+ assert.Equal(t, "usb://HP/LaserJet", dev.URI)
+ assert.Equal(t, "direct", dev.Class)
+ assert.Equal(t, "HP LaserJet", dev.Info)
+ assert.Equal(t, "HP LaserJet 1020", dev.MakeModel)
+ assert.Equal(t, "MFG:HP;MDL:LaserJet", dev.ID)
+ assert.Equal(t, "USB Port", dev.Location)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go
new file mode 100644
index 0000000..6b369b4
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go
@@ -0,0 +1,245 @@
+package cups
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+)
+
+type SubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ sequenceNumber int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+}
+
+func NewSubscriptionManager(client CUPSClientInterface, baseURL string) *SubscriptionManager {
+ return &SubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *SubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ subID, err := sm.createSubscription()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created IPP subscription with ID %d", subID)
+
+ sm.wg.Add(1)
+ go sm.notificationLoop()
+
+ return nil
+}
+
+func (sm *SubscriptionManager) createSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ // Subscription attributes go in SubscriptionAttributes (subscription-attributes-tag in IPP)
+ req.SubscriptionAttributes = map[string]any{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-pull-method": "ippget",
+ "notify-lease-duration": 0,
+ }
+
+ // Send to root IPP endpoint
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ // Check for IPP errors
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ // Subscription ID comes back in SubscriptionAttributes
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *SubscriptionManager) notificationLoop() {
+ defer sm.wg.Done()
+
+ backoff := 1 * time.Second
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ default:
+ }
+
+ gotAny, err := sm.fetchNotificationsWithWait()
+ if err != nil {
+ log.Warnf("[CUPS] Error fetching notifications: %v", err)
+ jitter := time.Duration(50+(time.Now().UnixNano()%200)) * time.Millisecond
+ sleepTime := backoff + jitter
+ if sleepTime > 30*time.Second {
+ sleepTime = 30 * time.Second
+ }
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(sleepTime):
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ }
+ continue
+ }
+
+ backoff = 1 * time.Second
+
+ if gotAny {
+ continue
+ }
+
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+func (sm *SubscriptionManager) fetchNotificationsWithWait() (bool, error) {
+ req := ipp.NewRequest(ipp.OperationGetNotifications, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-ids"] = sm.subscriptionID
+ if sm.sequenceNumber > 0 {
+ req.OperationAttributes["notify-sequence-numbers"] = sm.sequenceNumber
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return false, err
+ }
+
+ gotAny := false
+ for _, eventGroup := range resp.SubscriptionAttributes {
+ if seqAttr, ok := eventGroup["notify-sequence-number"]; ok && len(seqAttr) > 0 {
+ if seqNum, ok := seqAttr[0].Value.(int); ok {
+ sm.sequenceNumber = seqNum + 1
+ }
+ }
+
+ event := sm.parseEvent(eventGroup)
+ gotAny = true
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return gotAny, nil
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+
+ return gotAny, nil
+}
+
+func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEvent {
+ event := SubscriptionEvent{
+ SubscribedAt: time.Now(),
+ }
+
+ if attr, ok := attrs["notify-subscribed-event"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.EventName = val
+ }
+ }
+
+ if attr, ok := attrs["printer-name"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.PrinterName = val
+ }
+ }
+
+ if attr, ok := attrs["notify-job-id"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ event.JobID = val
+ }
+ }
+
+ return event
+}
+
+func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *SubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ sm.sequenceNumber = 0
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *SubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go
new file mode 100644
index 0000000..b205a6b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go
@@ -0,0 +1,295 @@
+package cups
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusSubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+ conn *dbus.Conn
+}
+
+func NewDBusSubscriptionManager(client CUPSClientInterface, baseURL string) *DBusSubscriptionManager {
+ return &DBusSubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *DBusSubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ sm.conn = conn
+
+ subID, err := sm.createDBusSubscription()
+ if err != nil {
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create D-Bus subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created D-Bus subscription with ID %d", subID)
+
+ if err := sm.conn.AddMatchSignal(
+ dbus.WithMatchInterface("org.cups.cupsd.Notifier"),
+ ); err != nil {
+ sm.cancelSubscription()
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to add D-Bus match: %w", err)
+ }
+
+ sm.wg.Add(1)
+ go sm.dbusListenerLoop()
+
+ return nil
+}
+
+func (sm *DBusSubscriptionManager) createDBusSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 2)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ req.SubscriptionAttributes = map[string]any{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-recipient-uri": "dbus:/",
+ "notify-lease-duration": 86400,
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *DBusSubscriptionManager) dbusListenerLoop() {
+ defer sm.wg.Done()
+
+ signalChan := make(chan *dbus.Signal, 10)
+ sm.conn.Signal(signalChan)
+ defer sm.conn.RemoveSignal(signalChan)
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ case sig := <-signalChan:
+ if sig == nil {
+ continue
+ }
+
+ event := sm.parseDBusSignal(sig)
+ if event.EventName == "" {
+ continue
+ }
+
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+ }
+}
+
+func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) SubscriptionEvent {
+ event := SubscriptionEvent{}
+
+ switch sig.Name {
+ case "org.cups.cupsd.Notifier.JobStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCreated":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-created"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCompleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-completed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterAdded":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-added"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterDeleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-deleted"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+ }
+
+ return event
+}
+
+func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *DBusSubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ }
+
+ if sm.conn != nil {
+ sm.conn.Close()
+ sm.conn = nil
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *DBusSubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go
new file mode 100644
index 0000000..d0203e9
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/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/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go
new file mode 100644
index 0000000..b835028
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/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/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go
new file mode 100644
index 0000000..4820e34
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go
@@ -0,0 +1,13 @@
+package cups
+
+func NewTestManager(client CUPSClientInterface, pkHelper PkHelper) *Manager {
+ return &Manager{
+ client: client,
+ pkHelper: pkHelper,
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go
new file mode 100644
index 0000000..52e9d2c
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go
@@ -0,0 +1,132 @@
+package cups
+
+import (
+ "io"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type CUPSState struct {
+ Printers map[string]*Printer `json:"printers"`
+}
+
+type Printer struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ State string `json:"state"`
+ StateReason string `json:"stateReason"`
+ Location string `json:"location"`
+ Info string `json:"info"`
+ MakeModel string `json:"makeModel"`
+ Accepting bool `json:"accepting"`
+ Jobs []Job `json:"jobs"`
+}
+
+type Job struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ Printer string `json:"printer"`
+ User string `json:"user"`
+ Size int `json:"size"`
+ TimeCreated time.Time `json:"timeCreated"`
+}
+
+type Device struct {
+ URI string `json:"uri"`
+ Class string `json:"class"`
+ Info string `json:"info"`
+ MakeModel string `json:"makeModel"`
+ ID string `json:"id"`
+ Location string `json:"location"`
+ IP string `json:"ip,omitempty"`
+}
+
+type PPD struct {
+ Name string `json:"name"`
+ NaturalLanguage string `json:"naturalLanguage"`
+ MakeModel string `json:"makeModel"`
+ DeviceID string `json:"deviceId"`
+ Product string `json:"product"`
+ PSVersion string `json:"psVersion"`
+ 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"`
+ State string `json:"state"`
+ Members []string `json:"members"`
+ Location string `json:"location"`
+ Info string `json:"info"`
+}
+
+type Manager struct {
+ state *CUPSState
+ client CUPSClientInterface
+ pkHelper PkHelper
+ subscription SubscriptionManagerInterface
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan CUPSState]
+ stopChan chan struct{}
+ eventWG sync.WaitGroup
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *CUPSState
+ baseURL string
+ probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
+}
+
+type SubscriptionManagerInterface interface {
+ Start() error
+ Stop()
+ Events() <-chan SubscriptionEvent
+}
+
+type CUPSClientInterface interface {
+ GetPrinters(attributes []string) (map[string]ipp.Attributes, error)
+ GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]ipp.Attributes, error)
+ CancelJob(jobID int, purge bool) error
+ PausePrinter(printer string) error
+ ResumePrinter(printer string) error
+ CancelAllJob(printer string, purge bool) error
+ SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error)
+
+ GetDevices() (map[string]ipp.Attributes, error)
+ GetPPDs() (map[string]ipp.Attributes, error)
+ GetClasses(attributes []string) (map[string]ipp.Attributes, error)
+ CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error
+ DeletePrinter(printer string) error
+ AcceptJobs(printer string) error
+ RejectJobs(printer string) error
+ SetPrinterIsShared(printer string, shared bool) error
+ SetPrinterLocation(printer, location string) error
+ SetPrinterInformation(printer, information string) error
+ MoveJob(jobID int, destPrinter string) error
+ PrintTestPage(printer string, testPageData io.Reader, size int) (int, error)
+ AddPrinterToClass(class, printer string) error
+ DeletePrinterFromClass(class, printer string) error
+ DeleteClass(class string) error
+ RestartJob(jobID int) error
+ HoldJobUntil(jobID int, holdUntil string) error
+}
+
+type SubscriptionEvent struct {
+ EventName string
+ PrinterName string
+ JobID int
+ SubscribedAt time.Time
+}