Advertise AirPrint subtypes and keep job folders, formats, and the service log consistent.

Windows DnsServiceRegister now registers the Bonjour service-type form and the RFC subtype name so a Mac browse of _ipp._tcp,_universal can see the printer. document-format-supported matches the TXT pdl list, including JPEG. Job ids persist in next-job and job-N directories. The service log is written and flushed even when stderr fails. The tray is started by Task Scheduler or Explorer so setup is not its parent.

Co-authored-by: George Lambert <marchon@gmail.com>
This commit is contained in:
Cursor Agent 2026-10-04 22:16:54 +00:00
commit 1d8dbac81a
No known key found for this signature in database
12 changed files with 457 additions and 133 deletions

View file

@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin
1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login.
2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files.
3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus `_universal` and `_print`) and AirPrint text records. On Windows 10 and later the service registers with `DnsServiceRegister`, so the DNS Client (which already owns UDP 5353) sends the advertisements. If that call fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound.
3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later the service registers the primary name with `DnsServiceRegister`, then registers each subtype again — once as `<name>._ipp._tcp,_universal.local` (the service-type form Windows publishes) and once as `<name>._universal._sub._ipp._tcp.local` — so `dns-sd -B _ipp._tcp,_universal` and iOS can see it. The DNS Client, which already owns UDP 5353, sends the advertisements. If the primary registration fails, the service tries a shared UDP 5353 socket and records the error. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. Get-Printer-Attributes `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG.
4. Print a PDF. Open **Receipts** from the tray, or browse to the printer’s admin URL (`http://<lan-address>:<port>/`). Each job has a receipt PDF and a verify link on `https://explorer.verae.ai/time`.
The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, and whether discovery is advertising. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, open recent receipts, and open settings (printer name, forward-to printer, signing key). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer.
@ -41,7 +41,7 @@ On macOS, the Application Firewall is per application (System Settings → Netwo
The control API listens only on `127.0.0.1:8630`.
A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` only starts the tray; an unknown `netprint` subcommand does not start a second server. The service itself appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log`. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`.
A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint launch-tray` starts the tray through Task Scheduler, or through Explorer if that task cannot be created, and then exits. The tray is not a child of setup, so `Start-Process -Wait` returns. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, and `mdnsError`.
macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary.
@ -51,7 +51,7 @@ macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the packag
- Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. CUPS on the same machine is fine: this printer falls back to port 8632 and still advertises that port. It does not fall back to 8631.
- Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`.
- Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token.
- Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) are converted to PDF on this machine before they are hashed. PDF is accepted as-is.
- Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) and `image/jpeg` are converted to PDF on this machine before they are hashed. PDF is accepted as-is.
Commands:

View file

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net"
"strings"
"time"
)
@ -27,8 +28,18 @@ type dnsSDClient interface {
func serviceToDNS(s Service) []dnsSDInstance {
host := sanitizeLabel(s.Host) + ".local"
base := sanitizeLabel(s.Instance)
// The first name is the primary service. A Mac `dns-sd -B _ipp._tcp` already
// resolves it. AirPrint and `dns-sd -B _ipp._tcp,_universal` query the PTR
// `_universal._sub._ipp._tcp.local`, which has to point at that same instance.
// DnsServiceRegister has no subtype field. Windows accepts the subtype on the
// service type, Bonjour-style ("<instance>._ipp._tcp,_universal.local"), and
// some builds only honor the RFC 6763 instance name
// ("<instance>._universal._sub._ipp._tcp.local"). Register both. A failure of
// either spelling is logged and does not remove the primary advertisement.
names := []string{
base + "._ipp._tcp.local",
base + "._ipp._tcp,_universal.local",
base + "._ipp._tcp,_print.local",
base + "._universal._sub._ipp._tcp.local",
base + "._print._sub._ipp._tcp.local",
}
@ -72,12 +83,24 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie
return fmt.Errorf("DnsServiceRegister registered nothing")
}
if sink != nil {
sink(mdnsReport{
var subtypes []string
for _, inst := range registered[1:] {
subtypes = append(subtypes, inst.InstanceName)
}
detail := fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port)
report := mdnsReport{
Advertising: true,
Mode: "dns-sd",
Detail: fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port),
Detail: detail,
LastSent: time.Now(),
})
}
if len(subtypes) > 0 {
report.Detail = detail + "; subtypes " + strings.Join(subtypes, ", ")
}
if len(registered) < len(insts) {
report.Err = "AirPrint subtype registration incomplete"
}
sink(report)
}
<-ctx.Done()
for _, inst := range registered {

View file

@ -31,6 +31,9 @@ type Printer struct {
UUID string
AdminURL string
OnDocument DocumentFunc
// AllocateID, when set, returns a job id that is never reused after a
// restart. Without it, ids are process-local and start again at 1.
AllocateID func() (int, error)
mu sync.Mutex
started time.Time
@ -38,6 +41,30 @@ type Printer struct {
jobs map[int]*ippJob
}
// acceptedFormats is every document MIME type this printer consumes.
// document-format-supported and the AirPrint TXT pdl record both use it.
var acceptedFormats = []string{
"application/pdf",
"image/urf",
"image/pwg-raster",
"image/jpeg",
}
func pdlValue() string { return strings.Join(acceptedFormats, ",") }
func formatAccepted(f string) bool {
switch f {
case "", "application/octet-stream":
return true
}
for _, a := range acceptedFormats {
if f == a {
return true
}
}
return false
}
type ippJob struct {
id, state int
name, user, format, reasons, message string
@ -111,13 +138,19 @@ func (p *Printer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message {
if st, msg := p.checkFormat(req); st != goipp.StatusOk {
return p.newResp(req, st, msg)
}
j := p.createJob(req)
j, err := p.createJob(req)
if err != nil {
return p.newResp(req, goipp.StatusErrorInternal, err.Error())
}
return p.receive(req, r, j)
case goipp.OpCreateJob:
if st, msg := p.checkFormat(req); st != goipp.StatusOk {
return p.newResp(req, st, msg)
}
j := p.createJob(req)
j, err := p.createJob(req)
if err != nil {
return p.newResp(req, goipp.StatusErrorInternal, err.Error())
}
resp := p.newResp(req, goipp.StatusOk, "")
resp.Job = p.jobAttrs(j, r)
return resp
@ -206,6 +239,8 @@ func sniffFormat(format string, doc []byte) string {
return "image/urf"
case len(doc) >= 4 && (string(doc[:4]) == "RaS2" || string(doc[:4]) == "2SaR"):
return "image/pwg-raster"
case len(doc) >= 3 && doc[0] == 0xff && doc[1] == 0xd8 && doc[2] == 0xff:
return "image/jpeg"
default:
if format == "" {
return "application/octet-stream"
@ -216,23 +251,24 @@ func sniffFormat(format string, doc []byte) string {
func (p *Printer) checkFormat(req *goipp.Message) (goipp.Status, string) {
f := strings.ToLower(strings.TrimSpace(attrString(req.Operation, "document-format")))
switch f {
case "", "application/octet-stream", "application/pdf", "image/urf", "image/pwg-raster":
if formatAccepted(f) {
return goipp.StatusOk, ""
default:
return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f
}
return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f
}
func (p *Printer) createJob(req *goipp.Message) *ippJob {
func (p *Printer) createJob(req *goipp.Message) (*ippJob, error) {
id, err := p.allocJobID()
if err != nil {
return nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
if p.jobs == nil {
p.jobs = map[int]*ippJob{}
}
p.nextID++
j := &ippJob{
id: p.nextID,
id: id,
name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"),
user: attrString(req.Operation, "requesting-user-name"),
format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"),
@ -241,7 +277,17 @@ func (p *Printer) createJob(req *goipp.Message) *ippJob {
created: time.Now(),
}
p.jobs[j.id] = j
return j
return j, nil
}
func (p *Printer) allocJobID() (int, error) {
if p.AllocateID != nil {
return p.AllocateID()
}
p.mu.Lock()
defer p.mu.Unlock()
p.nextID++
return p.nextID, nil
}
func (p *Printer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message {
@ -336,7 +382,7 @@ func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attrib
add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String(admin)))
add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(p.uuid())))
add(goipp.MakeAttribute("printer-dns-sd-name", goipp.TagName, goipp.String(name)))
add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG;CLS:PRINTER;DES:Verae Timestamp;")))
add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG,JPEG;CLS:PRINTER;DES:Verae Timestamp;")))
add(ints("printer-state", goipp.TagEnum, state))
add(kw("printer-state-reasons", "none"))
add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true)))
@ -352,7 +398,7 @@ func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attrib
add(multi("charset-supported", goipp.TagCharset, "utf-8"))
add(multi("natural-language-configured", goipp.TagLanguage, "en"))
add(multi("generated-natural-language-supported", goipp.TagLanguage, "en"))
add(multi("document-format-supported", goipp.TagMimeType, "application/pdf", "image/urf", "image/pwg-raster"))
add(multi("document-format-supported", goipp.TagMimeType, acceptedFormats...))
add(multi("document-format-default", goipp.TagMimeType, "application/pdf"))
add(kw("urf-supported", "V1.4", "W8", "SRGB24", "RS300", "RS600", "DM1"))
add(kw("pdl-override-supported", "attempted"))

View file

@ -9,55 +9,66 @@ import (
"os/exec"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"time"
)
const trayTaskName = "VeraeNetPrintTrayLaunch"
// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user, not as
// the elevated installer. A second call is a no-op when the tray is already running.
// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user.
// The tray is not a child of this process. Task Scheduler (and, if that is
// unavailable, Explorer) starts it, so Start-Process -Wait on setup returns
// when setup exits instead of waiting for the tray.
func LaunchTrayNow() error {
if trayAlreadyRunning() {
fmt.Println("tray started")
return nil
}
cmd, err := trayCommandLine()
spec, err := trayLaunchSpec()
if err != nil {
return err
}
if err := launchAsInteractiveUser(cmd); err != nil {
log.Printf("netprint: tray token launch: %v", err)
user, uerr := activeConsoleUser()
if uerr != nil {
return fmt.Errorf("%v; %w", err, uerr)
var launchErr error
if user, uerr := activeConsoleUser(); uerr != nil {
launchErr = uerr
} else if err := launchTrayTask(spec, user); err != nil {
launchErr = err
log.Printf("netprint: tray scheduled task: %v", err)
} else {
fmt.Println("tray started")
return nil
}
if err2 := launchTrayTask(cmd, user); err2 != nil {
return fmt.Errorf("%v; %w", err, err2)
// Explorer can only launch a program with no arguments. The dedicated tray
// binary needs none. verae-print.exe must be started as `tray` by the task.
if len(spec.args) == 0 && spec.exe != "" {
if err := launchViaExplorer(spec.exe); err != nil {
if launchErr != nil {
return fmt.Errorf("%v; %w", launchErr, err)
}
return err
}
fmt.Println("tray started")
return nil
}
if launchErr != nil {
return launchErr
}
return fmt.Errorf("tray was not started")
}
func trayCommandLine() (string, error) {
func trayLaunchSpec() (trayLaunch, error) {
exe, err := os.Executable()
if err != nil {
return "", err
return trayLaunch{}, err
}
exe, err = filepath.Abs(exe)
if err != nil {
return "", err
return trayLaunch{}, err
}
sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe")
if st, err := os.Stat(sibling); err == nil && !st.IsDir() {
// The tray binary's main is RunTray. It has no path that listens.
return `"` + sibling + `"`, nil
return trayLaunch{exe: sibling}, nil
}
// `verae-print.exe` with no arguments prints usage. The tray subcommand
// is required so this never starts `netprint` / the IPP server.
return `"` + exe + `" tray`, nil
return trayLaunch{exe: exe, args: []string{"tray"}}, nil
}
func trayAlreadyRunning() bool {
@ -103,81 +114,11 @@ func activeConsoleUser() (string, error) {
return "", fmt.Errorf("no interactive user")
}
func launchAsInteractiveUser(cmdline string) error {
hwnd := windows.GetShellWindow()
if hwnd == 0 {
return fmt.Errorf("no shell window")
}
var pid uint32
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil || pid == 0 {
return fmt.Errorf("shell process: %w", err)
}
proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return err
}
defer windows.CloseHandle(proc)
var token windows.Token
if err := windows.OpenProcessToken(proc, windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, &token); err != nil {
return err
}
defer token.Close()
var primary windows.Token
var access uint32 = windows.TOKEN_ASSIGN_PRIMARY | windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID
if err := windows.DuplicateTokenEx(token, access, nil, windows.SecurityImpersonation, windows.TokenPrimary, &primary); err != nil {
return err
}
defer primary.Close()
buf, err := windows.UTF16FromString(cmdline)
if err != nil {
return err
}
// CreateProcessWithTokenW may write into the command line buffer.
cmd := make([]uint16, len(buf)+8)
copy(cmd, buf)
var si windows.StartupInfo
si.Cb = uint32(unsafe.Sizeof(si))
var pi windows.ProcessInformation
if err := createProcessWithToken(primary, &cmd[0], &si, &pi); err != nil {
return err
}
_ = windows.CloseHandle(pi.Process)
_ = windows.CloseHandle(pi.Thread)
return nil
}
var (
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
procCreateProcessWithTokenW = advapi32.NewProc("CreateProcessWithTokenW")
)
func createProcessWithToken(token windows.Token, cmdline *uint16, si *windows.StartupInfo, pi *windows.ProcessInformation) error {
// LOGON_WITH_PROFILE (1) fails from an elevated installer. 0 uses the token as-is.
r, _, callErr := procCreateProcessWithTokenW.Call(
uintptr(token),
0,
0,
uintptr(unsafe.Pointer(cmdline)),
uintptr(windows.CREATE_UNICODE_ENVIRONMENT),
0,
0,
uintptr(unsafe.Pointer(si)),
uintptr(unsafe.Pointer(pi)),
)
if r == 0 {
if callErr != nil && callErr != windows.ERROR_SUCCESS {
return fmt.Errorf("CreateProcessWithTokenW: %w", callErr)
}
return fmt.Errorf("CreateProcessWithTokenW failed")
}
return nil
}
func launchTrayTask(cmdline, user string) error {
func launchTrayTask(spec trayLaunch, user string) error {
_ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run()
out, err := exec.Command("schtasks", "/Create",
"/TN", trayTaskName,
"/TR", cmdline,
"/TR", spec.taskCommand(),
"/SC", "ONCE",
"/ST", "00:00",
"/RL", "LIMITED",
@ -189,9 +130,27 @@ func launchTrayTask(cmdline, user string) error {
return fmt.Errorf("schtasks create: %w (%s)", err, strings.TrimSpace(string(out)))
}
out, err = exec.Command("schtasks", "/Run", "/TN", trayTaskName).CombinedOutput()
_ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run()
if err != nil {
_ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run()
return fmt.Errorf("schtasks run: %w (%s)", err, strings.TrimSpace(string(out)))
}
// /Run returns once Task Scheduler has created the process, which is not a
// child of setup. Give tasklist a moment to see it, then drop the one-shot
// task so it cannot also fire at midnight.
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) && !trayAlreadyRunning() {
time.Sleep(100 * time.Millisecond)
}
_ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run()
return nil
}
func launchViaExplorer(exe string) error {
// explorer.exe starts the program and returns. The tray's parent is
// Explorer, not setup, so the installer job object does not wait on it.
cmd := exec.Command("explorer.exe", exe)
if err := cmd.Start(); err != nil {
return fmt.Errorf("explorer launch: %w", err)
}
return nil
}

View file

@ -36,7 +36,7 @@ type Service struct {
// adminURL should be reachable on the LAN (http://ip:port/).
func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string {
if pdl == "" {
pdl = "application/pdf,image/urf,image/pwg-raster"
pdl = pdlValue()
}
if urf == "" {
urf = "V1.4,W8,SRGB24,RS300,RS600,DM1"
@ -72,7 +72,7 @@ func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string {
"TBCP=F",
"usb_MFG=Verae",
"usb_MDL=Timestamp",
"usb_CMD=PDF,URF,PWG",
"usb_CMD=PDF,URF,PWG,JPEG",
}
}

View file

@ -96,7 +96,7 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
})
}()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && api.count() < 3 {
for time.Now().Before(deadline) && api.count() < 5 {
time.Sleep(10 * time.Millisecond)
}
cancel()
@ -122,6 +122,8 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
}
want := []string{
"Verae Timestamp._ipp._tcp.local",
"Verae Timestamp._ipp._tcp,_universal.local",
"Verae Timestamp._ipp._tcp,_print.local",
"Verae Timestamp._universal._sub._ipp._tcp.local",
"Verae Timestamp._print._sub._ipp._tcp.local",
}
@ -131,12 +133,15 @@ func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) {
}
api.mu.Lock()
defer api.mu.Unlock()
if len(api.registered) != 3 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" {
if len(api.registered) != 5 || api.registered[0].Port != 631 || api.registered[0].Host != "host.local" {
t.Fatalf("instance %+v", api.registered)
}
if len(api.deregistered) != 3 {
if len(api.deregistered) != 5 {
t.Fatalf("deregistered %d", len(api.deregistered))
}
if !strings.Contains(report.Detail, "_ipp._tcp,_universal") {
t.Fatalf("detail missing universal subtype %q", report.Detail)
}
}
func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) {

View file

@ -5,6 +5,9 @@ import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"image"
"image/jpeg"
"io"
"net"
"net/http"
@ -185,7 +188,21 @@ func TestIPPEverywhereAttributesAndPrint(t *testing.T) {
if goipp.Status(gpa.Code) != goipp.StatusOk {
t.Fatalf("get-printer-attributes %v", goipp.Status(gpa.Code))
}
for _, kw := range []string{"application/pdf", "image/urf", "image/pwg-raster"} {
var formats []string
attrs := 0
for _, a := range gpa.Printer {
if a.Name != "document-format-supported" {
continue
}
attrs++
for _, v := range a.Values {
formats = append(formats, v.V.String())
}
}
if attrs != 1 || strings.Join(formats, ",") != pdlValue() {
t.Fatalf("document-format-supported attrs=%d values=%v", attrs, formats)
}
for _, kw := range acceptedFormats {
if !hasKeyword(gpa.Printer, "document-format-supported", kw) {
t.Fatalf("missing document-format %s", kw)
}
@ -360,7 +377,7 @@ func TestRasterRoundTripAndMenu(t *testing.T) {
func TestMDNSAirPrintReply(t *testing.T) {
txt := AirPrintTXT("Verae Timestamp", "abc-def", "http://192.0.2.10:631/", "", "")
joined := strings.Join(txt, "\n")
for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=application/pdf,image/urf,image/pwg-raster", "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/"} {
for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=" + pdlValue(), "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/", "usb_CMD=PDF,URF,PWG,JPEG"} {
if !strings.Contains(joined, k) {
t.Fatalf("txt missing %s\n%s", k, joined)
}
@ -401,6 +418,124 @@ func TestMachineToken(t *testing.T) {
}
}
func TestJobIDsPersistAndDoNotReuseFolders(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "job-1"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "job-1", "document-3.pdf"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "jobs.json"), []byte("[{\"id\":3,\"state\":\"completed\"}]\n"), 0o600); err != nil {
t.Fatal(err)
}
st, err := OpenStore(dir)
if err != nil {
t.Fatal(err)
}
if st.NextID() != 4 {
t.Fatalf("next %d", st.NextID())
}
id, err := st.AllocateID()
if err != nil || id != 4 {
t.Fatalf("id %d err %v", id, err)
}
st2, err := OpenStore(dir)
if err != nil {
t.Fatal(err)
}
id2, err := st2.AllocateID()
if err != nil || id2 != 5 {
t.Fatalf("restarted id %d err %v", id2, err)
}
req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpCreateJob, 1)
req.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("third")))
p := &Printer{AllocateID: st2.AllocateID}
j, err := p.createJob(req)
if err != nil || j == nil || j.id != 6 {
t.Fatalf("job %+v err %v", j, err)
}
old, err := os.ReadFile(filepath.Join(dir, "job-1", "document-3.pdf"))
if err != nil || string(old) != "old" {
t.Fatalf("job-1 was reused: %q %v", old, err)
}
entries, err := os.ReadDir(filepath.Join(dir, "job-1"))
if err != nil || len(entries) != 1 || entries[0].Name() != "document-3.pdf" {
t.Fatalf("job-1 entries %v %v", entries, err)
}
}
func TestJPEGAcceptedAndConverted(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
if got := sniffFormat("application/octet-stream", buf.Bytes()); got != "image/jpeg" {
t.Fatalf("sniff %s", got)
}
pdf, err := documentPDF("image/jpeg", buf.Bytes())
if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) {
t.Fatalf("pdf %v", err)
}
req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpValidateJob, 1)
req.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("image/jpeg")))
if st, msg := (&Printer{}).checkFormat(req); st != goipp.StatusOk {
t.Fatalf("%v %s", st, msg)
}
if joined := strings.Join(acceptedFormats, ","); !strings.Contains(pdlValue(), "image/jpeg") || pdlValue() != joined {
t.Fatalf("pdl %s", pdlValue())
}
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) { return 0, errors.New("stderr closed") }
func TestServiceLogWriteSurvivesBrokenStderr(t *testing.T) {
dir := t.TempDir()
f, err := os.Create(filepath.Join(dir, "netprint.log"))
if err != nil {
t.Fatal(err)
}
w := &serviceLogWriter{file: f, extra: failWriter{}}
if _, err := w.Write([]byte("netprint: starting\n")); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, "netprint.log"))
if err != nil {
t.Fatal(err)
}
if string(b) != "netprint: starting\n" {
t.Fatalf("log %q", b)
}
}
func TestTrayLaunchIsDetachedFromSetup(t *testing.T) {
b, err := os.ReadFile("launch_tray_windows.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
if strings.Contains(src, "CreateProcessWithTokenW") {
t.Fatal("tray launch must not be a child of setup")
}
if !strings.Contains(src, "schtasks") || !strings.Contains(src, "explorer.exe") {
t.Fatal("tray launch should use a scheduled task or Explorer")
}
spec := trayLaunch{exe: `C:\Program Files\Verae\NetPrint\bin\verae-netprint-tray.exe`}
if spec.taskCommand() != spec.exe {
t.Fatalf("task command %q", spec.taskCommand())
}
withArgs := trayLaunch{exe: `C:\Program Files\Verae\PrintStamp\bin\verae-print.exe`, args: []string{"tray"}}
if withArgs.taskCommand() != `"C:\Program Files\Verae\PrintStamp\bin\verae-print.exe" tray` {
t.Fatalf("task command %q", withArgs.taskCommand())
}
}
func TestOwnersRefcount(t *testing.T) {
dir := t.TempDir()
// ownersPath is platform-fixed; exercise the JSON helpers by writing the file they read

View file

@ -1,8 +1,12 @@
package netprint
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/jpeg"
"log"
"os"
"path/filepath"
@ -32,7 +36,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s
rec := Record{
ID: id, Received: time.Now(), User: user, Title: title, Format: format, State: "processing",
}
_ = st.Put(rec)
if err := st.Put(rec); err != nil {
log.Printf("netprint: job %d index: %v", id, err)
}
if p != nil {
p.SetState(id, jobProcessing, "job-printing", "")
}
@ -42,7 +48,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s
rec.State = "aborted"
rec.Error = err.Error()
rec.Completed = time.Now()
_ = st.Put(rec)
if putErr := st.Put(rec); putErr != nil {
log.Printf("netprint: job %d index: %v", id, putErr)
}
if p != nil {
p.SetState(id, jobAborted, "aborted-by-system", err.Error())
}
@ -50,7 +58,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s
if p != nil && p.Canceled(id) {
rec.State = "canceled"
rec.Completed = time.Now()
_ = st.Put(rec)
if putErr := st.Put(rec); putErr != nil {
log.Printf("netprint: job %d index: %v", id, putErr)
}
return
}
pdf, err := documentPDF(format, doc)
@ -101,7 +111,9 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s
rec.Notified = maybeEmail(user, rec)
rec.State = "completed"
rec.Completed = time.Now()
_ = st.Put(rec)
if err := st.Put(rec); err != nil {
log.Printf("netprint: job %d index: %v", id, err)
}
log.Printf("netprint: job %d completed receipt=%s sha256=%s", id, rec.ReceiptID, rec.SHA256)
if p != nil {
p.SetState(id, jobCompleted, "job-completed-successfully", "")
@ -119,11 +131,38 @@ func documentPDF(format string, doc []byte) ([]byte, error) {
return nil, err
}
return PagesToPDF(pages)
case "image/jpeg":
pages, err := jpegToPages(doc)
if err != nil {
return nil, err
}
return PagesToPDF(pages)
default:
return nil, fmt.Errorf("unsupported document format %s", f)
}
}
func jpegToPages(doc []byte) ([]Page, error) {
img, err := jpeg.Decode(bytes.NewReader(doc))
if err != nil {
return nil, fmt.Errorf("jpeg: %w", err)
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w < 1 || h < 1 || w > 20000 || h > 20000 || w*h > maxPagePixels {
return nil, fmt.Errorf("jpeg: dimensions %dx%d", w, h)
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
rgb := make([]byte, w*h*3)
for i, j := 0, 0; i < len(dst.Pix); i, j = i+4, j+3 {
rgb[j] = dst.Pix[i]
rgb[j+1] = dst.Pix[i+1]
rgb[j+2] = dst.Pix[i+2]
}
return []Page{{Width: w, Height: h, DPI: 300, RGB: rgb}}, nil
}
func annotateUser(path, user string) {
b, err := os.ReadFile(path)
if err != nil {

View file

@ -77,6 +77,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
printer: &Printer{
Name: cfg.Name,
UUID: uuid,
AllocateID: st.AllocateID,
},
}
ippLn, port, err := listenIPP(cfg)

View file

@ -7,6 +7,33 @@ import (
"path/filepath"
)
// serviceLogWriter appends each line to the log file and flushes it.
// A Windows service has no console, so os.Stderr.Write fails. io.MultiWriter
// stops at that first error and never reaches the file, which left
// netprint.log at 0 bytes. The file is written first; stderr is best-effort.
type serviceLogWriter struct {
file *os.File
extra io.Writer
}
func (w *serviceLogWriter) Write(p []byte) (int, error) {
if w == nil || w.file == nil {
return 0, os.ErrInvalid
}
n, err := w.file.Write(p)
if err != nil {
return n, err
}
if n != len(p) {
return n, io.ErrShortWrite
}
_ = w.file.Sync()
if w.extra != nil {
_, _ = w.extra.Write(p)
}
return n, nil
}
// setupServiceLog appends the service log under %ProgramData%\Verae\logs on Windows.
// VERAE_LOG_DIR overrides the directory (used by tests).
func setupServiceLog() {
@ -22,7 +49,7 @@ func setupServiceLog() {
return
}
log.SetFlags(log.Ldate | log.Ltime)
log.SetOutput(io.MultiWriter(os.Stderr, f))
log.SetOutput(&serviceLogWriter{file: f, extra: os.Stderr})
log.Printf("netprint: log file %s", f.Name())
}

View file

@ -5,6 +5,8 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
@ -82,16 +84,85 @@ func (s *Store) Get(id int) (Record, bool) {
return Record{}, false
}
func (s *Store) seqPath() string { return filepath.Join(s.dir, "next-job") }
// NextID is the next job id that AllocateID would return, without reserving it.
func (s *Store) NextID() int {
s.mu.Lock()
defer s.mu.Unlock()
max := 0
return s.highWaterLocked()
}
// AllocateID reserves a job id and its job-N directory. The counter and the
// directory both survive a process restart, so a later job cannot land in an
// earlier job-N folder.
func (s *Store) AllocateID() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
next := s.highWaterLocked()
for {
dir := filepath.Join(s.dir, fmt.Sprintf("job-%d", next))
err := os.Mkdir(dir, 0o700)
if err == nil {
if werr := s.writeSeq(next + 1); werr != nil {
return 0, werr
}
return next, nil
}
if !os.IsExist(err) {
return 0, err
}
next++
if next > 1_000_000_000 {
return 0, fmt.Errorf("job id space exhausted")
}
}
}
func (s *Store) highWaterLocked() int {
next := 1
if n, err := s.readSeq(); err == nil && n > next {
next = n
}
for _, r := range s.all {
if r.ID > max {
max = r.ID
if r.ID+1 > next {
next = r.ID + 1
}
}
return max + 1
entries, err := os.ReadDir(s.dir)
if err != nil {
return next
}
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "job-") {
continue
}
n, err := strconv.Atoi(name[len("job-"):])
if err != nil || n < 1 {
continue
}
if n+1 > next {
next = n + 1
}
}
return next
}
func (s *Store) readSeq() (int, error) {
b, err := os.ReadFile(s.seqPath())
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(b)))
}
func (s *Store) writeSeq(n int) error {
tmp := s.seqPath() + ".tmp"
if err := os.WriteFile(tmp, []byte(strconv.Itoa(n)+"\n"), 0o600); err != nil {
return err
}
return os.Rename(tmp, s.seqPath())
}
func (s *Store) Put(r Record) error {

View file

@ -0,0 +1,18 @@
package netprint
import "strings"
// trayLaunch is the program to start. args is empty for verae-netprint-tray.exe,
// whose main is RunTray and which never listens. The verae-print.exe fallback
// must pass "tray" so it does not start the IPP server.
type trayLaunch struct {
exe string
args []string
}
func (t trayLaunch) taskCommand() string {
if len(t.args) == 0 {
return t.exe
}
return `"` + t.exe + `" ` + strings.Join(t.args, " ")
}