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:
parent
0808cdd781
commit
1d8dbac81a
12 changed files with 457 additions and 133 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
if err2 := launchTrayTask(cmd, user); err2 != nil {
|
||||
return fmt.Errorf("%v; %w", err, err2)
|
||||
}
|
||||
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
|
||||
}
|
||||
fmt.Println("tray started")
|
||||
return nil
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
|
|||
ctrl: ctrl,
|
||||
stamp: stamp,
|
||||
printer: &Printer{
|
||||
Name: cfg.Name,
|
||||
UUID: uuid,
|
||||
Name: cfg.Name,
|
||||
UUID: uuid,
|
||||
AllocateID: st.AllocateID,
|
||||
},
|
||||
}
|
||||
ippLn, port, err := listenIPP(cfg)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
for _, r := range s.all {
|
||||
if r.ID > max {
|
||||
max = r.ID
|
||||
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")
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
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+1 > next {
|
||||
next = r.ID + 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 {
|
||||
|
|
|
|||
18
internal/netprint/tray_launch.go
Normal file
18
internal/netprint/tray_launch.go
Normal 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, " ")
|
||||
}
|
||||
Loading…
Reference in a new issue