- 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
260 lines
7.7 KiB
Go
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
|
|
}
|