PrintToVeraeTimeStamp/cmd/verae-print/pending.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

260 lines
7.7 KiB
Go

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"
"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/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) }
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
}
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(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
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
}
// 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())
notifyUser("Verae: print jobs waiting", fmt.Sprintf("%d job(s) will be timestamped after you sign in.", n))
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
}