windows-debug: IPP loopback printer helper, Print-to-PDF fallback, debug installer, diag script

This commit is contained in:
Grok Bot (for George) 2026-10-03 18:49:56 -04:00
commit dc37fa76dc
21 changed files with 1843 additions and 29 deletions

View file

@ -0,0 +1,12 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
func hideWindow(c *exec.Cmd) {
c.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
}

View file

@ -6,14 +6,20 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
) )
func main() { func main() {
// verae-printhelper.exe (GUI subsystem, no console) defaults to "serve".
if len(os.Args) < 2 && isHelperExe() {
os.Args = append(os.Args, "serve")
}
if len(os.Args) < 2 { if len(os.Args) < 2 {
usage() usage()
os.Exit(2) os.Exit(2)
@ -21,6 +27,10 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "stamp": case "stamp":
os.Exit(runStamp(os.Args[2:])) os.Exit(runStamp(os.Args[2:]))
case "serve":
os.Exit(runServe(os.Args[2:]))
case "ipp-selftest":
os.Exit(runIPPSelftest(os.Args[2:]))
case "setup", "login": case "setup", "login":
os.Exit(runSetup()) os.Exit(runSetup())
case "install-macos": case "install-macos":
@ -29,6 +39,9 @@ func main() {
os.Exit(runInstallPrinter()) os.Exit(runInstallPrinter())
case "version", "-version", "--version": case "version", "-version", "--version":
fmt.Println(job.HelperVersion) fmt.Println(job.HelperVersion)
if len(os.Args) > 2 {
fmt.Println("logs:", vlog.Dir())
}
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
default: default:
@ -50,8 +63,13 @@ Usage:
verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] <file.pdf> verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] <file.pdf>
verae-print install-printer # Destination list: Verae Timestamping (recommended) verae-print install-printer # Destination list: Verae Timestamping (recommended)
verae-print install-macos # also install PDF Services menu items (legacy) verae-print install-macos # also install PDF Services menu items (legacy)
verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug]
# Windows print helper: IPP loopback printer + fallback watcher
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
verae-print version verae-print version
Debug: set VERAE_DEBUG=1 (or --debug) for full IPP request dumps. Logs: see "verae-print version -v".
First install opens https://customer.verae.ai/portal/ so you can create an account First install opens https://customer.verae.ai/portal/ so you can create an account
and mint an xVerae- signing key (Security tab). The key is stored in Keychain. and mint an xVerae- signing key (Security tab). The key is stored in Keychain.
@ -87,12 +105,20 @@ func runStamp(args []string) int {
name := fs.String("name", "", "document stem override") name := fs.String("name", "", "document stem override")
openFolder := fs.Bool("open", false, "open output folder on success (macOS)") openFolder := fs.Bool("open", false, "open output folder on success (macOS)")
skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog") skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog")
jobFlag := fs.String("job-id", "", "debug job id (for log correlation)")
_ = fs.Parse(args) _ = fs.Parse(args)
if fs.NArg() < 1 { if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "stamp: PDF path required") fmt.Fprintln(os.Stderr, "stamp: PDF path required")
return 2 return 2
} }
pdfPath := fs.Arg(0) pdfPath := fs.Arg(0)
jobID := *jobFlag
if jobID == "" {
jobID = vlog.NewJobID("cli")
}
vlog.Init("cli", false)
lj := vlog.Job(jobID)
lj.Printf("stamp start pdf=%q channel=%s out=%q name=%q", pdfPath, *channel, *outDir, *name)
ch, err := auth.ParseChannel(*channel) ch, err := auth.ParseChannel(*channel)
if err != nil { if err != nil {
@ -139,15 +165,17 @@ func runStamp(args []string) int {
Client: client, Client: client,
}) })
if err != nil { if err != nil {
lj.Errorf("stamp failed: %v", err)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err) fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
return 1 return 1
} }
lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF)
fmt.Printf("Saved:\n %s\n %s\n %s\nReceipt: %s\nSHA-256: %s\nChannel: %s\n", fmt.Printf("Saved:\n %s\n %s\n %s\nReceipt: %s\nSHA-256: %s\nChannel: %s\n",
res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel) res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel)
if *openFolder { if *openFolder {
_ = exec.Command("open", filepath.Dir(res.OriginalPDF)).Run() openFolderPath(filepath.Dir(res.OriginalPDF))
} }
// macOS notification (best-effort) // macOS notification (best-effort)
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
@ -155,6 +183,9 @@ func runStamp(args []string) int {
} }
func runInstallPrinter() int { func runInstallPrinter() int {
if runtime.GOOS == "windows" {
return runInstallPrinterWindows()
}
script := filepath.Join(repoRootHint(), "platform", "macos", "ipp", "install-ipp-printer.sh") script := filepath.Join(repoRootHint(), "platform", "macos", "ipp", "install-ipp-printer.sh")
if _, err := os.Stat(script); err != nil { if _, err := os.Stat(script); err != nil {
// fall back next to executable // fall back next to executable
@ -276,11 +307,6 @@ fi
return 0 return 0
} }
func notify(title, subtitle string) {
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
_ = exec.Command("osascript", "-e", script).Run()
}
func envOr(k, def string) string { func envOr(k, def string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v

View file

@ -0,0 +1,38 @@
//go:build !windows
package main
import (
"fmt"
"os/exec"
"runtime"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" {
return
}
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
_ = exec.Command("osascript", "-e", script).Run()
}
func notifyUser(title, body string) {
vlog.Printf("notify: %s — %s", title, body)
notify(title, body)
}
func openFolderPath(p string) {
if runtime.GOOS == "darwin" {
_ = exec.Command("open", p).Run()
return
}
_ = exec.Command("xdg-open", p).Start()
}
func launchSetupUI() { vlog.Printf("setup: no signing key — run: verae-print setup") }
func runInstallPrinterWindows() int { return 1 }
func debugFromRegistry() bool { return false }

View file

@ -0,0 +1,62 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
func exeDir() string {
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
// notifyUser: best-effort balloon via PowerShell (no extra deps). Always logs.
func notifyUser(title, body string) {
vlog.Printf("notify: %s — %s", title, body)
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
return
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info);Start-Sleep 9;$n.Dispose()`, esc(title), esc(body))
cmd := exec.Command("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
hideWindow(cmd)
_ = cmd.Start()
}
func notify(title, subtitle string) { notifyUser(title, subtitle) }
func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() }
// launchSetupUI opens a console running "verae-print setup" for the user.
func launchSetupUI() {
cli := filepath.Join(exeDir(), "verae-print.exe")
vlog.Printf("setup: no signing key — launching %q setup in a new console", cli)
_ = exec.Command("cmd.exe", "/c", "start", "Verae Print Stamp setup", cli, "setup").Start()
}
func runInstallPrinterWindows() int {
script := filepath.Join(exeDir(), "..", "scripts", "install-printer.ps1")
ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
cmd := exec.Command(ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "install-printer failed: %v (run elevated; see %s)\n", err, vlog.Dir())
return 1
}
return 0
}
func debugFromRegistry() bool {
out, err := exec.Command("reg.exe", "query", `HKLM\Software\Verae\PrintStamp`, "/v", "Debug").Output()
return err == nil && strings.Contains(string(out), "0x1")
}

244
cmd/verae-print/serve.go Normal file
View file

@ -0,0 +1,244 @@
package main
import (
"bytes"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/OpenPrinting/goipp"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
)
func isHelperExe() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(filepath.Base(exe)), "printhelper")
}
// fallbackDropDir is the folder the "Verae TimeStamp PDF (Local Port)" queue writes into.
func fallbackDropDir() string {
if v := os.Getenv("VERAE_FALLBACK_DIR"); v != "" {
return v
}
if runtime.GOOS == "windows" {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
return filepath.Join(pd, "Verae", "spool", "fallback")
}
return filepath.Join(os.TempDir(), "verae-fallback")
}
func userWorkDir() string {
base, err := os.UserCacheDir() // %LOCALAPPDATA% on Windows
if err != nil {
base = os.TempDir()
}
return filepath.Join(base, "Verae", "PrintStamp", "spool")
}
func runServe(args []string) int {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", envOr("VERAE_IPP_ADDR", "127.0.0.1:8631"), "IPP listen address")
noIPP := fs.Bool("no-ipp", false, "disable IPP loopback printer")
noWatch := fs.Bool("no-watch", false, "disable fallback folder watcher")
debug := fs.Bool("debug", false, "verbose logging (also VERAE_DEBUG=1)")
outDir := fs.String("out-dir", "", "output dir (default Documents\\Verae Timestamps)")
_ = fs.Parse(args)
vlog.SetDebug(*debug || debugFromRegistry())
logPath := vlog.Init("helper", !isHelperExe())
host, _ := os.Hostname()
home, _ := os.UserHomeDir()
exe, _ := os.Executable()
vlog.Printf("==== verae-print helper %s start exe=%q os=%s/%s host=%s home=%q log=%q debug=%v",
job.HelperVersion, exe, runtime.GOOS, runtime.GOARCH, host, home, logPath, vlog.DebugEnabled())
logAuthState()
proc := makeProcessor(*outDir)
errc := make(chan error, 2)
if !*noIPP {
srv := &winprint.IPPServer{Addr: *addr, Name: "Verae TimeStamp PDF", SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc}
go func() { errc <- srv.ListenAndServe() }()
}
if !*noWatch {
w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc}
go w.Run()
}
if *noIPP {
select {}
}
err := <-errc
vlog.Errorf("helper exiting: %v", err)
fmt.Fprintln(os.Stderr, err)
return 1
}
func logAuthState() {
_, xerr := auth.LoadXVeraeToken()
_, jerr := auth.LoadVeraeJWT()
vlog.Printf("auth: preferred=%s xVerae-token=%v jwt(auth.properties)=%v", auth.PreferredChannel(), errState(xerr), errState(jerr))
}
func errState(err error) string {
if err == nil {
return "present"
}
return "missing (" + err.Error() + ")"
}
var setupOnce sync.Once
func makeProcessor(outDir string) winprint.Processor {
return func(j winprint.Job) error {
lj := vlog.Job(j.ID)
lj.Printf("process: source=%s title=%q user=%q format=%s bytes=%d path=%q", j.Source, j.Title, j.User, j.Format, j.Bytes, j.Path)
if !strings.Contains(j.Format, "pdf") && sniffPDF(j.Path) == false {
return fmt.Errorf("received %s (not PDF); raster→PDF conversion not implemented in this debug build — file kept at %s. Re-test with VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path)
}
ch := auth.PreferredChannel()
client := &veraeapi.Client{Channel: ch}
switch ch {
case auth.ChannelZapier:
tok, err := auth.LoadXVeraeToken()
if err != nil {
if jwt, jerr := auth.LoadVeraeJWT(); jerr == nil {
lj.Printf("process: no xVerae- token, falling back to verae channel (auth.properties)")
ch, client.Channel, client.VeraeToken = auth.ChannelVerae, auth.ChannelVerae, jwt
break
}
setupOnce.Do(launchSetupUI)
return fmt.Errorf("no signing key: %v — run 'verae-print setup' then reprint (PDF kept at %s)", err, j.Path)
}
client.XVeraeToken = tok
default:
tok, err := auth.LoadVeraeJWT()
if err != nil {
return fmt.Errorf("verae channel: %v", err)
}
client.VeraeToken = tok
}
client.HTTPDo = loggingDo(lj)
name := safeStem(j.Title)
start := time.Now()
res, err := job.Run(job.Options{SourcePDF: j.Path, OutDir: outDir, DocumentName: name + ".pdf", Channel: ch, Client: client})
if err != nil {
notifyUser("Verae timestamp FAILED", err.Error())
return err
}
lj.Printf("process: OK in %s receipt=%s sha256=%s\n original=%q\n cert=%q\n json=%q",
time.Since(start).Round(time.Millisecond), res.ReceiptID, res.SHA256, res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON)
_ = os.Remove(j.Path)
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
return nil
}
}
func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
start := time.Now()
res, err := http.DefaultClient.Do(r)
if err != nil {
lj.Errorf("api: %s %s → error after %s: %v", r.Method, r.URL.Redacted(), time.Since(start).Round(time.Millisecond), err)
return res, err
}
lj.Printf("api: %s %s → HTTP %d in %s", r.Method, r.URL.Redacted(), res.StatusCode, time.Since(start).Round(time.Millisecond))
return res, nil
}
}
func sniffPDF(p string) bool {
f, err := os.Open(p)
if err != nil {
return false
}
defer f.Close()
b := make([]byte, 5)
n, _ := f.Read(b)
return n == 5 && string(b) == "%PDF-"
}
func safeStem(title string) string {
t := filepath.Base(strings.ReplaceAll(title, `\`, "/"))
if ext := filepath.Ext(t); ext != "" && len(ext) <= 5 {
t = strings.TrimSuffix(t, ext)
}
var b strings.Builder
for _, r := range t {
switch {
case r == ' ' || r == '-' || r == '_' || r == '.' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
b.WriteRune(r)
}
if b.Len() >= 80 {
break
}
}
s := strings.TrimSpace(b.String())
if s == "" {
s = "document-" + time.Now().Format("20060102-150405")
}
return s
}
// runIPPSelftest sends Get-Printer-Attributes + Validate-Job to the helper.
func runIPPSelftest(args []string) int {
fs := flag.NewFlagSet("ipp-selftest", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:8631", "helper address")
_ = fs.Parse(args)
uri := "ipp://" + *addr + "/ipp/print"
ok := true
for _, op := range []goipp.Op{goipp.OpGetPrinterAttributes, goipp.OpValidateJob} {
m := goipp.NewRequest(goipp.DefaultVersion, op, 1)
m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8")))
m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en")))
m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String(uri)))
if op == goipp.OpValidateJob {
m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf")))
}
b, _ := m.EncodeBytes()
res, err := http.Post("http://"+*addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b))
if err != nil {
fmt.Printf("FAIL %s: %v\n", op, err)
return 1
}
var out goipp.Message
err = out.Decode(res.Body)
res.Body.Close()
if err != nil {
fmt.Printf("FAIL %s: decode: %v\n", op, err)
return 1
}
st := goipp.Status(out.Code)
fmt.Printf("%s → %s (%d printer attrs)\n", op, st, len(out.Printer))
if st != goipp.StatusOk {
ok = false
}
if op == goipp.OpGetPrinterAttributes {
for _, a := range out.Printer {
if a.Name == "document-format-supported" || a.Name == "printer-name" || a.Name == "printer-uri-supported" {
fmt.Printf(" %s = %s\n", a.Name, a.Values)
}
}
}
}
if !ok {
return 1
}
fmt.Println("IPP helper OK:", uri)
return 0
}

1
go.mod
View file

@ -3,6 +3,7 @@ module github.com/VeraeLLC/PrintToVeraeTimeStamp
go 1.26.1 go 1.26.1
require ( require (
github.com/OpenPrinting/goipp v1.2.0 // indirect
github.com/phpdave11/gofpdf v1.4.2 // indirect github.com/phpdave11/gofpdf v1.4.2 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
) )

2
go.sum
View file

@ -1,3 +1,5 @@
github.com/OpenPrinting/goipp v1.2.0 h1:qeB3GyhhB7NM16quwyl51CsTEHFb9chZXAprt+00NKo=
github.com/OpenPrinting/goipp v1.2.0/go.mod h1:ot2iw+QF7fVLaX+55JUNlF5YSDNiXVo2LRAv21iGcQI=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
@ -83,6 +84,9 @@ func LoadXVeraeToken() (string, error) {
} }
} }
// macOS Keychain (generic password, account optional) // macOS Keychain (generic password, account optional)
if runtime.GOOS != "darwin" {
return "", fmt.Errorf("no xVerae- token (set XVERAE_TOKEN or create %s)", filepath.Join(home, ".verae", "xverae-token"))
}
out, err := exec.Command("security", "find-generic-password", "-s", "verae.xVeraeToken", "-w").Output() out, err := exec.Command("security", "find-generic-password", "-s", "verae.xVeraeToken", "-w").Output()
if err == nil { if err == nil {
t := strings.TrimSpace(string(out)) t := strings.TrimSpace(string(out))
@ -144,6 +148,9 @@ func SaveXVeraeToken(token string) error {
return err return err
} }
// Keychain (best-effort): delete old, add new // Keychain (best-effort): delete old, add new
if runtime.GOOS != "darwin" {
return nil
}
account := os.Getenv("USER") account := os.Getenv("USER")
if account == "" { if account == "" {
account = "verae" account = "verae"

144
internal/vlog/vlog.go Normal file
View file

@ -0,0 +1,144 @@
// Package vlog is the verbose debug logger used by the CLI, the print helper
// and the IPP/watch servers. On Windows logs go to %ProgramData%\Verae\logs
// (shared, ACL'd by the installer so Users can write); elsewhere to the user
// cache dir. Every print job carries a job ID so one job can be followed from
// IPP receive → save → hash → API → certificate.
package vlog
import (
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
var (
mu sync.Mutex
out io.Writer = os.Stderr
file *os.File
comp = "verae"
jobSeq uint64
debugOn = os.Getenv("VERAE_DEBUG") == "1"
)
// Dir returns the log directory for this platform.
func Dir() string {
if runtime.GOOS == "windows" {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
return filepath.Join(pd, "Verae", "logs")
}
if d, err := os.UserCacheDir(); err == nil {
return filepath.Join(d, "Verae", "logs")
}
return filepath.Join(os.TempDir(), "verae-logs")
}
func userTag() string {
if u, err := user.Current(); err == nil {
n := u.Username
if i := strings.LastIndexAny(n, `\/`); i >= 0 {
n = n[i+1:]
}
return sanitize(n)
}
return "unknown"
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if r < 128 && (r == '-' || r == '_' || r == '.' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "x"
}
return b.String()
}
// Init opens <Dir>/<component>-<user>.log (append). alsoStderr tees output.
// Falls back to %TEMP% if the shared dir is not writable. Returns the path.
func Init(component string, alsoStderr bool) string {
mu.Lock()
defer mu.Unlock()
comp = component
name := fmt.Sprintf("%s-%s.log", component, userTag())
var path string
for _, d := range []string{Dir(), filepath.Join(os.TempDir(), "Verae", "logs")} {
if err := os.MkdirAll(d, 0o775); err != nil {
continue
}
p := filepath.Join(d, name)
f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o664)
if err != nil {
continue
}
file, path = f, p
break
}
switch {
case file != nil && alsoStderr:
out = io.MultiWriter(file, os.Stderr)
case file != nil:
out = file
default:
out = os.Stderr
}
return path
}
// SetDebug enables verbose (Debugf) output.
func SetDebug(on bool) { debugOn = on || debugOn }
// DebugEnabled reports whether verbose output is on.
func DebugEnabled() bool { return debugOn }
func write(level, job, msg string) {
mu.Lock()
defer mu.Unlock()
ts := time.Now().Format("2006-01-02T15:04:05.000-07:00")
if job == "" {
job = "-"
}
fmt.Fprintf(out, "%s %s %s pid=%d job=%s %s\n", ts, level, comp, os.Getpid(), job, strings.TrimRight(msg, "\n"))
}
// Printf logs at INFO with no job.
func Printf(format string, a ...any) { write("INFO", "", fmt.Sprintf(format, a...)) }
// Errorf logs at ERROR with no job.
func Errorf(format string, a ...any) { write("ERROR", "", fmt.Sprintf(format, a...)) }
// Debugf logs at DEBUG when enabled.
func Debugf(format string, a ...any) {
if debugOn {
write("DEBUG", "", fmt.Sprintf(format, a...))
}
}
// NewJobID returns J<yyyymmdd-hhmmss>-<src>-<n>.
func NewJobID(src string) string {
n := atomic.AddUint64(&jobSeq, 1)
return fmt.Sprintf("J%s-%s-%d", time.Now().Format("20060102-150405"), sanitize(src), n)
}
// Job is a logger bound to a job ID.
type Job string
func (j Job) Printf(format string, a ...any) { write("INFO", string(j), fmt.Sprintf(format, a...)) }
func (j Job) Errorf(format string, a ...any) { write("ERROR", string(j), fmt.Sprintf(format, a...)) }
func (j Job) Debugf(format string, a ...any) {
if debugOn {
write("DEBUG", string(j), fmt.Sprintf(format, a...))
}
}

615
internal/winprint/ipp.go Normal file
View file

@ -0,0 +1,615 @@
// Package winprint implements the Windows "Verae TimeStamp PDF" virtual
// printer receivers: a minimal IPP Everywhere loopback server (primary path,
// used with the inbox Microsoft IPP Class Driver) and a folder watcher for the
// Microsoft Print To PDF + Local file port fallback. Pure Go, no cgo, so it
// cross-compiles from macOS and is unit-testable there.
package winprint
import (
"bytes"
"crypto/sha1"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/OpenPrinting/goipp"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// Job is a captured print job handed to the processor.
type Job struct {
ID string // vlog job id
IPPJobID int
Source string // "ipp" or "watch"
Title string
User string
Format string
Path string // spooled document on disk
Bytes int64
Received time.Time
}
// Processor turns a captured job into a stamp. Return error to mark aborted.
type Processor func(Job) error
// IPPServer is a tiny IPP/2.0 printer.
type IPPServer struct {
Addr string // e.g. 127.0.0.1:8631
Name string // printer-name / info
SpoolDir string // where documents are written
Formats []string // document-format-supported
Process Processor
started time.Time
mu sync.Mutex
nextID int
jobs map[int]*ippJob
queue chan Job
}
type ippJob struct {
id int
logID string
name string
user string
format string
state int // 3 pending,5 processing,7 canceled,8 aborted,9 completed
reasons string
message string
created time.Time
done time.Time
}
const (
jsPending = 3
jsProcessing = 5
jsCanceled = 7
jsAborted = 8
jsCompleted = 9
)
// DefaultFormats are advertised unless VERAE_IPP_FORMATS overrides.
var DefaultFormats = []string{"application/pdf", "image/pwg-raster"}
// ListenAndServe binds and serves until error. Jobs are processed serially.
func (s *IPPServer) ListenAndServe() error {
if s.Addr == "" {
s.Addr = "127.0.0.1:8631"
}
if s.Name == "" {
s.Name = "Verae TimeStamp PDF"
}
if len(s.Formats) == 0 {
s.Formats = DefaultFormats
if env := strings.TrimSpace(os.Getenv("VERAE_IPP_FORMATS")); env != "" {
s.Formats = strings.Split(env, ",")
}
}
if err := os.MkdirAll(s.SpoolDir, 0o700); err != nil {
return fmt.Errorf("spool dir: %w", err)
}
s.started = time.Now()
s.jobs = map[int]*ippJob{}
s.queue = make(chan Job, 64)
go s.worker()
ln, err := net.Listen("tcp", s.Addr)
if err != nil {
return fmt.Errorf("listen %s: %w (is another helper / user session already running?)", s.Addr, err)
}
vlog.Printf("ipp: listening on http://%s/ipp/print formats=%v spool=%s", s.Addr, s.Formats, s.SpoolDir)
srv := &http.Server{Handler: s, ReadHeaderTimeout: 30 * time.Second}
return srv.Serve(ln)
}
func (s *IPPServer) worker() {
for j := range s.queue {
lj := vlog.Job(j.ID)
s.setState(j.IPPJobID, jsProcessing, "job-printing", "stamping")
start := time.Now()
err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
if s.Process == nil {
return fmt.Errorf("no processor configured")
}
return s.Process(j)
}()
if err != nil {
lj.Errorf("ipp: job %d FAILED after %s: %v", j.IPPJobID, time.Since(start).Round(time.Millisecond), err)
s.setState(j.IPPJobID, jsAborted, "aborted-by-system", truncate(err.Error(), 200))
continue
}
lj.Printf("ipp: job %d completed in %s", j.IPPJobID, time.Since(start).Round(time.Millisecond))
s.setState(j.IPPJobID, jsCompleted, "job-completed-successfully", "timestamped")
}
}
func (s *IPPServer) setState(id, st int, reasons, msg string) {
s.mu.Lock()
defer s.mu.Unlock()
if j := s.jobs[id]; j != nil {
j.state, j.reasons, j.message = st, reasons, msg
if st >= jsCanceled {
j.done = time.Now()
}
}
}
// ServeHTTP handles IPP POSTs (any path) and a plain-text GET status page.
func (s *IPPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
vlog.Debugf("http: %s %s from=%s ct=%q ua=%q len=%d te=%v", r.Method, r.URL.Path, r.RemoteAddr, r.Header.Get("Content-Type"), r.UserAgent(), r.ContentLength, r.TransferEncoding)
if r.Method == http.MethodGet || r.Method == http.MethodHead {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
s.mu.Lock()
n := len(s.jobs)
s.mu.Unlock()
fmt.Fprintf(w, "Verae TimeStamp PDF IPP helper\nprinter-uri: ipp://%s/ipp/print\nformats: %s\njobs-seen: %d\nup-since: %s\npid: %d\n",
s.Addr, strings.Join(s.Formats, ","), n, s.started.Format(time.RFC3339), os.Getpid())
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req goipp.Message
if err := req.Decode(r.Body); err != nil {
vlog.Errorf("ipp: decode error from %s: %v", r.RemoteAddr, err)
http.Error(w, "bad ipp", http.StatusBadRequest)
return
}
op := goipp.Op(req.Code)
if vlog.DebugEnabled() {
var b bytes.Buffer
req.Print(&b, true)
vlog.Debugf("ipp: request %s id=%d\n%s", op, req.RequestID, b.String())
} else {
vlog.Printf("ipp: request %s id=%d user=%q", op, req.RequestID, attrString(req.Operation, "requesting-user-name"))
}
resp := s.dispatch(&req, r)
if vlog.DebugEnabled() {
var b bytes.Buffer
resp.Print(&b, false)
vlog.Debugf("ipp: response %s id=%d\n%s", goipp.Status(resp.Code), req.RequestID, b.String())
}
// drain anything left so keep-alive works
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", goipp.ContentType)
if err := resp.Encode(w); err != nil {
vlog.Errorf("ipp: encode response: %v", err)
}
}
func (s *IPPServer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message {
resp := goipp.NewResponse(req.Version, st, req.RequestID)
resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8")))
resp.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en")))
if msg != "" {
resp.Operation.Add(goipp.MakeAttribute("status-message", goipp.TagText, goipp.String(msg)))
}
return resp
}
func (s *IPPServer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message {
switch goipp.Op(req.Code) {
case goipp.OpGetPrinterAttributes:
resp := s.newResp(req, goipp.StatusOk, "")
resp.Printer = s.printerAttrs(req, r)
return resp
case goipp.OpValidateJob:
if st, msg := s.checkFormat(req); st != goipp.StatusOk {
return s.newResp(req, st, msg)
}
return s.newResp(req, goipp.StatusOk, "")
case goipp.OpPrintJob:
if st, msg := s.checkFormat(req); st != goipp.StatusOk {
return s.newResp(req, st, msg)
}
j := s.createJob(req)
return s.receiveDocument(req, r, j)
case goipp.OpCreateJob:
j := s.createJob(req)
resp := s.newResp(req, goipp.StatusOk, "")
resp.Job = s.jobAttrs(j, r)
return resp
case goipp.OpSendDocument:
id := int(attrInt(req.Operation, "job-id"))
s.mu.Lock()
j := s.jobs[id]
s.mu.Unlock()
if j == nil {
return s.newResp(req, goipp.StatusErrorNotFound, "no such job")
}
if f := attrString(req.Operation, "document-format"); f != "" {
j.format = f
}
return s.receiveDocument(req, r, j)
case goipp.OpCloseJob:
return s.newResp(req, goipp.StatusOk, "")
case goipp.OpGetJobAttributes:
id := int(attrInt(req.Operation, "job-id"))
s.mu.Lock()
j := s.jobs[id]
s.mu.Unlock()
if j == nil {
return s.newResp(req, goipp.StatusErrorNotFound, "no such job")
}
resp := s.newResp(req, goipp.StatusOk, "")
resp.Job = s.jobAttrs(j, r)
return resp
case goipp.OpGetJobs:
resp := s.newResp(req, goipp.StatusOk, "")
which := attrString(req.Operation, "which-jobs")
s.mu.Lock()
var groups goipp.Groups
groups.Add(goipp.Group{Tag: goipp.TagOperationGroup, Attrs: resp.Operation})
for _, j := range s.jobs {
done := j.state >= jsCanceled
if (which == "completed") != done {
continue
}
groups.Add(goipp.Group{Tag: goipp.TagJobGroup, Attrs: s.jobAttrsLocked(j, r)})
}
s.mu.Unlock()
resp.Groups = groups
return resp
case goipp.OpCancelJob:
id := int(attrInt(req.Operation, "job-id"))
s.setState(id, jsCanceled, "job-canceled-by-user", "canceled")
return s.newResp(req, goipp.StatusOk, "")
default:
vlog.Printf("ipp: unsupported operation %s (0x%04x)", goipp.Op(req.Code), req.Code)
return s.newResp(req, goipp.StatusErrorOperationNotSupported, "operation not supported")
}
}
func (s *IPPServer) checkFormat(req *goipp.Message) (goipp.Status, string) {
f := attrString(req.Operation, "document-format")
if f == "" || f == "application/octet-stream" {
return goipp.StatusOk, ""
}
for _, x := range s.Formats {
if strings.EqualFold(strings.TrimSpace(x), f) {
return goipp.StatusOk, ""
}
}
vlog.Errorf("ipp: client asked for unsupported document-format %q", f)
return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f
}
func (s *IPPServer) createJob(req *goipp.Message) *ippJob {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
j := &ippJob{
id: s.nextID,
logID: vlog.NewJobID("ipp"),
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"),
state: jsPending,
reasons: "none",
created: time.Now(),
}
s.jobs[j.id] = j
vlog.Job(j.logID).Printf("ipp: created job %d name=%q user=%q format=%s", j.id, j.name, j.user, j.format)
return j
}
func (s *IPPServer) receiveDocument(req *goipp.Message, r *http.Request, j *ippJob) *goipp.Message {
lj := vlog.Job(j.logID)
ext := ".bin"
switch {
case strings.Contains(j.format, "pdf"):
ext = ".pdf"
case strings.Contains(j.format, "pwg-raster"):
ext = ".pwg"
case strings.Contains(j.format, "urf"):
ext = ".urf"
case strings.Contains(j.format, "postscript"):
ext = ".ps"
}
path := filepath.Join(s.SpoolDir, fmt.Sprintf("%s%s", j.logID, ext))
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
lj.Errorf("ipp: create spool file: %v", err)
s.setState(j.id, jsAborted, "aborted-by-system", err.Error())
return s.newResp(req, goipp.StatusErrorInternal, "spool failed")
}
n, err := io.Copy(f, r.Body)
cerr := f.Close()
if err == nil {
err = cerr
}
if err != nil {
lj.Errorf("ipp: read document after %d bytes: %v", n, err)
s.setState(j.id, jsAborted, "aborted-by-system", err.Error())
return s.newResp(req, goipp.StatusErrorInternal, "read failed")
}
magic := sniff(path)
lj.Printf("ipp: received %d bytes format=%s magic=%q → %s", n, j.format, magic, path)
if n == 0 {
s.setState(j.id, jsAborted, "aborted-by-system", "empty document")
resp := s.newResp(req, goipp.StatusOk, "empty document")
resp.Job = s.jobAttrs(j, r)
return resp
}
s.queue <- Job{
ID: j.logID, IPPJobID: j.id, Source: "ipp", Title: j.name, User: j.user,
Format: j.format, Path: path, Bytes: n, Received: time.Now(),
}
resp := s.newResp(req, goipp.StatusOk, "")
resp.Job = s.jobAttrs(j, r)
return resp
}
func sniff(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
b := make([]byte, 8)
n, _ := f.Read(b)
return strings.Map(func(r rune) rune {
if r < 32 || r > 126 {
return '.'
}
return r
}, string(b[:n]))
}
func (s *IPPServer) printerURI(req *goipp.Message, r *http.Request) string {
if u := attrString(req.Operation, "printer-uri"); u != "" {
return u
}
host := r.Host
if host == "" {
host = s.Addr
}
return "ipp://" + host + "/ipp/print"
}
func (s *IPPServer) jobAttrs(j *ippJob, r *http.Request) goipp.Attributes {
s.mu.Lock()
defer s.mu.Unlock()
return s.jobAttrsLocked(j, r)
}
func (s *IPPServer) jobAttrsLocked(j *ippJob, r *http.Request) goipp.Attributes {
host := r.Host
if host == "" {
host = s.Addr
}
var a goipp.Attributes
a.Add(goipp.MakeAttribute("job-id", goipp.TagInteger, goipp.Integer(j.id)))
a.Add(goipp.MakeAttribute("job-uri", goipp.TagURI, goipp.String(fmt.Sprintf("ipp://%s/ipp/print/%d", host, j.id))))
a.Add(goipp.MakeAttribute("job-printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print")))
a.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(j.name)))
a.Add(goipp.MakeAttribute("job-originating-user-name", goipp.TagName, goipp.String(firstNonEmpty(j.user, "anonymous"))))
a.Add(goipp.MakeAttribute("job-state", goipp.TagEnum, goipp.Integer(j.state)))
a.Add(goipp.MakeAttribute("job-state-reasons", goipp.TagKeyword, goipp.String(firstNonEmpty(j.reasons, "none"))))
if j.message != "" {
a.Add(goipp.MakeAttribute("job-state-message", goipp.TagText, goipp.String(j.message)))
}
a.Add(goipp.MakeAttribute("time-at-creation", goipp.TagInteger, goipp.Integer(int(j.created.Sub(s.started).Seconds())+1)))
if !j.done.IsZero() {
a.Add(goipp.MakeAttribute("time-at-completed", goipp.TagInteger, goipp.Integer(int(j.done.Sub(s.started).Seconds())+1)))
}
a.Add(goipp.MakeAttribute("job-impressions-completed", goipp.TagInteger, goipp.Integer(0)))
return a
}
func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) }
func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute {
a := goipp.Attribute{Name: name}
for _, v := range vals {
a.Values.Add(tag, goipp.String(v))
}
return a
}
func ints(name string, tag goipp.Tag, vals ...int) goipp.Attribute {
a := goipp.Attribute{Name: name}
for _, v := range vals {
a.Values.Add(tag, goipp.Integer(v))
}
return a
}
func mediaCol(w, h int, margins int) goipp.Attribute {
size := goipp.Collection{}
size.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(w)))
size.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(h)))
col := goipp.Collection{}
col.Add(goipp.MakeAttribute("media-size", goipp.TagBeginCollection, size))
for _, m := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} {
col.Add(goipp.MakeAttribute(m, goipp.TagInteger, goipp.Integer(margins)))
}
col.Add(goipp.MakeAttribute("media-source", goipp.TagKeyword, goipp.String("auto")))
col.Add(goipp.MakeAttribute("media-type", goipp.TagKeyword, goipp.String("stationery")))
return goipp.MakeAttribute("media-col", goipp.TagBeginCollection, col)
}
func (s *IPPServer) uuid() string {
h := sha1.Sum([]byte("verae-timestamp-pdf|" + s.Addr))
return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", h[0:4], h[4:6], h[6:8], h[8:10], h[10:16])
}
func (s *IPPServer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attributes {
uri := s.printerURI(req, r)
sec := "none"
if strings.HasPrefix(uri, "ipps:") || strings.HasPrefix(uri, "https:") {
sec = "tls"
}
s.mu.Lock()
queued := 0
busy := false
for _, j := range s.jobs {
if j.state < jsCanceled {
queued++
busy = busy || j.state == jsProcessing
}
}
s.mu.Unlock()
state := 3
if busy {
state = 4
}
up := int(time.Since(s.started).Seconds()) + 1
var a goipp.Attributes
add := func(x goipp.Attribute) { a.Add(x) }
add(goipp.MakeAttribute("printer-uri-supported", goipp.TagURI, goipp.String(uri)))
add(kw("uri-security-supported", sec))
add(kw("uri-authentication-supported", "none"))
add(goipp.MakeAttribute("printer-name", goipp.TagName, goipp.String(s.Name)))
add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(s.Name)))
add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae TimeStamp PDF")))
add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("This PC (loopback)")))
add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String("http://"+s.Addr+"/")))
add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(s.uuid())))
add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:TimeStamp PDF;CMD:PDF,PWGRaster;CLS:PRINTER;DES:Verae TimeStamp PDF;")))
add(ints("printer-state", goipp.TagEnum, state))
add(kw("printer-state-reasons", "none"))
add(goipp.MakeAttribute("printer-state-message", goipp.TagText, goipp.String("ready")))
add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true)))
add(ints("queued-job-count", goipp.TagInteger, queued))
add(ints("printer-up-time", goipp.TagInteger, up))
add(ints("printer-state-change-time", goipp.TagInteger, 1))
add(ints("printer-config-change-time", goipp.TagInteger, 1))
add(goipp.MakeAttribute("printer-current-time", goipp.TagDateTime, goipp.Time{Time: time.Now()}))
add(kw("ipp-versions-supported", "1.1", "2.0"))
add(kw("ipp-features-supported", "ipp-everywhere"))
add(ints("operations-supported", goipp.TagEnum,
int(goipp.OpPrintJob), int(goipp.OpValidateJob), int(goipp.OpCreateJob), int(goipp.OpSendDocument),
int(goipp.OpCancelJob), int(goipp.OpGetJobAttributes), int(goipp.OpGetJobs), int(goipp.OpGetPrinterAttributes),
int(goipp.OpCloseJob)))
add(multi("charset-configured", goipp.TagCharset, "utf-8"))
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, s.Formats...))
add(multi("document-format-default", goipp.TagMimeType, s.Formats[0]))
add(multi("document-format-preferred", goipp.TagMimeType, "application/pdf"))
add(kw("pdf-versions-supported", "adobe-1.3", "adobe-1.4", "adobe-1.5", "adobe-1.6", "adobe-1.7", "iso-32000-1_2008"))
add(kw("compression-supported", "none"))
add(kw("pdl-override-supported", "attempted"))
add(goipp.MakeAttribute("multiple-document-jobs-supported", goipp.TagBoolean, goipp.Boolean(false)))
add(kw("multiple-document-handling-supported", "separate-documents-uncollated-copies", "separate-documents-collated-copies"))
add(ints("multiple-operation-time-out", goipp.TagInteger, 120))
add(kw("multiple-operation-time-out-action", "process-job"))
add(kw("which-jobs-supported", "completed", "not-completed"))
add(kw("job-ids-supported", "true"))
add(kw("printer-kind", "document"))
add(goipp.MakeAttribute("color-supported", goipp.TagBoolean, goipp.Boolean(true)))
add(goipp.MakeAttribute("copies-default", goipp.TagInteger, goipp.Integer(1)))
add(goipp.MakeAttribute("copies-supported", goipp.TagRange, goipp.Range{Lower: 1, Upper: 1}))
add(ints("finishings-default", goipp.TagEnum, 3))
add(ints("finishings-supported", goipp.TagEnum, 3))
add(ints("orientation-requested-default", goipp.TagEnum, 3))
add(ints("orientation-requested-supported", goipp.TagEnum, 3, 4, 5, 6))
add(kw("sides-default", "one-sided"))
add(kw("sides-supported", "one-sided"))
add(kw("print-color-mode-default", "color"))
add(kw("print-color-mode-supported", "auto", "color", "monochrome"))
add(ints("print-quality-default", goipp.TagEnum, 4))
add(ints("print-quality-supported", goipp.TagEnum, 3, 4, 5))
add(kw("output-bin-default", "face-up"))
add(kw("output-bin-supported", "face-up"))
add(kw("print-scaling-default", "auto"))
add(kw("print-scaling-supported", "auto", "auto-fit", "fill", "fit", "none"))
res := goipp.Resolution{Xres: 600, Yres: 600, Units: goipp.UnitsDpi}
res3 := goipp.Resolution{Xres: 300, Yres: 300, Units: goipp.UnitsDpi}
add(goipp.MakeAttribute("printer-resolution-default", goipp.TagResolution, res))
pr := goipp.Attribute{Name: "printer-resolution-supported"}
pr.Values.Add(goipp.TagResolution, res3)
pr.Values.Add(goipp.TagResolution, res)
add(pr)
pw := goipp.Attribute{Name: "pwg-raster-document-resolution-supported"}
pw.Values.Add(goipp.TagResolution, res3)
pw.Values.Add(goipp.TagResolution, res)
add(pw)
add(kw("pwg-raster-document-type-supported", "sgray_8", "srgb_8"))
add(kw("pwg-raster-document-sheet-back", "normal"))
// media (hundredths of mm)
add(kw("media-default", "na_letter_8.5x11in"))
add(kw("media-supported", "na_letter_8.5x11in", "na_legal_8.5x14in", "iso_a4_210x297mm"))
add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm"))
add(kw("media-type-supported", "stationery"))
add(kw("media-source-supported", "auto"))
add(ints("media-bottom-margin-supported", goipp.TagInteger, 0))
add(ints("media-left-margin-supported", goipp.TagInteger, 0))
add(ints("media-right-margin-supported", goipp.TagInteger, 0))
add(ints("media-top-margin-supported", goipp.TagInteger, 0))
letter, legal, a4 := mediaCol(21590, 27940, 0), mediaCol(21590, 35560, 0), mediaCol(21000, 29700, 0)
def := letter
def.Name = "media-col-default"
add(def)
db := goipp.Attribute{Name: "media-col-database"}
for _, m := range []goipp.Attribute{letter, legal, a4} {
db.Values = append(db.Values, m.Values...)
}
add(db)
rd := goipp.Attribute{Name: "media-col-ready"}
rd.Values = append(rd.Values, letter.Values...)
rd.Values = append(rd.Values, a4.Values...)
add(rd)
ms := goipp.Attribute{Name: "media-size-supported"}
for _, wh := range [][2]int{{21590, 27940}, {21590, 35560}, {21000, 29700}} {
c := goipp.Collection{}
c.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(wh[0])))
c.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(wh[1])))
ms.Values.Add(goipp.TagBeginCollection, c)
}
add(ms)
add(kw("media-col-supported", "media-size", "media-type", "media-source", "media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"))
add(kw("job-creation-attributes-supported", "copies", "media", "media-col", "orientation-requested", "print-color-mode", "print-quality", "sides", "output-bin", "print-scaling", "printer-resolution"))
add(goipp.MakeAttribute("job-k-octets-supported", goipp.TagRange, goipp.Range{Lower: 0, Upper: 2000000}))
return a
}
func attrString(attrs goipp.Attributes, name string) string {
for _, a := range attrs {
if a.Name == name && len(a.Values) > 0 {
return a.Values[0].V.String()
}
}
return ""
}
func attrInt(attrs goipp.Attributes, name string) int32 {
for _, a := range attrs {
if a.Name == name && len(a.Values) > 0 {
if v, ok := a.Values[0].V.(goipp.Integer); ok {
return int32(v)
}
}
}
return 0
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if strings.TrimSpace(s) != "" {
return s
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View file

@ -0,0 +1,88 @@
package winprint
import (
"bytes"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/OpenPrinting/goipp"
)
func freeAddr(t *testing.T) string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
a := l.Addr().String()
l.Close()
return a
}
func call(t *testing.T, addr string, m *goipp.Message, doc []byte) *goipp.Message {
b, err := m.EncodeBytes()
if err != nil {
t.Fatal(err)
}
b = append(b, doc...)
res, err := http.Post("http://"+addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var out goipp.Message
if err := out.Decode(res.Body); err != nil {
t.Fatal(err)
}
return &out
}
func req(op goipp.Op, addr string) *goipp.Message {
m := goipp.NewRequest(goipp.DefaultVersion, op, 1)
m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8")))
m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en")))
m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String("ipp://"+addr+"/ipp/print")))
return m
}
func TestIPPPrintJobFlow(t *testing.T) {
addr := freeAddr(t)
got := make(chan Job, 1)
s := &IPPServer{Addr: addr, SpoolDir: t.TempDir(), Process: func(j Job) error { got <- j; return nil }}
go s.ListenAndServe()
time.Sleep(200 * time.Millisecond)
r := call(t, addr, req(goipp.OpGetPrinterAttributes, addr), nil)
if goipp.Status(r.Code) != goipp.StatusOk || len(r.Printer) < 40 {
t.Fatalf("gpa status=%v attrs=%d", goipp.Status(r.Code), len(r.Printer))
}
pj := req(goipp.OpPrintJob, addr)
pj.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("george")))
pj.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("Contract.docx")))
pj.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf")))
doc := []byte("%PDF-1.4\n%fake\n")
r = call(t, addr, pj, doc)
if goipp.Status(r.Code) != goipp.StatusOk {
t.Fatalf("print-job status %v", goipp.Status(r.Code))
}
select {
case j := <-got:
b, _ := os.ReadFile(j.Path)
if !bytes.Equal(b, doc) || j.Title != "Contract.docx" || j.User != "george" || filepath.Ext(j.Path) != ".pdf" {
t.Fatalf("bad job %+v body=%q", j, b)
}
case <-time.After(3 * time.Second):
t.Fatal("job not processed")
}
bad := req(goipp.OpPrintJob, addr)
bad.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("image/urf")))
r = call(t, addr, bad, []byte("x"))
if goipp.Status(r.Code) != goipp.StatusErrorDocumentFormatNotSupported {
t.Fatalf("expected format error, got %v", goipp.Status(r.Code))
}
}

108
internal/winprint/watch.go Normal file
View file

@ -0,0 +1,108 @@
package winprint
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// Watcher polls a drop folder written by the spooler through a Local file
// port (Microsoft Print To PDF → C:\ProgramData\Verae\spool\fallback\verae-job.pdf).
// When a file is stable and can be opened exclusively it is moved into the
// per-user work dir under a unique name and handed to Process.
type Watcher struct {
Dir string
WorkDir string
Interval time.Duration
Process Processor
}
// Run blocks forever.
func (w *Watcher) Run() {
if w.Interval <= 0 {
w.Interval = time.Second
}
_ = os.MkdirAll(w.WorkDir, 0o700)
vlog.Printf("watch: polling %s every %s (work=%s)", w.Dir, w.Interval, w.WorkDir)
sizes := map[string]int64{}
warned := false
for {
ents, err := os.ReadDir(w.Dir)
if err != nil {
if !warned {
vlog.Errorf("watch: read %s: %v (fallback printer disabled until it exists)", w.Dir, err)
warned = true
}
time.Sleep(5 * time.Second)
continue
}
warned = false
seen := map[string]bool{}
for _, e := range ents {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") || strings.HasSuffix(strings.ToLower(e.Name()), ".tmp") {
continue
}
p := filepath.Join(w.Dir, e.Name())
seen[p] = true
st, err := os.Stat(p)
if err != nil || st.Size() == 0 {
continue
}
prev, ok := sizes[p]
sizes[p] = st.Size()
if !ok || prev != st.Size() || time.Since(st.ModTime()) < 2*time.Second {
continue // still growing
}
w.claim(p, st.Size())
delete(sizes, p)
}
for p := range sizes {
if !seen[p] {
delete(sizes, p)
}
}
time.Sleep(w.Interval)
}
}
func (w *Watcher) claim(p string, size int64) {
id := vlog.NewJobID("watch")
lj := vlog.Job(id)
dst := filepath.Join(w.WorkDir, id+".pdf")
// Rename fails while the spooler still holds the file open (Windows sharing rules).
if err := os.Rename(p, dst); err != nil {
// cross-volume or locked: try copy+remove
b, rerr := os.ReadFile(p)
if rerr != nil {
lj.Debugf("watch: %s not ready: %v / %v", p, err, rerr)
return
}
if werr := os.WriteFile(dst, b, 0o600); werr != nil {
lj.Errorf("watch: copy to %s: %v", dst, werr)
return
}
if derr := os.Remove(p); derr != nil {
lj.Errorf("watch: copied but could not remove %s: %v (will re-detect; check ACLs)", p, derr)
}
}
lj.Printf("watch: claimed %s (%d bytes, magic=%q) → %s", p, size, sniff(dst), dst)
j := Job{ID: id, Source: "watch", Title: "Printed " + time.Now().Format("2006-01-02 1504"), Format: "application/pdf", Path: dst, Bytes: size, Received: time.Now()}
start := time.Now()
err := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return w.Process(j)
}()
if err != nil {
lj.Errorf("watch: job FAILED after %s: %v", time.Since(start).Round(time.Millisecond), err)
return
}
lj.Printf("watch: job completed in %s", time.Since(start).Round(time.Millisecond))
}

View file

@ -0,0 +1,71 @@
Verae TimeStamp PDF - Windows DEBUG test build
==============================================
What this build adds
--------------------
* "Verae TimeStamp PDF" printer (PRIMARY). It uses the built-in Microsoft IPP
Class Driver and sends jobs to a local helper (verae-printhelper.exe) at
http://127.0.0.1:8631/ipp/print. The helper runs as you, saves the PDF,
timestamps it with Verae, and writes the certificate.
* "Verae TimeStamp PDF (Local Port)" printer (FALLBACK). It uses the built-in
Microsoft Print To PDF driver and writes to
C:\ProgramData\Verae\spool\fallback\verae-job.pdf. The same helper picks the
file up.
* Heavy logging in C:\ProgramData\Verae\logs:
install-<time>.log full installer transcript (every step OK/FAIL)
install-summary-<time>.json
helper-<user>.log per-job IDs like J20261003-190000-ipp-1
cli-<user>.log
* Start menu > Verae > "Collect diagnostics" puts verae-diag-<time>.zip on
your Desktop.
Test steps
----------
1. Run VeraePrintStamp-windows-amd64-0.2.0-debug-setup.exe and accept the UAC
prompt. If SmartScreen appears, choose More info > Run anyway (the build is
unsigned).
The install details pane should end with "Verae printer install: OK".
(The old per-user 0.1.2 install is removed automatically.)
2. Open PowerShell (not elevated) and check:
Get-Printer | ? Name -like 'Verae*' | ft Name,DriverName,PortName
verae-print ipp-selftest
Expected: two printers, and "IPP helper OK".
3. If you have never saved a signing key: Start > Verae > Setup signing key.
This box already has %USERPROFILE%\.verae\xverae-token.
4. Open Notepad, type something, File > Print, choose "Verae TimeStamp PDF",
click Print.
Within about 10 s you should get a balloon "Verae timestamp <receipt>", and
these files in Documents\Verae Timestamps:
<name>.pdf, <name>-verae-timestamp-<RECEIPT>.pdf, <name>.verae.json
5. Repeat step 4 with "Verae TimeStamp PDF (Local Port)". The file is named
"Printed <date time>".
6. Repeat with Edge (a web page) and Word if installed.
7. Whatever happened, run Start > Verae > Collect diagnostics (right-click >
More > Run as administrator for complete results) and send the zip from
your Desktop.
If the printer does not appear
------------------------------
* Look in C:\ProgramData\Verae\logs\install-*.log for the first "RESULT: FAIL".
* Reinstall the printer only (elevated PowerShell):
& "C:\Program Files\Verae\PrintStamp\scripts\install-printer.ps1"
* Is the helper running?
Get-Process verae-printhelper
netstat -ano | findstr 8631
To start it: Start > Verae > Start print helper.
* For verbose IPP dumps, the installer sets HKLM\Software\Verae\PrintStamp
Debug=1. Check helper-<user>.log for "ipp: request".
If jobs arrive as PWG raster instead of PDF
-------------------------------------------
The helper log says "received image/pwg-raster". Make the helper advertise PDF
only, then re-add the printer:
setx VERAE_IPP_FORMATS application/pdf
(sign out and back in, or restart the helper)
Remove-Printer "Verae TimeStamp PDF" (elevated)
& "C:\Program Files\Verae\PrintStamp\scripts\install-printer.ps1" -SkipFallback
Uninstall
---------
Settings > Apps > "Verae Print Stamp (debug)", or run
"C:\Program Files\Verae\PrintStamp\Uninstall.exe". Logs are kept.

View file

@ -0,0 +1,27 @@
# Build the Windows DEBUG installer natively on Windows (Go + NSIS).
# Usage: powershell -ExecutionPolicy Bypass -File platform\windows\build-windows-debug.ps1 [-Arch amd64|arm64]
param([string]$Arch = "amd64", [string]$Version = "0.2.0-debug")
$ErrorActionPreference = "Stop"
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$Out = Join-Path $Root "dist\windows-debug"
$Stage = Join-Path $Out "stage-$Arch"
$Go = (Get-Command go -ErrorAction SilentlyContinue).Source; if (-not $Go) { $Go = "C:\Program Files\Go\bin\go.exe" }
$Nsis = (Get-Command makensis -ErrorAction SilentlyContinue).Source; if (-not $Nsis) { $Nsis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe" }
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage, (Join-Path $Stage "scripts") | Out-Null
Push-Location $Root
try {
$env:CGO_ENABLED = "0"; $env:GOOS = "windows"; $env:GOARCH = $Arch
& $Go version
& $Go test ./internal/... ; if ($LASTEXITCODE) { throw "go test failed" }
& $Go build -trimpath -o (Join-Path $Stage "verae-print.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build cli failed" }
& $Go build -trimpath -ldflags "-H windowsgui" -o (Join-Path $Stage "verae-printhelper.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build helper failed" }
} finally { Pop-Location; Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue }
Copy-Item (Join-Path $PSScriptRoot "scripts\*.ps1") (Join-Path $Stage "scripts")
Copy-Item (Join-Path $PSScriptRoot "LICENSE.txt"), (Join-Path $PSScriptRoot "README-TEST.txt") $Stage
& $Nsis /V3 "/DVERSION=$Version" "/DARCH=$Arch" "/DSRCDIR=$Stage" "/DOUTDIR=$Out" (Join-Path $PSScriptRoot "installer-debug.nsi")
if ($LASTEXITCODE) { throw "makensis failed" }
# raw files alongside the installer
Copy-Item (Join-Path $Stage "*") $Out -Recurse -Force
Get-ChildItem $Out -File | Get-FileHash -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) $(Split-Path $_.Path -Leaf)" } | Set-Content (Join-Path $Out "SHA256SUMS.txt")
Get-ChildItem $Out -File | Format-Table Name, Length -AutoSize

View file

@ -0,0 +1,128 @@
; Verae Print Stamp — Windows DEBUG installer with "Verae TimeStamp PDF" printer.
; Build: platform\windows\build-windows-debug.ps1 (on Windows) or makensis with -DSRCDIR.
!ifndef VERSION
!define VERSION "0.2.0-debug"
!endif
!ifndef ARCH
!define ARCH "amd64"
!endif
!ifndef SRCDIR
!define SRCDIR "."
!endif
!ifndef OUTDIR
!define OUTDIR "."
!endif
!define PRODUCT_NAME "Verae Print Stamp (debug)"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStampDebug"
Name "${PRODUCT_NAME} ${VERSION}"
OutFile "${OUTDIR}\VeraePrintStamp-windows-${ARCH}-${VERSION}-setup.exe"
InstallDir "$PROGRAMFILES64\Verae\PrintStamp"
RequestExecutionLevel admin
Unicode true
SetCompressor /SOLID lzma
ShowInstDetails show
ShowUninstDetails show
!include "MUI2.nsh"
!include "x64.nsh"
!include "LogicLib.nsh"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN "$INSTDIR\bin\verae-print.exe"
!define MUI_FINISHPAGE_RUN_PARAMETERS "setup"
!define MUI_FINISHPAGE_RUN_TEXT "Run signing-key setup (only needed if not done before)"
!define MUI_FINISHPAGE_RUN_NOTCHECKED
!define MUI_FINISHPAGE_SHOWREADME "$INSTDIR\README-TEST.txt"
!define MUI_FINISHPAGE_SHOWREADME_TEXT "Open test instructions"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt"
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
!define PS '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass'
Function .onInit
${If} ${RunningX64}
SetRegView 64
${Else}
MessageBox MB_ICONSTOP "This debug build targets 64-bit Windows."
Abort
${EndIf}
FunctionEnd
Function un.onInit
SetRegView 64
FunctionEnd
Section "Verae Print Stamp + printer" SecMain
SectionIn RO
; stop a running helper so files can be replaced
nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe'
Pop $0
SetOutPath "$INSTDIR\bin"
File "${SRCDIR}\verae-print.exe"
File "${SRCDIR}\verae-printhelper.exe"
SetOutPath "$INSTDIR\scripts"
File "${SRCDIR}\scripts\install-printer.ps1"
File "${SRCDIR}\scripts\uninstall-printer.ps1"
File "${SRCDIR}\scripts\verae-diag.ps1"
SetOutPath "$INSTDIR"
File "${SRCDIR}\README-TEST.txt"
File "${SRCDIR}\LICENSE.txt"
WriteUninstaller "$INSTDIR\Uninstall.exe"
; Remove the old per-user 0.1.2 CLI-only install (it shadows PATH and has no printer)
DetailPrint "Removing old per-user install in $LOCALAPPDATA\Verae\PrintStamp (if any)"
Delete "$LOCALAPPDATA\Verae\PrintStamp\bin\verae-print.exe"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStamp"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"'
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae"
CreateDirectory "$SMPROGRAMS\Verae"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Collect diagnostics.lnk" "$WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" '-NoProfile -ExecutionPolicy Bypass -NoExit -File "$INSTDIR\scripts\verae-diag.ps1"'
CreateShortCut "$SMPROGRAMS\Verae\Verae - Setup signing key.lnk" "$INSTDIR\bin\verae-print.exe" "setup"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Start print helper.lnk" "$INSTDIR\bin\verae-printhelper.exe"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Logs folder.lnk" "$WINDIR\explorer.exe" '"$COMMONAPPDATA\Verae\logs"'
CreateShortCut "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" "$INSTDIR\Uninstall.exe"
DetailPrint "Installing printer(s) — full transcript in $COMMONAPPDATA\Verae\logs"
nsExec::ExecToLog '${PS} -File "$INSTDIR\scripts\install-printer.ps1" -InstallDir "$INSTDIR"'
Pop $0
${If} $0 == "0"
DetailPrint "Verae printer install: OK"
${Else}
DetailPrint "Verae printer install: FAILED (exit $0) — run Start menu > Verae > Collect diagnostics"
MessageBox MB_ICONEXCLAMATION "Printer setup reported a problem (exit $0).$\r$\nLogs: $COMMONAPPDATA\Verae\logs$\r$\nPlease run Start > Verae > Collect diagnostics and send the zip from your Desktop."
${EndIf}
SectionEnd
Section "Uninstall"
nsExec::ExecToLog '${PS} -File "$INSTDIR\scripts\uninstall-printer.ps1"'
Pop $0
nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe'
Pop $0
Delete "$INSTDIR\bin\verae-print.exe"
Delete "$INSTDIR\bin\verae-printhelper.exe"
RMDir "$INSTDIR\bin"
Delete "$INSTDIR\scripts\*.ps1"
RMDir "$INSTDIR\scripts"
Delete "$INSTDIR\README-TEST.txt"
Delete "$INSTDIR\LICENSE.txt"
Delete "$INSTDIR\Uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Verae\*.lnk"
RMDir "$SMPROGRAMS\Verae"
DeleteRegKey HKLM "${UNINST_KEY}"
DeleteRegKey HKLM "Software\Verae\PrintStamp"
; logs in %ProgramData%\Verae\logs are kept on purpose
SectionEnd

View file

@ -0,0 +1,154 @@
# Verae TimeStamp PDF — printer installer (DEBUG build). Must run elevated.
# Every Add-* is followed by a Get-* verification. Full transcript + JSON summary
# go to %ProgramData%\Verae\logs. Exit code 0 = at least one printer verified.
param(
[string]$InstallDir = (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)),
[switch]$SkipIpp,
[switch]$SkipFallback,
[string]$IppAddr = "127.0.0.1:8631"
)
$ErrorActionPreference = "Continue"
$PrinterName = "Verae TimeStamp PDF"
$FallbackName = "Verae TimeStamp PDF (Local Port)"
$LogDir = Join-Path $env:ProgramData "Verae\logs"
$DropDir = Join-Path $env:ProgramData "Verae\spool\fallback"
$PortPath = Join-Path $DropDir "verae-job.pdf"
$Bin = Join-Path $InstallDir "bin"
$Helper = Join-Path $Bin "verae-printhelper.exe"
$Cli = Join-Path $Bin "verae-print.exe"
$IppUrl = "http://$IppAddr/ipp/print"
$ts = Get-Date -Format "yyyyMMdd-HHmmss"
New-Item -ItemType Directory -Force -Path $LogDir, $DropDir | Out-Null
$Transcript = Join-Path $LogDir "install-$ts.log"
Start-Transcript -Path $Transcript -Force | Out-Null
$summary = [ordered]@{ started = (Get-Date).ToString("o"); steps = @(); ipp = "not-run"; fallback = "not-run" }
function Step([string]$Name, [scriptblock]$Do, [scriptblock]$Verify) {
Write-Host "`n=== STEP: $Name"
$err = $null; $ok = $false
try { & $Do | Out-Host } catch { $err = $_; Write-Host " EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message) HResult=0x$('{0:X8}' -f $_.Exception.HResult)" }
if ($Verify) {
try { $ok = [bool](& $Verify) } catch { Write-Host " VERIFY EXCEPTION: $($_.Exception.Message)" }
} else { $ok = ($null -eq $err) }
Write-Host (" RESULT: " + $(if ($ok) { "OK" } else { "FAIL" }))
$script:summary.steps += [ordered]@{ step = $Name; ok = $ok; error = $(if ($err) { "$($err.Exception.Message)" } else { $null }) }
return $ok
}
Write-Host "Verae printer install (debug) $ts"
Write-Host "InstallDir=$InstallDir User=$(whoami) PS=$($PSVersionTable.PSVersion) 64bitProcess=$([Environment]::Is64BitProcess)"
$os = Get-CimInstance Win32_OperatingSystem
Write-Host "OS: $($os.Caption) $($os.Version) build $($os.BuildNumber) $($os.OSArchitecture)"
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
Write-Host "Elevated: $isAdmin"
if (-not $isAdmin) { Write-Host "ERROR: not elevated — printer/port creation will fail (Access denied)." }
if (-not [Environment]::Is64BitProcess -and [Environment]::Is64BitOperatingSystem) { Write-Host "WARNING: 32-bit PowerShell on 64-bit Windows (WOW64) — should be launched via Sysnative." }
Write-Host "`n--- WPP / policy registry"
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\WPP','HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers','HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint') {
if (Test-Path $k) { Write-Host "[$k]"; Get-ItemProperty $k | Format-List | Out-String | Write-Host } else { Write-Host "(absent) $k" }
}
Write-Host "--- BEFORE: printers / drivers / ports"
Get-Printer | Format-Table Name,DriverName,PortName,Type -AutoSize | Out-String -Width 250 | Write-Host
Get-PrinterDriver | Format-Table Name,MajorVersion,PrinterEnvironment -AutoSize | Out-String -Width 250 | Write-Host
Get-PrinterPort | Format-Table Name,Description,PortMonitor -AutoSize | Out-String -Width 250 | Write-Host
Step "Spooler running" { $s = Get-Service Spooler; if ($s.Status -ne 'Running') { Start-Service Spooler } } { (Get-Service Spooler).Status -eq 'Running' } | Out-Null
Step "Enable PrintService/Operational log" { wevtutil sl Microsoft-Windows-PrintService/Operational /e:true | Out-Host } { (Get-WinEvent -ListLog Microsoft-Windows-PrintService/Operational).IsEnabled } | Out-Null
Step "ACL logs+spool dirs (Users: Modify)" {
icacls (Join-Path $env:ProgramData "Verae") /grant "*S-1-5-32-545:(OI)(CI)M" /T /C | Out-Host
} { Test-Path $LogDir } | Out-Null
Step "Debug flag HKLM\Software\Verae\PrintStamp\Debug=1" {
New-Item -Path 'HKLM:\Software\Verae\PrintStamp' -Force | Out-Null
Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name Debug -Value 1 -Type DWord
Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name InstallDir -Value $InstallDir
} { (Get-ItemProperty 'HKLM:\Software\Verae\PrintStamp').Debug -eq 1 } | Out-Null
Step "Helper autostart (HKLM Run)" {
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -Value "`"$Helper`""
} { (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run').VeraePrintHelper -like "*verae-printhelper.exe*" } | Out-Null
Step "Add bin to machine PATH" {
$p = [Environment]::GetEnvironmentVariable("Path","Machine")
if ($p -notlike "*$Bin*") { [Environment]::SetEnvironmentVariable("Path", ($p.TrimEnd(';') + ";" + $Bin), "Machine") }
} { [Environment]::GetEnvironmentVariable("Path","Machine") -like "*$Bin*" } | Out-Null
function Test-Ipp { try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 "http://$IppAddr/"; return ($r.StatusCode -eq 200) } catch { return $false } }
# ---------------- Primary: IPP class driver → loopback helper ----------------
if (-not $SkipIpp) {
$helperUp = Step "Helper listening on $IppAddr" {
Get-Process verae-printhelper -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " existing helper pid=$($_.Id)" }
if (-not (Test-Ipp)) {
# explorer.exe launches the exe with the (non-elevated) shell token = the real user session
Write-Host " starting helper un-elevated via explorer.exe"
Start-Process explorer.exe -ArgumentList "`"$Helper`""
for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 }
}
if (-not (Test-Ipp)) {
Write-Host " explorer launch did not bring IPP up; starting elevated temporary helper for discovery"
Start-Process -FilePath $Helper -WindowStyle Hidden
for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 }
}
& $Cli ipp-selftest --addr $IppAddr | Out-Host
} { Test-Ipp }
if ($helperUp) {
Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " removing stale '$($_.Name)'"; Remove-Printer -Name $_.Name }
$before = @(Get-Printer | Select-Object -ExpandProperty Name)
$hasIppUrl = (Get-Command Add-Printer).Parameters.ContainsKey('IppURL')
Write-Host "Add-Printer supports -IppURL: $hasIppUrl"
$added = Step "Add IPP printer ($IppUrl)" {
if ($hasIppUrl) {
Add-Printer -IppURL $IppUrl -ErrorAction Stop
} else {
Add-Printer -Name $PrinterName -DriverName "Microsoft IPP Class Driver" -PortName $IppUrl -ErrorAction Stop
}
} {
Start-Sleep 2
$new = @(Get-Printer | Where-Object { $before -notcontains $_.Name })
Write-Host " new printers: $(( $new | ForEach-Object { "$($_.Name) [$($_.DriverName) / $($_.PortName)]" }) -join '; ')"
$script:newIpp = $new | Select-Object -First 1
($null -ne $script:newIpp) -or (Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue)
}
if ($added -and $script:newIpp -and $script:newIpp.Name -ne $PrinterName) {
Step "Rename '$($script:newIpp.Name)' → '$PrinterName'" { Rename-Printer -Name $script:newIpp.Name -NewName $PrinterName -ErrorAction Stop } { Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue } | Out-Null
}
$p = Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue
if ($p) { $p | Format-List * | Out-String | Write-Host; $summary.ipp = "OK: driver=$($p.DriverName) port=$($p.PortName)" } else { $summary.ipp = "FAIL" }
} else { $summary.ipp = "FAIL: helper not reachable" }
}
# ---------------- Fallback: Microsoft Print To PDF → Local file port ----------------
if (-not $SkipFallback) {
$drv = Step "Driver 'Microsoft Print To PDF' present" {
if (-not (Get-PrinterDriver -Name "Microsoft Print To PDF" -ErrorAction SilentlyContinue)) { Add-PrinterDriver -Name "Microsoft Print To PDF" -ErrorAction Stop }
} { Get-PrinterDriver -Name "Microsoft Print To PDF" -ErrorAction SilentlyContinue }
$port = Step "Local port '$PortPath'" {
if (-not (Get-PrinterPort -Name $PortPath -ErrorAction SilentlyContinue)) { Add-PrinterPort -Name $PortPath -ErrorAction Stop }
} { Get-PrinterPort -Name $PortPath -ErrorAction SilentlyContinue }
if ($drv -and $port) {
Get-Printer -Name $FallbackName -ErrorAction SilentlyContinue | ForEach-Object { Remove-Printer -Name $_.Name }
$fb = Step "Add printer '$FallbackName'" {
Add-Printer -Name $FallbackName -DriverName "Microsoft Print To PDF" -PortName $PortPath -ErrorAction Stop
} { Get-Printer -Name $FallbackName -ErrorAction SilentlyContinue }
$summary.fallback = $(if ($fb) { "OK" } else { "FAIL" })
} else { $summary.fallback = "FAIL: driver=$drv port=$port" }
}
Write-Host "`n--- AFTER: printers / ports"
Get-Printer | Format-Table Name,DriverName,PortName,Type,PrinterStatus -AutoSize | Out-String -Width 250 | Write-Host
Get-PrinterPort | Format-Table Name,Description,PortMonitor -AutoSize | Out-String -Width 250 | Write-Host
Write-Host "--- PrintService events (last 15 min)"
foreach ($ln in 'Microsoft-Windows-PrintService/Admin','Microsoft-Windows-PrintService/Operational') {
Get-WinEvent -FilterHashtable @{LogName=$ln; StartTime=(Get-Date).AddMinutes(-15)} -ErrorAction SilentlyContinue |
Format-Table TimeCreated,Id,LevelDisplayName,Message -Wrap | Out-String -Width 250 | Write-Host
}
$summary.finished = (Get-Date).ToString("o")
$summary.transcript = $Transcript
$summaryPath = Join-Path $LogDir "install-summary-$ts.json"
$summary | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 $summaryPath
$okAny = ($summary.ipp -like "OK*") -or ($summary.fallback -eq "OK")
Write-Host "`nVerae printer install: $(if ($okAny) {'OK'} else {'FAIL'}) ipp=[$($summary.ipp)] fallback=[$($summary.fallback)]"
Write-Host "Log: $Transcript"
Write-Host "Summary: $summaryPath"
Stop-Transcript | Out-Null
if ($okAny) { exit 0 } else { exit 1 }

View file

@ -0,0 +1,13 @@
# Remove Verae printers, port, autostart (elevated).
$ErrorActionPreference = "Continue"
$LogDir = Join-Path $env:ProgramData "Verae\logs"
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
Start-Transcript -Path (Join-Path $LogDir ("uninstall-" + (Get-Date -Format yyyyMMdd-HHmmss) + ".log")) -Force | Out-Null
Get-Process verae-printhelper -ErrorAction SilentlyContinue | Stop-Process -Force
Get-Printer | Where-Object { $_.Name -like "Verae TimeStamp PDF*" } | ForEach-Object { Write-Host "Remove-Printer $($_.Name)"; Remove-Printer -Name $_.Name }
Start-Sleep 1
$port = Join-Path $env:ProgramData "Verae\spool\fallback\verae-job.pdf"
if (Get-PrinterPort -Name $port -ErrorAction SilentlyContinue) { Write-Host "Remove-PrinterPort $port"; Remove-PrinterPort -Name $port }
Remove-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -ErrorAction SilentlyContinue
Get-Printer | Format-Table Name,DriverName,PortName -AutoSize | Out-String | Write-Host
Stop-Transcript | Out-Null

Some files were not shown because too many files have changed in this diff Show more