windows-debug: IPP loopback printer helper, Print-to-PDF fallback, debug installer, diag script
This commit is contained in:
parent
4f1d90cf64
commit
dc37fa76dc
21 changed files with 1843 additions and 29 deletions
12
cmd/verae-print/hide_windows.go
Normal file
12
cmd/verae-print/hide_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -6,14 +6,20 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
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 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
|
|
@ -21,6 +27,10 @@ func main() {
|
|||
switch os.Args[1] {
|
||||
case "stamp":
|
||||
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":
|
||||
os.Exit(runSetup())
|
||||
case "install-macos":
|
||||
|
|
@ -29,6 +39,9 @@ func main() {
|
|||
os.Exit(runInstallPrinter())
|
||||
case "version", "-version", "--version":
|
||||
fmt.Println(job.HelperVersion)
|
||||
if len(os.Args) > 2 {
|
||||
fmt.Println("logs:", vlog.Dir())
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
default:
|
||||
|
|
@ -50,8 +63,13 @@ Usage:
|
|||
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-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
|
||||
|
||||
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
|
||||
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")
|
||||
openFolder := fs.Bool("open", false, "open output folder on success (macOS)")
|
||||
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)
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "stamp: PDF path required")
|
||||
return 2
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
|
|
@ -139,15 +165,17 @@ func runStamp(args []string) int {
|
|||
Client: client,
|
||||
})
|
||||
if err != nil {
|
||||
lj.Errorf("stamp failed: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||
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",
|
||||
res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel)
|
||||
|
||||
if *openFolder {
|
||||
_ = exec.Command("open", filepath.Dir(res.OriginalPDF)).Run()
|
||||
openFolderPath(filepath.Dir(res.OriginalPDF))
|
||||
}
|
||||
// macOS notification (best-effort)
|
||||
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
|
|
@ -155,6 +183,9 @@ func runStamp(args []string) int {
|
|||
}
|
||||
|
||||
func runInstallPrinter() int {
|
||||
if runtime.GOOS == "windows" {
|
||||
return runInstallPrinterWindows()
|
||||
}
|
||||
script := filepath.Join(repoRootHint(), "platform", "macos", "ipp", "install-ipp-printer.sh")
|
||||
if _, err := os.Stat(script); err != nil {
|
||||
// fall back next to executable
|
||||
|
|
@ -276,11 +307,6 @@ fi
|
|||
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 {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
|
|
|
|||
38
cmd/verae-print/platform_other.go
Normal file
38
cmd/verae-print/platform_other.go
Normal 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 }
|
||||
62
cmd/verae-print/platform_windows.go
Normal file
62
cmd/verae-print/platform_windows.go
Normal 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
244
cmd/verae-print/serve.go
Normal 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
1
go.mod
|
|
@ -3,6 +3,7 @@ module github.com/VeraeLLC/PrintToVeraeTimeStamp
|
|||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/OpenPrinting/goipp v1.2.0 // indirect
|
||||
github.com/phpdave11/gofpdf v1.4.2 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -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/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=
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -83,6 +84,9 @@ func LoadXVeraeToken() (string, error) {
|
|||
}
|
||||
}
|
||||
// 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()
|
||||
if err == nil {
|
||||
t := strings.TrimSpace(string(out))
|
||||
|
|
@ -144,6 +148,9 @@ func SaveXVeraeToken(token string) error {
|
|||
return err
|
||||
}
|
||||
// Keychain (best-effort): delete old, add new
|
||||
if runtime.GOOS != "darwin" {
|
||||
return nil
|
||||
}
|
||||
account := os.Getenv("USER")
|
||||
if account == "" {
|
||||
account = "verae"
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ const HelperVersion = "0.1.2"
|
|||
|
||||
// Result paths written for one print job.
|
||||
type Result struct {
|
||||
OriginalPDF string
|
||||
CertificatePDF string
|
||||
ReceiptJSON string
|
||||
ReceiptID string
|
||||
SHA256 string
|
||||
Channel auth.Channel
|
||||
OriginalPDF string
|
||||
CertificatePDF string
|
||||
ReceiptJSON string
|
||||
ReceiptID string
|
||||
SHA256 string
|
||||
Channel auth.Channel
|
||||
}
|
||||
|
||||
// Options for processing a captured print PDF.
|
||||
|
|
|
|||
|
|
@ -73,14 +73,14 @@ func Build(in Input) ([]byte, error) {
|
|||
}
|
||||
|
||||
row := map[string]any{
|
||||
"file": docName,
|
||||
"sha256": in.SHA256,
|
||||
"scope": scope,
|
||||
"receipt_id": in.ReceiptID,
|
||||
"created_at": created,
|
||||
"merkle": merkle,
|
||||
"lookup_url": lookup,
|
||||
"size_bytes": in.ByteLength,
|
||||
"file": docName,
|
||||
"sha256": in.SHA256,
|
||||
"scope": scope,
|
||||
"receipt_id": in.ReceiptID,
|
||||
"created_at": created,
|
||||
"merkle": merkle,
|
||||
"lookup_url": lookup,
|
||||
"size_bytes": in.ByteLength,
|
||||
}
|
||||
|
||||
if b, err := buildWithNode(row); err == nil && len(b) > 1000 {
|
||||
|
|
|
|||
|
|
@ -22,15 +22,15 @@ const (
|
|||
|
||||
// Receipt is the stamp result used by the print helper.
|
||||
type Receipt struct {
|
||||
ReceiptID string
|
||||
SHA256 string
|
||||
CreatedAt string
|
||||
Merkle string
|
||||
Channel auth.Channel
|
||||
BaseURL string
|
||||
PDF []byte // registration / certificate PDF when available
|
||||
PDFFilename string
|
||||
RawResponse json.RawMessage
|
||||
ReceiptID string
|
||||
SHA256 string
|
||||
CreatedAt string
|
||||
Merkle string
|
||||
Channel auth.Channel
|
||||
BaseURL string
|
||||
PDF []byte // registration / certificate PDF when available
|
||||
PDFFilename string
|
||||
RawResponse json.RawMessage
|
||||
}
|
||||
|
||||
// Client stamps digests on Verae Time or the Zapier edge.
|
||||
|
|
|
|||
144
internal/vlog/vlog.go
Normal file
144
internal/vlog/vlog.go
Normal 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
615
internal/winprint/ipp.go
Normal 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] + "…"
|
||||
}
|
||||
88
internal/winprint/ipp_test.go
Normal file
88
internal/winprint/ipp_test.go
Normal 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
108
internal/winprint/watch.go
Normal 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))
|
||||
}
|
||||
71
platform/windows/README-TEST.txt
Normal file
71
platform/windows/README-TEST.txt
Normal 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.
|
||||
27
platform/windows/build-windows-debug.ps1
Normal file
27
platform/windows/build-windows-debug.ps1
Normal 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
|
||||
128
platform/windows/installer-debug.nsi
Normal file
128
platform/windows/installer-debug.nsi
Normal 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
|
||||
154
platform/windows/scripts/install-printer.ps1
Normal file
154
platform/windows/scripts/install-printer.ps1
Normal 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 }
|
||||
13
platform/windows/scripts/uninstall-printer.ps1
Normal file
13
platform/windows/scripts/uninstall-printer.ps1
Normal 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
Loading…
Reference in a new issue