Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
On HTTP 402/429 (and credit/billing error bodies), hold the print job and open https://zap.verae.ai/portal/?tab=billing so the user can reload prepaid balance. Throttle browser opens to once per 10 minutes. Auth failures still use the sign-in flow unchanged.
444 lines
15 KiB
Go
444 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
|
)
|
|
|
|
func main() {
|
|
// verae-printhelper.exe (GUI subsystem, no console) defaults to "serve".
|
|
if len(os.Args) < 2 && isHelperExe() {
|
|
os.Args = append(os.Args, "serve")
|
|
}
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
switch os.Args[1] {
|
|
case "stamp":
|
|
os.Exit(runStamp(os.Args[2:]))
|
|
case "serve":
|
|
os.Exit(runServe(os.Args[2:]))
|
|
case "ipp-selftest":
|
|
os.Exit(runIPPSelftest(os.Args[2:]))
|
|
case "installer-account":
|
|
os.Exit(runInstallerAccount(os.Args[2:]))
|
|
case "validate-key":
|
|
os.Exit(runValidateKey(os.Args[2:]))
|
|
case "config":
|
|
os.Exit(runConfig(os.Args[2:]))
|
|
case "pending", "queue":
|
|
os.Exit(runPendingList(os.Args[2:]))
|
|
case "tray-status", "status":
|
|
os.Exit(runTrayStatus(os.Args[2:]))
|
|
case "menubar", "tray":
|
|
os.Exit(runMenubar(os.Args[2:]))
|
|
case "mac-key-setup":
|
|
os.Exit(runMacKeySetup(os.Args[2:]))
|
|
case "key-dialog":
|
|
os.Exit(runKeyDialog())
|
|
case "setup", "login":
|
|
os.Exit(runSetup())
|
|
case "install-macos":
|
|
os.Exit(runInstallMacOS(os.Args[2:]))
|
|
case "install-printer":
|
|
os.Exit(runInstallPrinter())
|
|
case "version", "-version", "--version":
|
|
fmt.Println(job.HelperVersion)
|
|
if len(os.Args) > 2 {
|
|
fmt.Println("logs:", vlog.Dir())
|
|
}
|
|
case "help", "-h", "--help":
|
|
usage()
|
|
default:
|
|
// PDF Services / drop-target may invoke the binary with the file as argv[1]
|
|
if fileExists(os.Args[1]) {
|
|
os.Exit(runStamp([]string{"-channel", string(auth.PreferredChannel()), os.Args[1]}))
|
|
}
|
|
fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintf(os.Stderr, `verae-print %s — stamp a printed PDF with Verae (digests only)
|
|
|
|
Usage:
|
|
verae-print setup # first run: create customer account + save xVerae- signing key
|
|
verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] <file.pdf>
|
|
verae-print install-printer # Destination list: Verae Timestamping (recommended)
|
|
verae-print install-macos # also install PDF Services menu items (legacy)
|
|
verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug]
|
|
# Windows print helper: IPP loopback printer + fallback watcher
|
|
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
|
|
verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH]
|
|
# after each print: reveal the certificate (folder, default), open it (pdf), or nothing
|
|
verae-print pending [--json|--html] # PT-04/W4 job queue (brand labels; hold id ≠ evidence)
|
|
verae-print tray-status [--json|--open] # PT-W3 tray substitute: held count, key, helper, queue URL
|
|
verae-print menubar [--addr host:port] [--install|--uninstall|--foreground]
|
|
# PT-W3/PT-06: Windows NotifyIcon / macOS menu bar (GET /status)
|
|
# --install: Win Startup shortcut or macOS LaunchAgent at login
|
|
verae-print mac-key-setup # macOS: native dialogs to keep/sign in/create account/paste a key (run as the user)
|
|
verae-print version
|
|
|
|
Debug logging is off by default; set VERAE_LOG_LEVEL=debug (or "logLevel": "debug" in
|
|
~/.verae/config.json, or serve --debug) for full IPP request dumps. Logs: see "verae-print version -v".
|
|
|
|
First install opens https://customer.verae.ai/portal/ so you can create an account
|
|
and mint an xVerae- signing key (Security tab). The key is stored in Keychain.
|
|
|
|
Channels:
|
|
zapier (default) zap.verae.ai + customer xVerae- signing key
|
|
verae api.veraetime.net + ~/.verae/auth.properties JWT
|
|
|
|
Outputs in --out-dir (default ~/Documents/Verae Timestamps):
|
|
{name}.pdf
|
|
{name}-verae-timestamp-{RECEIPTID}.pdf
|
|
{name}.verae.json
|
|
`, job.HelperVersion)
|
|
}
|
|
|
|
func runSetup() int {
|
|
if auth.HasXVeraeToken() {
|
|
fmt.Println("Signing key already saved (xVerae-).")
|
|
fmt.Println("Portal:", auth.PortalSignupURL)
|
|
return 0
|
|
}
|
|
if err := auth.RunFirstRunSetup(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
fmt.Println("Signing key saved. Print → Destination → Verae Timestamping is ready.")
|
|
return 0
|
|
}
|
|
|
|
func runStamp(args []string) int {
|
|
fs := flag.NewFlagSet("stamp", flag.ExitOnError)
|
|
channel := fs.String("channel", string(auth.PreferredChannel()), "verae | zapier")
|
|
outDir := fs.String("out-dir", "", "output directory")
|
|
name := fs.String("name", "", "document stem override")
|
|
openFolder := fs.Bool("open", false, "on success, apply the OpenOnComplete setting (folder|pdf|none; see 'verae-print config')")
|
|
skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog")
|
|
jobFlag := fs.String("job-id", "", "debug job id (for log correlation)")
|
|
titleFlag := fs.String("title", "", "job title used to prefill the Save As name (with -prompt-name)")
|
|
promptName := fs.Bool("prompt-name", false, "printer jobs: show a Save As dialog first when promptForFilename is on (cancel discards the job)")
|
|
_ = fs.Parse(args)
|
|
if fs.NArg() < 1 {
|
|
fmt.Fprintln(os.Stderr, "stamp: PDF path required")
|
|
return 2
|
|
}
|
|
pdfPath := fs.Arg(0)
|
|
jobID := *jobFlag
|
|
if jobID == "" {
|
|
jobID = vlog.NewJobID("cli")
|
|
}
|
|
vlog.Init("cli", false)
|
|
lj := vlog.Job(jobID)
|
|
lj.Printf("stamp start pdf=%q channel=%s out=%q name=%q", pdfPath, *channel, *outDir, *name)
|
|
|
|
ch, err := auth.ParseChannel(*channel)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 2
|
|
}
|
|
|
|
outPath := ""
|
|
if *promptName {
|
|
if cfg, _ := settings.Load(); cfg.Prompt() {
|
|
t := *titleFlag
|
|
if t == "" && *name != "document" {
|
|
t = *name
|
|
}
|
|
p, ok := askSaveTarget(lj, t, *outDir)
|
|
if !ok {
|
|
lj.Printf("stamp: Save As cancelled by user — job discarded (not stamped, no credit used)")
|
|
fmt.Println("Cancelled: the print was discarded (not timestamped).")
|
|
return 0
|
|
}
|
|
outPath = p
|
|
}
|
|
}
|
|
|
|
// First-run: customer account + xVerae- signing key (preferred path)
|
|
if ch == auth.ChannelZapier && !auth.HasXVeraeToken() {
|
|
if *skipSetup {
|
|
fmt.Fprintf(os.Stderr, "no xVerae- signing key — run: verae-print setup\nPortal: %s\n", auth.PortalSignupURL)
|
|
return 1
|
|
}
|
|
if err := auth.EnsureReady(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
notify("Create a Verae customer account to get your signing key", auth.PortalSignupURL)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
client := &veraeapi.Client{Channel: ch}
|
|
switch ch {
|
|
case auth.ChannelZapier:
|
|
tok, err := auth.LoadXVeraeToken()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
client.XVeraeToken = tok
|
|
default:
|
|
tok, err := auth.LoadVeraeJWT()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\nTip: run verae-print setup to create a customer account and xVerae- key instead.\n", err)
|
|
return 1
|
|
}
|
|
client.VeraeToken = tok
|
|
}
|
|
|
|
res, err := job.Run(job.Options{
|
|
SourcePDF: pdfPath,
|
|
OutDir: *outDir,
|
|
OutPath: outPath,
|
|
DocumentName: *name,
|
|
Channel: ch,
|
|
Client: client,
|
|
})
|
|
if err != nil && isAuthFailure(err) {
|
|
// Key invalid/expired: keep the job, ask the user to sign in (one prompt
|
|
// at a time across processes), then stamp everything that is pending.
|
|
title := *name
|
|
if title == "" {
|
|
title = filepath.Base(pdfPath)
|
|
}
|
|
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
|
|
if herr != nil {
|
|
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
|
return 1
|
|
}
|
|
lj.Printf("stamp: key rejected (%v); job HELD as pending %s", err, m.ID)
|
|
fmt.Printf("Verae did not accept the signing key; the job is saved (pending %s) and will be timestamped after you sign in.\n", m.ID)
|
|
notify(statuslang.NotifyTitle("held"), notifyQueueHint(statuslang.NotifyBody("held", title, m.ID)))
|
|
handleAuthFailure(authReason(err), stampFile)
|
|
return 0
|
|
}
|
|
if err != nil && isBillingFailure(err) {
|
|
title := *name
|
|
if title == "" {
|
|
title = filepath.Base(pdfPath)
|
|
}
|
|
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
|
|
if herr != nil {
|
|
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
|
fmt.Fprintf(os.Stderr, "Add funds: %s\n", auth.ZapBillingURL)
|
|
_ = openBrowser(auth.ZapBillingURL)
|
|
return 1
|
|
}
|
|
lj.Printf("stamp: out of credits (%v); job HELD as pending %s — opening Billing", err, m.ID)
|
|
fmt.Printf("Out of credits; the job is saved (pending %s). Opening Billing so you can add funds:\n %s\n", m.ID, auth.ZapBillingURL)
|
|
notify(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", title, m.ID)))
|
|
handleBillingFailure(billingReason(err))
|
|
return 0
|
|
}
|
|
if err != nil {
|
|
lj.Errorf("stamp failed: %v", err)
|
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
|
failTitle := *name
|
|
if failTitle == "" {
|
|
failTitle = filepath.Base(pdfPath)
|
|
}
|
|
notify(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", failTitle, err.Error()))
|
|
return 1
|
|
}
|
|
lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF)
|
|
|
|
fmt.Printf("Saved:\n %s\n %s\n %s\nReceipt: %s\nSHA-256: %s\nChannel: %s\n",
|
|
res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel)
|
|
|
|
if *openFolder {
|
|
openOnComplete(lj, res.CertificatePDF)
|
|
}
|
|
// the key works: stamp anything held from earlier failures
|
|
if len(listPending()) > 0 {
|
|
done, remaining, _ := retryPending(stampFile)
|
|
lj.Printf("pending: %d stamped, %d remaining", done, remaining)
|
|
}
|
|
docTitle := *name
|
|
if docTitle == "" {
|
|
docTitle = filepath.Base(pdfPath)
|
|
}
|
|
notify(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", docTitle, res.ReceiptID))
|
|
return 0
|
|
}
|
|
|
|
func runInstallPrinter() int {
|
|
if runtime.GOOS == "windows" {
|
|
return runInstallPrinterWindows()
|
|
}
|
|
script := filepath.Join(repoRootHint(), "platform", "macos", "ipp", "install-ipp-printer.sh")
|
|
if _, err := os.Stat(script); err != nil {
|
|
// fall back next to executable
|
|
exe, _ := os.Executable()
|
|
exe, _ = filepath.EvalSymlinks(exe)
|
|
script = filepath.Join(filepath.Dir(exe), "..", "platform", "macos", "ipp", "install-ipp-printer.sh")
|
|
}
|
|
if abs, err := filepath.Abs(script); err == nil {
|
|
script = abs
|
|
}
|
|
cmd := exec.Command("/bin/bash", script)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = os.Environ()
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "install-printer failed: %v\n(run platform/macos/ipp/install-ipp-printer.sh)\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func repoRootHint() string {
|
|
// Prefer the go.mod tree that owns this binary (A4 packaging source of truth).
|
|
// No machine-specific absolute paths — walk cwd and exe parents.
|
|
var starts []string
|
|
if wd, err := os.Getwd(); err == nil {
|
|
starts = append(starts, wd)
|
|
}
|
|
starts = append(starts, exeDirHint())
|
|
if exe := exeDirHint(); exe != "." {
|
|
starts = append(starts, filepath.Join(exe, ".."), filepath.Join(exe, "..", ".."))
|
|
}
|
|
starts = append(starts, ".")
|
|
seen := map[string]bool{}
|
|
for _, start := range starts {
|
|
dir, err := filepath.Abs(start)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
if seen[dir] {
|
|
break
|
|
}
|
|
seen[dir] = true
|
|
if fileExists(filepath.Join(dir, "go.mod")) {
|
|
// Any go.mod with platform/ is good enough for install scripts.
|
|
if fileExists(filepath.Join(dir, "platform")) ||
|
|
fileExists(filepath.Join(dir, "internal", "job", "job.go")) {
|
|
return dir
|
|
}
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
return "."
|
|
}
|
|
|
|
func runInstallMacOS(args []string) int {
|
|
fs := flag.NewFlagSet("install-macos", flag.ExitOnError)
|
|
bin := fs.String("bin", "", "path to verae-print binary (default: this executable)")
|
|
_ = fs.Parse(args)
|
|
|
|
exe := *bin
|
|
if exe == "" {
|
|
var err error
|
|
exe, err = os.Executable()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
exe, _ = filepath.EvalSymlinks(exe)
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
destDir := filepath.Join(home, "Library", "PDF Services")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
|
|
items := []struct {
|
|
name string
|
|
channel string
|
|
}{
|
|
{"Timestamp with Verae", "verae"},
|
|
{"Timestamp with Verae (Zapier)", "zapier"},
|
|
}
|
|
for _, it := range items {
|
|
appDir := filepath.Join(destDir, it.name+".app", "Contents", "MacOS")
|
|
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
plist := filepath.Join(destDir, it.name+".app", "Contents", "Info.plist")
|
|
plistBody := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0"><dict>
|
|
<key>CFBundleExecutable</key><string>runner</string>
|
|
<key>CFBundleIdentifier</key><string>ai.verae.print.%s</string>
|
|
<key>CFBundleName</key><string>%s</string>
|
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
<key>CFBundleShortVersionString</key><string>%s</string>
|
|
</dict></plist>
|
|
`, strings.ReplaceAll(it.channel, " ", ""), it.name, job.HelperVersion)
|
|
if err := os.WriteFile(plist, []byte(plistBody), 0o644); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
runner := filepath.Join(appDir, "runner")
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
set -euo pipefail
|
|
LOG="${HOME}/Library/Logs/verae-print.log"
|
|
mkdir -p "$(dirname "$LOG")"
|
|
exec >>"$LOG" 2>&1
|
|
echo "$(date -u +%%Y-%%m-%%dT%%H:%%MZ) channel=%s args: $*"
|
|
PDF=""
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
*.pdf|*.PDF) PDF="$a" ;;
|
|
esac
|
|
done
|
|
if [[ -z "${PDF}" && $# -ge 1 ]]; then PDF="$1"; fi
|
|
if [[ -z "${PDF}" || ! -f "${PDF}" ]]; then
|
|
osascript -e 'display notification "No PDF received from Print dialog" with title "Verae Timestamp"' || true
|
|
exit 1
|
|
fi
|
|
%q stamp -channel %s -open "$PDF"
|
|
`, it.channel, exe, it.channel)
|
|
if err := os.WriteFile(runner, []byte(script), 0o755); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
fmt.Println("Installed:", filepath.Join(destDir, it.name+".app"))
|
|
}
|
|
fmt.Println("Print → PDF → Timestamp with Verae (or Zapier)")
|
|
fmt.Println("Binary:", exe)
|
|
return 0
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
st, err := os.Stat(p)
|
|
return err == nil && !st.IsDir()
|
|
}
|