diff options
| author | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
|---|---|---|
| committer | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
| commit | 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch) | |
| tree | ab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups | |
| parent | 5d94c0a7d44a2255b81815a52a7056a94a39842d (diff) | |
| download | RaveOS-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')
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, ¤tState) { + 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 +} |