- 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
269 lines
8.5 KiB
Go
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
|
|
}
|