Print job queue
PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. %s
| Hold id | Document | State | Created |
|---|
package main // Held ("pending") print jobs: when a stamp fails because the key is missing, // invalid or expired (HTTP 401/403), the PDF is moved into a per-user pending // folder instead of being lost, ONE login prompt is shown (cross-process lock), // and after a new key is saved every pending job is retried automatically. // Cancelled → jobs stay pending and are retried on the next helper start, // the next successful login/key save, or the next successful print. import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" ) // errNoKey: no usable signing key is stored. var errNoKey = errors.New("no Verae signing key is set up") // isAuthFailure: the job can succeed after the user signs in again. func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) } // isBillingFailure: the job can succeed after the user adds funds on Zap Billing. func isBillingFailure(err error) bool { return veraeapi.IsBillingError(err) } type pendingMeta struct { ID string `json:"id"` Title string `json:"title"` Source string `json:"source"` OutDir string `json:"outDir,omitempty"` OutPath string `json:"outPath,omitempty"` // file chosen in the Save As dialog Reason string `json:"reason"` Created string `json:"created"` } func pendingDir() string { if d := os.Getenv("VERAE_PENDING_DIR"); d != "" { return d } base, err := os.UserConfigDir() // %APPDATA% / ~/Library/Application Support if err != nil { base = os.TempDir() } return filepath.Join(base, "Verae", "PrintStamp", "pending") } func (m pendingMeta) pdf() string { return filepath.Join(pendingDir(), m.ID+".pdf") } func (m pendingMeta) meta() string { return filepath.Join(pendingDir(), m.ID+".json") } func moveOrCopy(src, dst string, move bool) error { if move { if err := os.Rename(src, dst); err == nil { return nil } } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(dst) return err } if err := out.Close(); err != nil { return err } if move { _ = os.Remove(src) } return nil } // holdJob stores the PDF + metadata in the pending folder. func holdJob(pdfPath string, t stampTarget, source string, reason error, move bool) (pendingMeta, error) { if err := os.MkdirAll(pendingDir(), 0o700); err != nil { return pendingMeta{}, err } rb := make([]byte, 3) _, _ = rand.Read(rb) m := pendingMeta{ID: time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(rb), Title: t.Title, Source: source, OutDir: t.OutDir, OutPath: t.OutPath, Reason: reason.Error(), Created: time.Now().Format(time.RFC3339)} if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil { return pendingMeta{}, err } b, _ := json.MarshalIndent(m, "", " ") if err := os.WriteFile(m.meta(), b, 0o600); err != nil { return pendingMeta{}, err } return m, nil } func listPending() []pendingMeta { ents, _ := os.ReadDir(pendingDir()) var out []pendingMeta for _, e := range ents { if !strings.HasSuffix(e.Name(), ".json") { continue } b, err := os.ReadFile(filepath.Join(pendingDir(), e.Name())) if err != nil { continue } var m pendingMeta if json.Unmarshal(b, &m) != nil || m.ID == "" { continue } if _, err := os.Stat(m.pdf()); err != nil { continue } out = append(out, m) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func removePending(m pendingMeta) { _ = os.Remove(m.pdf()) _ = os.Remove(m.meta()) } // stampFn stamps one PDF with the CURRENT stored key. type stampFn func(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) // stampTarget says where results go: OutPath (Save As choice) or OutDir+Title. type stampTarget struct{ Title, OutDir, OutPath string } // acquireKeyHook replaces the interactive prompt in tests. var acquireKeyHook func(reason string) bool var retryMu sync.Mutex // one retry pass at a time within a process // retryPending re-stamps every pending job. Stops at the first auth failure. func retryPending(stamp stampFn) (done, remaining int, authFail bool) { retryMu.Lock() defer retryMu.Unlock() for _, m := range listPending() { lj := vlog.Job(vlog.NewJobID("pending")) lj.Printf("pending: retrying held job %s title=%q (held because: %s)", m.ID, m.Title, m.Reason) res, err := stamp(m.pdf(), stampTarget{Title: m.Title, OutDir: m.OutDir, OutPath: m.OutPath}, lj) if err != nil { if isAuthFailure(err) { lj.Errorf("pending: %s still not authorised: %v", m.ID, err) return done, len(listPending()), true } if isBillingFailure(err) { lj.Errorf("pending: %s still needs funds: %v", m.ID, err) offerBillingCheckout(billingReason(err)) return done, len(listPending()), false } lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err) continue } removePending(m) done++ lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF) notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", m.Title, res.ReceiptID)) openOnComplete(lj, res.CertificatePDF) } return done, len(listPending()), false } // promptLock guarantees a single login prompt across processes (macOS runs one // process per print job; Windows has the helper plus the Start-menu dialog). func promptLock() (func(), bool) { _ = os.MkdirAll(pendingDir(), 0o700) p := filepath.Join(pendingDir(), ".prompt.lock") for i := 0; i < 2; i++ { f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err == nil { fmt.Fprintf(f, "%d %s\n", os.Getpid(), time.Now().Format(time.RFC3339)) f.Close() return func() { _ = os.Remove(p) }, true } st, serr := os.Stat(p) if serr == nil && time.Since(st.ModTime()) > 20*time.Minute { _ = os.Remove(p) // stale (crashed prompt) continue } return func() {}, false } return func() {}, false } // billingOpenThrottle is how long we suppress re-opening the Billing tab // after a credit failure (avoids a browser storm while jobs stay held). const billingOpenThrottle = 10 * time.Minute // offerBillingCheckout opens the Zap Billing reload page at most once per // billingOpenThrottle. Safe across processes via a lock file in pendingDir. func offerBillingCheckout(reason string) { _ = os.MkdirAll(pendingDir(), 0o700) marker := filepath.Join(pendingDir(), ".billing.open") if st, err := os.Stat(marker); err == nil && time.Since(st.ModTime()) < billingOpenThrottle { vlog.Printf("billing: checkout already offered %s ago; not re-opening (%d pending)", time.Since(st.ModTime()).Round(time.Second), len(listPending())) return } _ = os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)+"\n"+reason+"\n"), 0o600) vlog.Printf("billing: opening %s (%d pending)", auth.ZapBillingURL, len(listPending())) notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(reason)) if err := openBrowser(auth.ZapBillingURL); err != nil { vlog.Errorf("billing: could not open browser: %v — open %s", err, auth.ZapBillingURL) fmt.Fprintf(os.Stderr, "Out of credits. Open Billing to add funds:\n %s\n", auth.ZapBillingURL) } } // handleBillingFailure holds the print job narrative for the user and opens // Zap Billing. Does not prompt for a new signing key. func handleBillingFailure(reason string) { offerBillingCheckout(reason) } // handleAuthFailure shows ONE login prompt (if none is open), then retries all // pending jobs. Returns true when everything pending was stamped. func handleAuthFailure(reason string, stamp stampFn) bool { release, ok := promptLock() if !ok { vlog.Printf("pending: a Verae sign-in prompt is already open; job queued (%d pending)", len(listPending())) return false } defer release() for attempt := 0; attempt < 3; attempt++ { n := len(listPending()) if n == 0 { return true } msg := reason + "\n\n" + strconv.Itoa(n) + " print job(s) are saved and will be timestamped as soon as you sign in." vlog.Printf("pending: prompting for sign-in (%d job(s) held)", n) acquire := acquireKeyInteractive if acquireKeyHook != nil { acquire = acquireKeyHook } if !acquire(msg) { vlog.Printf("pending: sign-in cancelled; %d job(s) kept in %s", n, pendingDir()) ht, hb := statuslang.NotifyHeldJobs(n) notifyUser(ht, notifyQueueHint(hb)) return false } markKeySeen() done, remaining, authFail := retryPending(stamp) vlog.Printf("pending: after sign-in: %d stamped, %d remaining", done, remaining) if !authFail { return remaining == 0 } reason = "Verae did not accept the new key either." } return false } // keyFingerprint identifies the stored key without revealing it. func keyFingerprint() string { k, err := auth.LoadXVeraeToken() if err != nil { return "" } h := sha256.Sum256([]byte(k)) return hex.EncodeToString(h[:6]) } var ( seenMu sync.Mutex seenKey string ) func markKeySeen() { seenMu.Lock(); seenKey = keyFingerprint(); seenMu.Unlock() } // keyChangedSinceSeen: a key was saved elsewhere (Start-menu dialog, installer). func keyChangedSinceSeen() bool { fp := keyFingerprint() seenMu.Lock() defer seenMu.Unlock() if fp != "" && fp != seenKey { seenKey = fp return true } return false } // runPendingList prints the held job queue (PT-04 / PT-W4 style) using brand status strings. // Flags: -json (machine-readable), -html (self-contained local queue page). func runPendingList(args []string) int { fs := flag.NewFlagSet("pending", flag.ContinueOnError) fs.SetOutput(os.Stderr) asJSON := fs.Bool("json", false, "machine-readable JSON (held jobs only; hold id is not evidence)") asHTML := fs.Bool("html", false, "self-contained HTML job queue (PT-04/W4 labels)") if err := fs.Parse(args); err != nil { return 2 } list := listPending() if *asJSON { b, err := encodePendingJSON(list, traySnapshot("")) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } _, _ = os.Stdout.Write(b) return 0 } if *asHTML { _, err := io.WriteString(os.Stdout, renderPendingHTML(list, traySnapshot(""))) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return 0 } fmt.Printf("Pending print jobs · %s\n", statuslang.Pending) fmt.Printf("Folder: %s\n", pendingDir()) if len(list) == 0 { fmt.Println("(empty) — nothing waiting for a signing key") fmt.Println("Note:", statuslang.EvidenceNote) return 0 } fmt.Printf("%-22s %-28s %-16s %s\n", "HOLD_ID", "TITLE", "STATE", "CREATED") for _, m := range list { title := m.Title if title == "" { title = "(untitled)" } if len(title) > 28 { title = title[:25] + "…" } fmt.Printf("%-22s %-28s %-16s %s\n", m.ID, title, statuslang.Pending, m.Created) } fmt.Printf("\n%d held · sign in / setup key to stamp\n", len(list)) fmt.Println(statuslang.EvidenceNote) return 0 } type pendingJSONRow struct { HoldID string `json:"holdId"` Title string `json:"title"` Source string `json:"source,omitempty"` State string `json:"state"` Reason string `json:"reason,omitempty"` Created string `json:"created"` } type pendingJSONOut struct { Headline string `json:"headline"` Detail string `json:"detail"` State string `json:"state"` Folder string `json:"folder"` Count int `json:"count"` KeyOK bool `json:"keyOk"` HelperUp bool `json:"helperUp"` QueueURL string `json:"queueUrl,omitempty"` Note string `json:"note"` Jobs []pendingJSONRow `json:"jobs"` } func encodePendingJSON(list []pendingMeta, snap traySnap) ([]byte, error) { out := pendingJSONOut{ Headline: snap.Headline, Detail: snap.Detail, State: statuslang.Pending, Folder: pendingDir(), Count: len(list), KeyOK: snap.KeyOK, HelperUp: snap.HelperUp, QueueURL: snap.QueueURL, Note: statuslang.EvidenceNote, Jobs: make([]pendingJSONRow, 0, len(list)), } if len(list) == 0 && snap.KeyOK { out.State = statuslang.PrinterReady } for _, m := range list { title := m.Title if title == "" { title = "(untitled)" } out.Jobs = append(out.Jobs, pendingJSONRow{ HoldID: m.ID, Title: title, Source: m.Source, State: statuslang.Pending, Reason: m.Reason, Created: m.Created, }) } return json.MarshalIndent(out, "", " ") } func htmlEsc(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s) } // renderPendingHTML is the PT-04/W4 queue page (also served at helper GET /queue). func renderPendingHTML(list []pendingMeta, snap traySnap) string { var rows strings.Builder if len(list) == 0 { rows.WriteString(`
PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. %s
| Hold id | Document | State | Created |
|---|