PrintToVeraeTimeStamp/cmd/verae-print/serve.go
Grok Bot (for George) 13ebc351f5 0.3.2: Save As filename prompt per print (Windows + macOS)
- promptForFilename setting (default true; env VERAE_PROMPT_FOR_FILENAME),
  'verae-print config --prompt-for-filename', last Save As folder in
  ~/.verae/state.json
- Windows helper: common file dialog (PDF filter, overwrite prompt, topmost
  owner) before stamping; macOS printer path: choose file name (NSSavePanel)
  via 'stamp -prompt-name -title'; cancel discards the job (no stamp/credit)
- job.Options.OutPath: original saved at the chosen path, certificate and
  .verae.json next to it with the chosen base name (never overwritten)
- held jobs keep the chosen path (pendingMeta.OutPath)
- installer: 'Ask for a file name for each print' checkbox; removed the
  'Open the user guide' finish-page checkbox; mac-key-setup asks too
- auth test no longer touches the real macOS keychain (VERAE_SKIP_KEYCHAIN)
- version 0.3.2
2026-10-03 20:00:56 -04:00

269 lines
8.5 KiB
Go

package main
import (
"bytes"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/OpenPrinting/goipp"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"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)
cfg, _ := settings.Load()
vlog.SetDebug(*debug || debugFromRegistry() || cfg.LogLevel == "debug")
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)
go pendingOnStart()
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 processor winprint.Processor
// makeProcessor: IPP/fallback jobs → stamp. Key missing/invalid/expired
// (HTTP 401/403) → the PDF is held in the pending folder, ONE sign-in prompt
// is shown, and all held jobs are retried after a new key is saved.
func makeProcessor(outDir string) winprint.Processor {
processor = 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) {
return fmt.Errorf("received %s (not PDF); raster→PDF conversion is not implemented — file kept at %s. Set VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path)
}
target := stampTarget{Title: j.Title, OutDir: outDir}
if cfg, _ := settings.Load(); cfg.Prompt() {
p, ok := askSaveTarget(lj, j.Title, outDir)
if !ok {
_ = os.Remove(j.Path)
lj.Printf("process: Save As cancelled by user — job discarded (not stamped, no credit used)")
return nil
}
target.OutPath = p
}
res, err := stampFile(j.Path, target, lj)
if err != nil {
if isAuthFailure(err) {
m, herr := holdJob(j.Path, target, j.Source, err, true)
if herr != nil {
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
return err
}
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, err)
go handleAuthFailure(authReason(err), stampFile)
return nil // the job is safe; IPP reports success to Windows
}
notifyUser("Verae timestamp FAILED", err.Error())
return err
}
_ = os.Remove(j.Path)
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
openOnComplete(lj, res.CertificatePDF)
// a successful stamp proves the key works: flush anything still pending
if len(listPending()) > 0 {
go retryPending(stampFile)
}
return nil
}
return processor
}
// pendingOnStart retries held jobs at helper start, and whenever a key is saved
// by another process (Start-menu "Setup signing key", installer).
func pendingOnStart() {
markKeySeen()
if n := len(listPending()); n > 0 {
vlog.Printf("pending: %d held job(s) found at start in %s", n, pendingDir())
if auth.HasXVeraeToken() {
if _, _, authFail := retryPending(stampFile); !authFail {
goto watch
}
}
handleAuthFailure("Print jobs from an earlier session are waiting for a valid Verae signing key.", stampFile)
}
watch:
for range time.Tick(30 * time.Second) {
if keyChangedSinceSeen() && len(listPending()) > 0 {
vlog.Printf("pending: a new key was saved; retrying held jobs")
retryPending(stampFile)
}
}
}
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
}