feat: 2026 brand UI — statuslang, tray/menubar, CERT pdf, site kit
- Brand receipt status strings (statuslang) for notify/queue - Windows tray + Start Menu/Startup install; macOS menubar helper - CERT-01 plum/gold PDF + multihash/blake3 when present - printit.verae.ai brand kit, queue page, customer-auth helper - verae-print pending/tray-status/menubar commands
This commit is contained in:
parent
10814f0bfb
commit
48da24a661
45 changed files with 5005 additions and 185 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@
|
|||
node_modules/
|
||||
/build/
|
||||
Verae-Secret-Credentials.MD
|
||||
# Compiled PT-06 menu bar (built by run-menubar.sh / swiftc)
|
||||
platform/macos/menubar/verae-menubar
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -29,7 +29,22 @@ go build ./...
|
|||
## Usage
|
||||
|
||||
```sh
|
||||
verae-print --channel=zapier # default billed path
|
||||
verae-print stamp --channel=zapier file.pdf # default billed path
|
||||
verae-print serve # IPP helper + loopback status
|
||||
verae-print tray-status # held/key/helper snapshot
|
||||
verae-print tray-status --open # open http://127.0.0.1:8631/queue
|
||||
verae-print menubar # PT-W3/PT-06: tray (Win) or menu bar (macOS)
|
||||
verae-print menubar --install # login presence (Win Startup / macOS LaunchAgent)
|
||||
verae-print menubar --uninstall # remove login presence
|
||||
verae-print pending --html # offline queue page
|
||||
# Live while helper runs: GET /queue (HTML), GET /status (JSON), GET / (health)
|
||||
# Windows tray: platform/windows/scripts/verae-tray.ps1
|
||||
# Windows Startup: platform/windows/scripts/install-tray-startup.ps1
|
||||
# Windows Start Menu (WiX-free stub): platform/windows/scripts/build-tray-startmenu.ps1
|
||||
# macOS menu bar: platform/macos/menubar/run-menubar.sh
|
||||
# macOS durable LaunchAgent assets: ~/Library/Application Support/Verae/menubar/
|
||||
# macOS optional .app (unsigned): platform/macos/menubar/build-menubar-app.sh
|
||||
# macOS notarization steps (docs only): platform/macos/menubar/README.txt
|
||||
# Direct chain only with VERAE_ALLOW_DIRECT_VERAE=1
|
||||
```
|
||||
|
||||
|
|
|
|||
7
cmd/verae-print/hide_other.go
Normal file
7
cmd/verae-print/hide_other.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func hideWindow(c *exec.Cmd) {}
|
||||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"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() {
|
||||
|
|
@ -38,6 +39,12 @@ func main() {
|
|||
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":
|
||||
|
|
@ -79,6 +86,11 @@ Usage:
|
|||
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
|
||||
|
||||
|
|
@ -214,12 +226,18 @@ func runStamp(args []string) int {
|
|||
}
|
||||
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 {
|
||||
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)
|
||||
|
|
@ -235,8 +253,11 @@ func runStamp(args []string) int {
|
|||
done, remaining, _ := retryPending(stampFile)
|
||||
lj.Printf("pending: %d stamped, %d remaining", done, remaining)
|
||||
}
|
||||
// macOS notification (best-effort)
|
||||
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
docTitle := *name
|
||||
if docTitle == "" {
|
||||
docTitle = filepath.Base(pdfPath)
|
||||
}
|
||||
notify(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", docTitle, res.ReceiptID))
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
577
cmd/verae-print/menubar.go
Normal file
577
cmd/verae-print/menubar.go
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
package main
|
||||
|
||||
// PT-W3 / PT-06 tray / menu-bar launcher.
|
||||
// Ships a working presence without a multi-month native C++ tray:
|
||||
// Windows → platform/windows/scripts/verae-tray.ps1 (NotifyIcon + /status poll)
|
||||
// → Startup shortcut via menubar --install (or install-tray-startup.ps1)
|
||||
// → Start Menu folder via build-tray-startmenu.ps1 (WiX/NSIS-free stub)
|
||||
// macOS → platform/macos/menubar/run-menubar.sh (NSStatusItem via Swift)
|
||||
// → durable copy under ~/Library/Application Support/Verae/menubar
|
||||
// → LaunchAgent via menubar --install; optional .app via build-menubar-app.sh
|
||||
// IPP POST stays pure; this only opens scripts that GET /status and /queue.
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
menubarLaunchLabel = "com.verae.timestamping.menubar"
|
||||
winStartupLnkName = "Verae Tray.lnk"
|
||||
)
|
||||
|
||||
// runMenubar starts the platform tray/menu-bar helper, or prints install hints.
|
||||
func runMenubar(args []string) int {
|
||||
fs := flag.NewFlagSet("menubar", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
addr := fs.String("addr", defaultHelperAddr(), "helper loopback address for /status and /queue")
|
||||
install := fs.Bool("install", false, "install at login (Windows Startup shortcut; macOS LaunchAgent)")
|
||||
uninstall := fs.Bool("uninstall", false, "remove login install (Windows Startup shortcut; macOS LaunchAgent)")
|
||||
foreground := fs.Bool("foreground", false, "run attached (default: detach on Windows/macOS)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
_ = os.Setenv("VERAE_IPP_ADDR", *addr)
|
||||
|
||||
if *uninstall {
|
||||
return menubarUninstall()
|
||||
}
|
||||
if *install {
|
||||
return menubarInstall(*addr)
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return startWindowsTray(*addr, *foreground)
|
||||
case "darwin":
|
||||
return startDarwinMenubar(*addr, *foreground)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "menubar: no tray script on this OS — use: verae-print tray-status --open")
|
||||
fmt.Fprintln(os.Stderr, " loopback: GET http://"+*addr+"/queue and GET http://"+*addr+"/status")
|
||||
return runTrayStatus([]string{"--open", "--addr", *addr})
|
||||
}
|
||||
}
|
||||
|
||||
func startWindowsTray(addr string, foreground bool) int {
|
||||
script, err := locateWindowsTrayScript()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: verae-tray.ps1 not found (%v)\n", err)
|
||||
fmt.Fprintln(os.Stderr, "fallback: verae-print tray-status --open")
|
||||
return runTrayStatus([]string{"--open", "--addr", addr})
|
||||
}
|
||||
ps := windowsPowerShell()
|
||||
args := []string{
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-STA",
|
||||
"-File", script,
|
||||
"-Addr", addr,
|
||||
}
|
||||
cmd := exec.Command(ps, args...)
|
||||
if foreground {
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: tray exited: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
hideWindow(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: start tray: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("Verae tray started (pid %d) · polls http://%s/status · queue http://%s/queue\n", cmd.Process.Pid, addr, addr)
|
||||
fmt.Println("Install at login: verae-print menubar --install")
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
|
||||
func startDarwinMenubar(addr string, foreground bool) int {
|
||||
script, err := locateDarwinMenubarScript()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: run-menubar.sh not found (%v)\n", err)
|
||||
fmt.Fprintln(os.Stderr, "fallback: verae-print tray-status --open")
|
||||
return runTrayStatus([]string{"--open", "--addr", addr})
|
||||
}
|
||||
_ = os.Chmod(script, 0o755)
|
||||
cmd := exec.Command("/bin/bash", script)
|
||||
cmd.Env = append(os.Environ(), "VERAE_IPP_ADDR="+addr)
|
||||
if foreground {
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: start: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("Verae menu bar started (pid %d) · polls http://%s/status · queue http://%s/queue\n", cmd.Process.Pid, addr, addr)
|
||||
fmt.Println("Install at login: verae-print menubar --install")
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
|
||||
func menubarInstall(addr string) int {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return menubarInstallWindows(addr)
|
||||
case "darwin":
|
||||
return menubarInstallDarwin(addr)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "menubar --install: no tray packaging on this OS")
|
||||
fmt.Fprintln(os.Stderr, " use: verae-print tray-status --open")
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func menubarUninstall() int {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return menubarUninstallWindows()
|
||||
case "darwin":
|
||||
return menubarUninstallDarwin()
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "menubar --uninstall: no tray packaging on this OS")
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// --- Windows Startup shortcut ------------------------------------------------
|
||||
|
||||
func menubarInstallWindows(addr string) int {
|
||||
tray, err := locateWindowsTrayScript()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
installer, err := locateWindowsStartupInstaller()
|
||||
if err == nil {
|
||||
ps := windowsPowerShell()
|
||||
cmd := exec.Command(ps,
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", installer,
|
||||
"-Addr", addr,
|
||||
"-TrayScript", tray,
|
||||
)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// Fallback without install-tray-startup.ps1: create .lnk via PowerShell COM.
|
||||
if err := writeWindowsStartupShortcut(tray, addr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("Installed Startup shortcut:", windowsStartupShortcutPath())
|
||||
fmt.Println("Tray starts at next login. Run now: verae-print menubar")
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
|
||||
func menubarUninstallWindows() int {
|
||||
installer, err := locateWindowsStartupInstaller()
|
||||
if err == nil {
|
||||
ps := windowsPowerShell()
|
||||
cmd := exec.Command(ps,
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", installer,
|
||||
"-Uninstall",
|
||||
)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar --uninstall: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
path := windowsStartupShortcutPath()
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("Removed Startup shortcut:", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
func windowsStartupShortcutPath() string {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", winStartupLnkName)
|
||||
}
|
||||
|
||||
func writeWindowsStartupShortcut(trayScript, addr string) error {
|
||||
ps := windowsPowerShell()
|
||||
lnk := windowsStartupShortcutPath()
|
||||
if err := os.MkdirAll(filepath.Dir(lnk), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Escape single quotes for PowerShell single-quoted strings.
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
psExe := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
if _, err := os.Stat(psExe); err != nil {
|
||||
psExe = "powershell.exe"
|
||||
}
|
||||
args := fmt.Sprintf(`-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "%s" -Addr %s`, trayScript, addr)
|
||||
script := fmt.Sprintf(`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$sc = $wsh.CreateShortcut('%s')
|
||||
$sc.TargetPath = '%s'
|
||||
$sc.Arguments = '%s'
|
||||
$sc.WorkingDirectory = '%s'
|
||||
$sc.WindowStyle = 7
|
||||
$sc.Description = 'Verae Timestamping tray — polls GET /status; hold id ≠ evidence.'
|
||||
$sc.Save()
|
||||
`, esc(lnk), esc(psExe), esc(args), esc(filepath.Dir(trayScript)))
|
||||
cmd := exec.Command(ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func windowsPowerShell() string {
|
||||
ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
if _, err := os.Stat(ps); err != nil {
|
||||
return "powershell.exe"
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
func locateWindowsTrayScript() (string, error) {
|
||||
script, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1")
|
||||
if err == nil {
|
||||
return script, nil
|
||||
}
|
||||
// Installer layout next to the exe.
|
||||
for _, alt := range []string{
|
||||
filepath.Join(exeDirHint(), "scripts", "verae-tray.ps1"),
|
||||
filepath.Join(exeDirHint(), "..", "scripts", "verae-tray.ps1"),
|
||||
filepath.Join(exeDirHint(), "verae-tray.ps1"),
|
||||
} {
|
||||
if fileExists(alt) {
|
||||
abs, _ := filepath.Abs(alt)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func locateWindowsStartupInstaller() (string, error) {
|
||||
script, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1")
|
||||
if err == nil {
|
||||
return script, nil
|
||||
}
|
||||
for _, alt := range []string{
|
||||
filepath.Join(exeDirHint(), "scripts", "install-tray-startup.ps1"),
|
||||
filepath.Join(exeDirHint(), "..", "scripts", "install-tray-startup.ps1"),
|
||||
} {
|
||||
if fileExists(alt) {
|
||||
abs, _ := filepath.Abs(alt)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// --- macOS durable LaunchAgent -----------------------------------------------
|
||||
|
||||
func menubarSupportDir(home string) string {
|
||||
return filepath.Join(home, "Library", "Application Support", "Verae", "menubar")
|
||||
}
|
||||
|
||||
func menubarInstallDarwin(addr string) int {
|
||||
srcScript, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
srcDir := filepath.Dir(srcScript)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
support := menubarSupportDir(home)
|
||||
if err := os.MkdirAll(support, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
// Copy durable assets so LaunchAgent does not depend on the checkout path.
|
||||
for _, name := range []string{"run-menubar.sh", "verae-menubar.swift", "com.verae.timestamping.menubar.plist"} {
|
||||
src := filepath.Join(srcDir, name)
|
||||
if !fileExists(src) {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(support, name)
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar --install: copy %s: %v\n", name, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
script := filepath.Join(support, "run-menubar.sh")
|
||||
_ = os.Chmod(script, 0o755)
|
||||
|
||||
// Best-effort compile so LaunchAgent does not need swift at login.
|
||||
bin := filepath.Join(support, "verae-menubar")
|
||||
swiftSrc := filepath.Join(support, "verae-menubar.swift")
|
||||
if fileExists(swiftSrc) {
|
||||
if path, lookErr := exec.LookPath("swiftc"); lookErr == nil {
|
||||
c := exec.Command(path, "-O", "-o", bin, swiftSrc)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: swiftc optional compile failed (will use swift at run): %v\n%s\n", err, strings.TrimSpace(string(out)))
|
||||
} else {
|
||||
_ = os.Chmod(bin, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logDir := filepath.Join(home, "Library", "Logs")
|
||||
_ = os.MkdirAll(logDir, 0o755)
|
||||
agents := filepath.Join(home, "Library", "LaunchAgents")
|
||||
_ = os.MkdirAll(agents, 0o755)
|
||||
plistPath := filepath.Join(agents, menubarLaunchLabel+".plist")
|
||||
|
||||
body := menubarPlistBody(script, logDir, addr)
|
||||
// Prefer rewriting the template if present.
|
||||
if tpl := filepath.Join(support, "com.verae.timestamping.menubar.plist"); fileExists(tpl) {
|
||||
raw, err := os.ReadFile(tpl)
|
||||
if err == nil {
|
||||
s := string(raw)
|
||||
s = strings.ReplaceAll(s, "MENUBAR_SCRIPT_PLACEHOLDER", script)
|
||||
s = strings.ReplaceAll(s, "LOG_DIR_PLACEHOLDER", logDir)
|
||||
// Ensure VERAE_IPP_ADDR matches --addr.
|
||||
if strings.Contains(s, "<key>VERAE_IPP_ADDR</key>") {
|
||||
// Replace the next <string>…</string> after VERAE_IPP_ADDR.
|
||||
s = replaceEnvString(s, "VERAE_IPP_ADDR", addr)
|
||||
}
|
||||
body = s
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(plistPath, []byte(body), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := launchctlLoadMenubar(plistPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "menubar: wrote %s but launchctl load failed: %v\n", plistPath, err)
|
||||
fmt.Fprintln(os.Stderr, " load manually after login, or: verae-print menubar")
|
||||
fmt.Fprintln(os.Stderr, " assets:", support)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("Installed LaunchAgent:", plistPath)
|
||||
fmt.Println("Durable assets:", support)
|
||||
fmt.Println("Menu bar polls http://" + addr + "/status · Open queue → /queue")
|
||||
fmt.Println("Optional .app: platform/macos/menubar/build-menubar-app.sh")
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
|
||||
func menubarUninstallDarwin() int {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
plistPath := filepath.Join(home, "Library", "LaunchAgents", menubarLaunchLabel+".plist")
|
||||
_ = launchctlUnloadMenubar(plistPath)
|
||||
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
// Leave Application Support assets; user may still run menubar manually.
|
||||
fmt.Println("Removed LaunchAgent:", plistPath)
|
||||
fmt.Println("Assets kept at:", menubarSupportDir(home), "(delete manually if desired)")
|
||||
return 0
|
||||
}
|
||||
|
||||
func menubarPlistBody(script, logDir, addr string) string {
|
||||
return 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>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>%s</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>%s/verae-menubar.launchd.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>%s/verae-menubar.launchd.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>VERAE_IPP_ADDR</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`, menubarLaunchLabel, script, logDir, logDir, addr)
|
||||
}
|
||||
|
||||
func launchctlLoadMenubar(plistPath string) error {
|
||||
uid := os.Getuid()
|
||||
domain := "gui/" + strconv.Itoa(uid)
|
||||
label := menubarLaunchLabel
|
||||
// Modern path first (macOS 10.13+); fall back to load -w.
|
||||
_ = exec.Command("launchctl", "bootout", domain+"/"+label).Run()
|
||||
_ = exec.Command("launchctl", "unload", plistPath).Run()
|
||||
if err := exec.Command("launchctl", "bootstrap", domain, plistPath).Run(); err == nil {
|
||||
_ = exec.Command("launchctl", "enable", domain+"/"+label).Run()
|
||||
_ = exec.Command("launchctl", "kickstart", "-k", domain+"/"+label).Run()
|
||||
return nil
|
||||
}
|
||||
if err := exec.Command("launchctl", "load", "-w", plistPath).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func launchctlUnloadMenubar(plistPath string) error {
|
||||
uid := os.Getuid()
|
||||
domain := "gui/" + strconv.Itoa(uid)
|
||||
label := menubarLaunchLabel
|
||||
_ = exec.Command("launchctl", "bootout", domain+"/"+label).Run()
|
||||
_ = exec.Command("launchctl", "unload", "-w", plistPath).Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func locateDarwinMenubarScript() (string, error) {
|
||||
// Prefer durable install copy if present.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
p := filepath.Join(menubarSupportDir(home), "run-menubar.sh")
|
||||
if fileExists(p) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
script, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
|
||||
if err == nil {
|
||||
return script, nil
|
||||
}
|
||||
for _, alt := range []string{
|
||||
filepath.Join(exeDirHint(), "menubar", "run-menubar.sh"),
|
||||
filepath.Join(exeDirHint(), "..", "menubar", "run-menubar.sh"),
|
||||
filepath.Join(exeDirHint(), "run-menubar.sh"),
|
||||
} {
|
||||
if fileExists(alt) {
|
||||
abs, _ := filepath.Abs(alt)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// replaceEnvString replaces the <string> value immediately following <key>NAME</key>.
|
||||
func replaceEnvString(plist, key, value string) string {
|
||||
needle := "<key>" + key + "</key>"
|
||||
i := strings.Index(plist, needle)
|
||||
if i < 0 {
|
||||
return plist
|
||||
}
|
||||
rest := plist[i+len(needle):]
|
||||
start := strings.Index(rest, "<string>")
|
||||
end := strings.Index(rest, "</string>")
|
||||
if start < 0 || end < 0 || end < start {
|
||||
return plist
|
||||
}
|
||||
return plist[:i+len(needle)] + rest[:start] + "<string>" + value + rest[end:]
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := dst + ".tmp"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return closeErr
|
||||
}
|
||||
if st, err := os.Stat(src); err == nil {
|
||||
_ = os.Chmod(tmp, st.Mode())
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func findRepoFile(parts ...string) (string, error) {
|
||||
rel := filepath.Join(parts...)
|
||||
candidates := []string{
|
||||
filepath.Join(".", rel),
|
||||
filepath.Join(exeDirHint(), rel),
|
||||
filepath.Join(exeDirHint(), "..", rel),
|
||||
filepath.Join(exeDirHint(), "..", "..", rel),
|
||||
filepath.Join(repoRootHint(), rel),
|
||||
}
|
||||
// Walk up from cwd for go.mod (tests + dev trees).
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
dir := wd
|
||||
for i := 0; i < 8; i++ {
|
||||
if fileExists(filepath.Join(dir, "go.mod")) {
|
||||
candidates = append(candidates, filepath.Join(dir, rel))
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
var tried []string
|
||||
for _, c := range candidates {
|
||||
abs, err := filepath.Abs(c)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tried = append(tried, abs)
|
||||
if fileExists(abs) {
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("not found: %s (tried %s)", rel, strings.Join(tried, ", "))
|
||||
}
|
||||
|
||||
func exeDirHint() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
299
cmd/verae-print/menubar_test.go
Normal file
299
cmd/verae-print/menubar_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindRepoFileMenubarAssets(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
p, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(p) != "run-menubar.sh" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "verae-menubar.swift"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "com.verae.timestamping.menubar.plist"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "windows":
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
// Scripts still exist in the tree for packaging even on Linux CI.
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "verae-menubar.swift"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarPlistBody(t *testing.T) {
|
||||
body := menubarPlistBody("/tmp/run-menubar.sh", "/tmp/logs", "127.0.0.1:8631")
|
||||
for _, want := range []string{
|
||||
menubarLaunchLabel,
|
||||
"/tmp/run-menubar.sh",
|
||||
"/tmp/logs/verae-menubar.launchd.out.log",
|
||||
"<string>127.0.0.1:8631</string>",
|
||||
"<key>KeepAlive</key>",
|
||||
"<key>RunAtLoad</key>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("plist missing %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceEnvString(t *testing.T) {
|
||||
in := `<key>VERAE_IPP_ADDR</key>
|
||||
<string>127.0.0.1:8631</string>`
|
||||
out := replaceEnvString(in, "VERAE_IPP_ADDR", "127.0.0.1:9999")
|
||||
if !strings.Contains(out, "<string>127.0.0.1:9999</string>") {
|
||||
t.Fatal(out)
|
||||
}
|
||||
if strings.Contains(out, "8631") {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarSupportDir(t *testing.T) {
|
||||
p := menubarSupportDir("/Users/test")
|
||||
want := filepath.Join("/Users/test", "Library", "Application Support", "Verae", "menubar")
|
||||
if p != want {
|
||||
t.Fatalf("got %q want %q", p, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "a.txt")
|
||||
dst := filepath.Join(dir, "b.txt")
|
||||
if err := os.WriteFile(src, []byte("hello-menubar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "hello-menubar" {
|
||||
t.Fatal(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsStartupShortcutPathShape(t *testing.T) {
|
||||
p := windowsStartupShortcutPath()
|
||||
if !strings.HasSuffix(p, winStartupLnkName) {
|
||||
t.Fatal(p)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(p), "startup") {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateScriptsPreferRepo(t *testing.T) {
|
||||
// On every OS the packaging scripts should resolve from the go.mod tree.
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrayStartmenuScriptDocumentsWiXFree(t *testing.T) {
|
||||
p, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(b)
|
||||
for _, want := range []string{
|
||||
"Start Menu",
|
||||
"Verae Tray",
|
||||
"install-tray-startup.ps1",
|
||||
"WiX",
|
||||
"WScript.Shell",
|
||||
"-Uninstall",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("build-tray-startmenu.ps1 missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarAppBuilderHasNotarizationPointer(t *testing.T) {
|
||||
p, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(b)
|
||||
for _, want := range []string{
|
||||
"LSUIElement",
|
||||
"ai.verae.timestamping.menubar",
|
||||
"entitlements.plist",
|
||||
"notarize",
|
||||
"PkgInfo",
|
||||
"HelperVersion",
|
||||
"packaging-metadata.txt",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("build-menubar-app.sh missing %q", want)
|
||||
}
|
||||
}
|
||||
readme, err := findRepoFile("platform", "macos", "menubar", "README.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rb, err := os.ReadFile(readme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rbody := string(rb)
|
||||
for _, want := range []string{
|
||||
"notarytool",
|
||||
"stapler",
|
||||
"Developer ID Application",
|
||||
"codesign",
|
||||
} {
|
||||
if !strings.Contains(rbody, want) {
|
||||
t.Fatalf("menubar README.txt missing notarization step %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackagingMetadataVersionStamp(t *testing.T) {
|
||||
// A4: tray/menubar install scripts + READMEs stamp HelperVersion.
|
||||
// job.HelperVersion is the single source of truth (do not hardcode here beyond import).
|
||||
helper := ""
|
||||
{
|
||||
// Read from job.go via the same constant the binary uses.
|
||||
helper = mustHelperVersion(t)
|
||||
}
|
||||
checks := []struct {
|
||||
parts []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
parts: []string{"platform", "macos", "menubar", "build-menubar-app.sh"},
|
||||
want: []string{"HelperVersion", "CFBundleShortVersionString", "packaging-metadata.txt", helper},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "macos", "menubar", "README.txt"},
|
||||
want: []string{"HelperVersion", "Package version", helper},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "scripts", "common.ps1"},
|
||||
want: []string{"Get-VeraePackageVersion", "HelperVersion", helper},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "scripts", "install-tray-startup.ps1"},
|
||||
want: []string{"PackageVersion", "HelperVersion", "no elevated MSI"},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "scripts", "build-tray-startmenu.ps1"},
|
||||
want: []string{"PackageVersion", "HelperVersion", "no elevated MSI"},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "scripts", "verae-tray.ps1"},
|
||||
want: []string{"PackageVersion", "HelperVersion"},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "README.txt"},
|
||||
want: []string{"HelperVersion", "Package version", "no elevated MSI"},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "windows", "README-WINDOWS.txt"},
|
||||
want: []string{"HelperVersion", "Package version"},
|
||||
},
|
||||
{
|
||||
parts: []string{"platform", "smoke-packaging-metadata.sh"},
|
||||
want: []string{"HelperVersion", "no elevated MSI", "A4"},
|
||||
},
|
||||
}
|
||||
for _, c := range checks {
|
||||
path, err := findRepoFile(c.parts...)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", c.parts, err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(b)
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(body, w) {
|
||||
t.Fatalf("%s missing %q", path, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustHelperVersion(t *testing.T) string {
|
||||
t.Helper()
|
||||
// Prefer the live constant so the test tracks job.HelperVersion automatically.
|
||||
// Imported via same module; avoid circular issues by reading job.go text if needed.
|
||||
p, err := findRepoFile("internal", "job", "job.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "const HelperVersion") {
|
||||
// const HelperVersion = "x.y.z"
|
||||
i := strings.Index(line, "\"")
|
||||
j := strings.LastIndex(line, "\"")
|
||||
if i >= 0 && j > i {
|
||||
return line[i+1 : j]
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("HelperVersion not found in job.go")
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -25,6 +26,7 @@ import (
|
|||
|
||||
"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"
|
||||
)
|
||||
|
|
@ -166,7 +168,7 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
|
|||
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)
|
||||
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", m.Title, res.ReceiptID))
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
}
|
||||
return done, len(listPending()), false
|
||||
|
|
@ -216,7 +218,8 @@ func handleAuthFailure(reason string, stamp stampFn) bool {
|
|||
}
|
||||
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))
|
||||
ht, hb := statuslang.NotifyHeldJobs(n)
|
||||
notifyUser(ht, notifyQueueHint(hb))
|
||||
return false
|
||||
}
|
||||
markKeySeen()
|
||||
|
|
@ -258,3 +261,174 @@ func keyChangedSinceSeen() bool {
|
|||
}
|
||||
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(`<tr><td colspan="4" class="empty">No held jobs — print with Destination → Verae Timestamping</td></tr>`)
|
||||
} else {
|
||||
for _, m := range list {
|
||||
title := m.Title
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
rows.WriteString(fmt.Sprintf(
|
||||
`<tr><td class="mono">%s</td><td>%s</td><td><span class="badge">%s</span></td><td class="muted">%s</td></tr>`,
|
||||
htmlEsc(m.ID), htmlEsc(title), htmlEsc(statuslang.Pending), htmlEsc(m.Created),
|
||||
))
|
||||
}
|
||||
}
|
||||
meta := fmt.Sprintf("Folder: %s · %d held", htmlEsc(pendingDir()), len(list))
|
||||
if snap.QueueURL != "" {
|
||||
meta += fmt.Sprintf(` · live: <a class="mono" href="%s">%s</a>`, htmlEsc(snap.QueueURL), htmlEsc(snap.QueueURL))
|
||||
} else {
|
||||
meta += ` · refresh: <span class="mono">verae-print pending --html</span>`
|
||||
}
|
||||
meta += fmt.Sprintf(` · <span class="mono">verae-print tray-status</span>`)
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<meta http-equiv="refresh" content="15"/>
|
||||
<title>Print job queue · Verae Timestamping</title>
|
||||
<style>
|
||||
:root{--plum:#531B59;--gold:#C4A85A;--ink:#1D1534;--muted:#5E5470;--bg:#FBFAFD;--card:#fff;--line:#E3DEF0;--surface:#F4F1F8;--pend-bg:#EEF0F3;--pend:#475467;--ok:#0E6B3A;--ok-bg:#E3F5EA}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--ink)}
|
||||
main{max-width:820px;margin:0 auto;padding:28px 16px}
|
||||
h1{font-size:1.35rem;margin:0 0 6px} .lede{color:var(--muted);font-size:.95rem;margin:0 0 18px;max-width:40rem}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;box-shadow:0 1px 2px rgba(29,21,52,.04)}
|
||||
table{width:100%%;border-collapse:collapse;font-size:.9rem} th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--line)}
|
||||
th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
|
||||
.mono{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.8rem}
|
||||
.muted{color:var(--muted)}.empty{color:var(--muted);text-align:center;padding:28px 8px}
|
||||
.badge{display:inline-block;background:var(--pend-bg);color:var(--pend);border-radius:999px;padding:2px 10px;font-size:.78rem;font-weight:600}
|
||||
.pill{display:inline-block;border-radius:999px;padding:3px 12px;font-size:.8rem;font-weight:600;background:var(--ok-bg);color:var(--ok)}
|
||||
.pill.warn{background:var(--pend-bg);color:var(--pend)}
|
||||
.note{margin-top:14px;padding:12px 14px;background:var(--surface);border-left:3px solid var(--gold);border-radius:0 8px 8px 0;font-size:.85rem;color:var(--muted)}
|
||||
.note b{color:var(--plum)} .meta{margin-top:12px;font-size:.8rem;color:var(--muted)}
|
||||
a{color:var(--plum)} .status{margin:0 0 14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
</style></head><body><main>
|
||||
<h1>Print job queue</h1>
|
||||
<p class="lede">PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. <b style="color:var(--plum)">%s</b></p>
|
||||
<div class="status"><span class="pill%s">%s</span><span class="muted">%s</span></div>
|
||||
<div class="card">
|
||||
<table><thead><tr><th>Hold id</th><th>Document</th><th>State</th><th>Created</th></tr></thead>
|
||||
<tbody>%s</tbody></table>
|
||||
<div class="note"><b>Not proof:</b> %s Wait for <b>%s</b>.</div>
|
||||
<p class="meta">%s</p>
|
||||
</div></main></body></html>
|
||||
`, htmlEsc(statuslang.EvidenceNote), pillClass(snap), htmlEsc(snap.Headline), htmlEsc(snap.Detail), rows.String(), htmlEsc(statuslang.EvidenceNote), htmlEsc(statuslang.FinalReceiptReady), meta)
|
||||
}
|
||||
|
||||
func pillClass(snap traySnap) string {
|
||||
if snap.Held > 0 || !snap.KeyOK {
|
||||
return " warn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,20 +11,25 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
func notify(title, subtitle string) {
|
||||
// notify shows an OS banner. title should be statuslang.NotifyTitle; body NotifyBody.
|
||||
func notify(title, body string) {
|
||||
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
title = "Verae Timestamp"
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
_ = exec.Command("notify-send", "-a", "Verae Timestamp", "Verae: "+title, subtitle).Run()
|
||||
_ = exec.Command("notify-send", "-a", "Verae Timestamp", title, body).Run()
|
||||
}
|
||||
return
|
||||
}
|
||||
if runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
|
||||
// Brand status string is the notification title (PT-06 / PT-W3).
|
||||
script := fmt.Sprintf(`display notification %q with title %q`, body, title)
|
||||
_ = exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,42 @@ func exeDir() string {
|
|||
}
|
||||
|
||||
// notifyUser: best-effort balloon via PowerShell (no extra deps). Always logs.
|
||||
// BalloonTipClicked / DoubleClick open the live loopback /queue (PT-W3).
|
||||
func notifyUser(title, body string) {
|
||||
vlog.Printf("notify: %s — %s", title, body)
|
||||
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info);Start-Sleep 9;$n.Dispose()`, esc(title), esc(body))
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
esc := func(s string) string {
|
||||
s = strings.ReplaceAll(s, "'", "''")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return s
|
||||
}
|
||||
queue := "http://127.0.0.1:8631/queue"
|
||||
if serveListenAddr != "" && isLoopbackAddr(serveListenAddr) {
|
||||
queue = queueURLFor(serveListenAddr)
|
||||
} else if a := os.Getenv("VERAE_IPP_ADDR"); a != "" && isLoopbackAddr(a) {
|
||||
queue = queueURLFor(a)
|
||||
}
|
||||
// STA + DoEvents so BalloonTipClicked can open /queue before dispose.
|
||||
ps := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$q='%s'
|
||||
$n=New-Object System.Windows.Forms.NotifyIcon
|
||||
$n.Icon=[System.Drawing.SystemIcons]::Information
|
||||
$n.Visible=$true
|
||||
$n.Text='Verae Timestamping'
|
||||
$open={ try { Start-Process $q } catch {} }
|
||||
$n.add_BalloonTipClicked($open)
|
||||
$n.add_DoubleClick($open)
|
||||
$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info)
|
||||
$sw=[Diagnostics.Stopwatch]::StartNew()
|
||||
while($sw.Elapsed.TotalSeconds -lt 9){ [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 120 }
|
||||
$n.Visible=$false; $n.Dispose()
|
||||
`, esc(queue), esc(title), esc(body))
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-STA", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
hideWindow(cmd)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
|
||||
)
|
||||
|
|
@ -74,8 +75,22 @@ func runServe(args []string) int {
|
|||
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}
|
||||
serveListenAddr = *addr
|
||||
srv := &winprint.IPPServer{
|
||||
Addr: *addr, Name: "Verae TimeStamp PDF",
|
||||
SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc,
|
||||
// PT-W3 tray substitute: GET-only status on the same loopback port as IPP.
|
||||
// POST remains pure IPP — Windows print clients are unaffected.
|
||||
ExtraHealth: helperExtraHealth,
|
||||
ServeQueue: handleQueueHTTP,
|
||||
ServeStatus: handleStatusHTTP,
|
||||
}
|
||||
go func() { errc <- srv.ListenAndServe() }()
|
||||
vlog.Printf("status: queue http://%s/queue · status http://%s/status (GET only; hold id ≠ evidence)", *addr, *addr)
|
||||
if n := len(listPending()); n > 0 {
|
||||
ht, hb := statuslang.NotifyHeldJobs(n)
|
||||
notifyUser(ht, notifyQueueHint(hb))
|
||||
}
|
||||
}
|
||||
if !*noWatch {
|
||||
w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc}
|
||||
|
|
@ -134,14 +149,15 @@ func makeProcessor(outDir string) winprint.Processor {
|
|||
return err
|
||||
}
|
||||
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, err)
|
||||
notifyUser(statuslang.NotifyTitle("held"), notifyQueueHint(statuslang.NotifyBody("held", m.Title, m.ID)))
|
||||
go handleAuthFailure(authReason(err), stampFile)
|
||||
return nil // the job is safe; IPP reports success to Windows
|
||||
}
|
||||
notifyUser("Verae timestamp FAILED", err.Error())
|
||||
notifyUser(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", j.Title, err.Error()))
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(j.Path)
|
||||
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", j.Title, res.ReceiptID))
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
// a successful stamp proves the key works: flush anything still pending
|
||||
if len(listPending()) > 0 {
|
||||
|
|
|
|||
291
cmd/verae-print/traystatus.go
Normal file
291
cmd/verae-print/traystatus.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package main
|
||||
|
||||
// PT-W3 / PT-06 tray substitute: no persistent native NotifyIcon loop in this
|
||||
// binary (would need a long-lived WinForms/AppIndicator process). Instead:
|
||||
// - loopback GET /queue (HTML) + /status (JSON) on the IPP helper
|
||||
// - verae-print tray-status [--json|--open]
|
||||
// - enriched plain GET health with held count + brand labels
|
||||
// IPP POST path is untouched.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
)
|
||||
|
||||
// serveListenAddr is set by runServe so notifications can mention the live queue URL.
|
||||
var serveListenAddr string
|
||||
|
||||
type traySnap struct {
|
||||
Headline string `json:"headline"`
|
||||
Detail string `json:"detail"`
|
||||
Held int `json:"held"`
|
||||
KeyOK bool `json:"keyOk"`
|
||||
HelperUp bool `json:"helperUp"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
QueueURL string `json:"queueUrl,omitempty"`
|
||||
Health string `json:"healthUrl,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Folder string `json:"folder"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func defaultHelperAddr() string {
|
||||
if a := os.Getenv("VERAE_IPP_ADDR"); a != "" {
|
||||
return a
|
||||
}
|
||||
return "127.0.0.1:8631"
|
||||
}
|
||||
|
||||
func queueURLFor(addr string) string {
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
return "http://" + addr + "/queue"
|
||||
}
|
||||
|
||||
func traySnapshot(preferAddr string) traySnap {
|
||||
addr := preferAddr
|
||||
if addr == "" {
|
||||
addr = serveListenAddr
|
||||
}
|
||||
if addr == "" {
|
||||
addr = defaultHelperAddr()
|
||||
}
|
||||
held := len(listPending())
|
||||
keyOK := auth.HasXVeraeToken()
|
||||
helperUp := probeHelper(addr)
|
||||
if !helperUp && preferAddr == "" && serveListenAddr == "" {
|
||||
// Prefer empty queue URL when helper is down and we only guessed the addr.
|
||||
addr = defaultHelperAddr()
|
||||
}
|
||||
snap := traySnap{
|
||||
Held: held,
|
||||
KeyOK: keyOK,
|
||||
HelperUp: helperUp,
|
||||
Addr: addr,
|
||||
Version: job.HelperVersion,
|
||||
Folder: pendingDir(),
|
||||
Note: statuslang.EvidenceNote,
|
||||
}
|
||||
if helperUp {
|
||||
snap.QueueURL = queueURLFor(addr)
|
||||
snap.Health = "http://" + addr + "/"
|
||||
}
|
||||
snap.Headline = statuslang.TrayHeadline(held, keyOK, helperUp)
|
||||
snap.Detail = statuslang.TrayDetail(held, keyOK, helperUp)
|
||||
return snap
|
||||
}
|
||||
|
||||
func probeHelper(addr string) bool {
|
||||
if addr == "" || !isLoopbackAddr(addr) {
|
||||
return false
|
||||
}
|
||||
client := &http.Client{Timeout: 400 * time.Millisecond}
|
||||
res, err := client.Get("http://" + addr + "/")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 512))
|
||||
return res.StatusCode == 200 && strings.Contains(string(b), "Verae TimeStamp")
|
||||
}
|
||||
|
||||
func isLoopbackAddr(addr string) bool {
|
||||
host := addr
|
||||
if i := strings.LastIndex(addr, ":"); i >= 0 {
|
||||
host = addr[:i]
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
return host == "127.0.0.1" || host == "localhost" || host == "::1"
|
||||
}
|
||||
|
||||
// helperExtraHealth lines appended to IPP GET / plain text.
|
||||
func helperExtraHealth() string {
|
||||
list := listPending()
|
||||
keyOK := auth.HasXVeraeToken()
|
||||
held := len(list)
|
||||
// Helper is serving this request → helperUp true.
|
||||
h := statuslang.TrayHeadline(held, keyOK, true)
|
||||
d := statuslang.TrayDetail(held, keyOK, true)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "headline: %s\n", h)
|
||||
fmt.Fprintf(&b, "detail: %s\n", d)
|
||||
fmt.Fprintf(&b, "held-count: %d\n", held)
|
||||
fmt.Fprintf(&b, "held-state: %s\n", statuslang.Pending)
|
||||
if keyOK {
|
||||
b.WriteString("signing-key: present\n")
|
||||
} else {
|
||||
b.WriteString("signing-key: missing\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "note: %s\n", statuslang.EvidenceNote)
|
||||
fmt.Fprintf(&b, "version: %s\n", job.HelperVersion)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func handleQueueHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
addr := serveListenAddr
|
||||
if addr == "" {
|
||||
addr = r.Host
|
||||
}
|
||||
snap := traySnap{
|
||||
Held: len(listPending()),
|
||||
KeyOK: auth.HasXVeraeToken(),
|
||||
HelperUp: true,
|
||||
Addr: addr,
|
||||
QueueURL: queueURLFor(addr),
|
||||
Health: "http://" + addr + "/",
|
||||
Version: job.HelperVersion,
|
||||
Folder: pendingDir(),
|
||||
Note: statuslang.EvidenceNote,
|
||||
}
|
||||
snap.Headline = statuslang.TrayHeadline(snap.Held, snap.KeyOK, true)
|
||||
snap.Detail = statuslang.TrayDetail(snap.Held, snap.KeyOK, true)
|
||||
html := renderPendingHTML(listPending(), snap)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, html)
|
||||
}
|
||||
|
||||
func handleStatusHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
addr := serveListenAddr
|
||||
if addr == "" {
|
||||
addr = r.Host
|
||||
}
|
||||
list := listPending()
|
||||
snap := traySnap{
|
||||
Held: len(list), KeyOK: auth.HasXVeraeToken(), HelperUp: true,
|
||||
Addr: addr, QueueURL: queueURLFor(addr), Health: "http://" + addr + "/",
|
||||
Version: job.HelperVersion, Folder: pendingDir(), Note: statuslang.EvidenceNote,
|
||||
}
|
||||
snap.Headline = statuslang.TrayHeadline(snap.Held, snap.KeyOK, true)
|
||||
snap.Detail = statuslang.TrayDetail(snap.Held, snap.KeyOK, true)
|
||||
b, err := encodePendingJSON(list, snap)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
_, _ = w.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
// runTrayStatus: verae-print tray-status [--json] [--open] [--addr host:port]
|
||||
// Semi-persistent status for PT-W3 without a native tray icon process.
|
||||
func runTrayStatus(args []string) int {
|
||||
fs := flag.NewFlagSet("tray-status", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
asJSON := fs.Bool("json", false, "machine-readable JSON")
|
||||
openQ := fs.Bool("open", false, "open the live /queue page (or write a temp HTML if helper is down)")
|
||||
addr := fs.String("addr", defaultHelperAddr(), "helper loopback address to probe")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
snap := traySnapshot(*addr)
|
||||
if *asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(snap); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Printf("Verae Timestamping · %s\n", snap.Headline)
|
||||
fmt.Printf("%s\n", snap.Detail)
|
||||
fmt.Printf("held: %d · state: %s\n", snap.Held, statuslang.Pending)
|
||||
if snap.KeyOK {
|
||||
fmt.Println("signing-key: present")
|
||||
} else {
|
||||
fmt.Println("signing-key: missing — run verae-print setup")
|
||||
}
|
||||
if snap.HelperUp {
|
||||
fmt.Printf("helper: up · %s\n", snap.Health)
|
||||
fmt.Printf("queue: %s\n", snap.QueueURL)
|
||||
} else {
|
||||
fmt.Printf("helper: down (start: verae-print serve --addr %s)\n", *addr)
|
||||
fmt.Println("queue: verae-print pending --html")
|
||||
}
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
if *openQ {
|
||||
if err := openTrayQueue(snap); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func openTrayQueue(snap traySnap) error {
|
||||
if snap.HelperUp && snap.QueueURL != "" {
|
||||
return openBrowser(snap.QueueURL)
|
||||
}
|
||||
// Helper down: write a one-shot HTML and open it.
|
||||
html := renderPendingHTML(listPending(), snap)
|
||||
f, err := os.CreateTemp("", "verae-queue-*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.WriteString(html); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
path := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return openBrowser(path)
|
||||
}
|
||||
|
||||
func openBrowser(target string) error {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd := exec.Command("cmd", "/c", "start", "", target)
|
||||
return cmd.Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", target).Start()
|
||||
default:
|
||||
return exec.Command("xdg-open", target).Start()
|
||||
}
|
||||
}
|
||||
|
||||
// notifyQueueHint appends the live queue URL to a held/fail body when the helper is serving.
|
||||
func notifyQueueHint(body string) string {
|
||||
addr := serveListenAddr
|
||||
if addr == "" {
|
||||
return body
|
||||
}
|
||||
u := queueURLFor(addr)
|
||||
if u == "" || strings.Contains(body, u) {
|
||||
return body
|
||||
}
|
||||
return body + " · " + u
|
||||
}
|
||||
142
cmd/verae-print/traystatus_test.go
Normal file
142
cmd/verae-print/traystatus_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
|
||||
)
|
||||
|
||||
func TestRenderPendingHTMLBrandLabels(t *testing.T) {
|
||||
d := pendingEnv(t)
|
||||
src := filepath.Join(d, "doc.pdf")
|
||||
_ = os.WriteFile(src, []byte("%PDF"), 0o600)
|
||||
m, err := holdJob(src, stampTarget{Title: "Board pack.pdf"}, "cli", errNoKey, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := traySnap{
|
||||
Headline: statuslang.TrayHeadline(1, false, true),
|
||||
Detail: statuslang.TrayDetail(1, false, true),
|
||||
Held: 1, KeyOK: false, HelperUp: true,
|
||||
QueueURL: "http://127.0.0.1:8631/queue",
|
||||
}
|
||||
html := renderPendingHTML(listPending(), snap)
|
||||
for _, want := range []string{statuslang.Pending, m.ID, "Board pack.pdf", statuslang.EvidenceNote, "Final receipt ready", snap.QueueURL, snap.Headline} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("html missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, "held as receipt") {
|
||||
t.Fatal("must not imply hold is receipt")
|
||||
}
|
||||
b, err := encodePendingJSON(listPending(), snap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out pendingJSONOut
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Count != 1 || out.Jobs[0].HoldID != m.ID || out.Jobs[0].State != statuslang.Pending {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
if out.Note != statuslang.EvidenceNote || out.Headline != snap.Headline {
|
||||
t.Fatalf("json note/headline: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperQueueAndStatusHTTP(t *testing.T) {
|
||||
d := pendingEnv(t)
|
||||
_ = d
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
serveListenAddr = addr
|
||||
defer func() { serveListenAddr = "" }()
|
||||
|
||||
src := filepath.Join(d, "a.pdf")
|
||||
_ = os.WriteFile(src, []byte("%PDF"), 0o600)
|
||||
if _, err := holdJob(src, stampTarget{Title: "Held.pdf"}, "test", errNoKey, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := &winprint.IPPServer{
|
||||
Addr: addr, SpoolDir: t.TempDir(),
|
||||
Process: func(winprint.Job) error { return nil },
|
||||
ExtraHealth: helperExtraHealth,
|
||||
ServeQueue: handleQueueHTTP,
|
||||
ServeStatus: handleStatusHTTP,
|
||||
}
|
||||
go s.ListenAndServe()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
res, err := http.Get("http://" + addr + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hb, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if !strings.Contains(string(hb), "held-count: 1") || !strings.Contains(string(hb), statuslang.Pending) {
|
||||
t.Fatalf("health: %s", hb)
|
||||
}
|
||||
|
||||
res, err = http.Get("http://" + addr + "/queue")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ct := res.Header.Get("Content-Type")
|
||||
qb, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if ct == "" || !strings.Contains(string(qb), "Held.pdf") || !strings.Contains(string(qb), statuslang.Pending) {
|
||||
t.Fatalf("queue html: ct=%s body=%s", ct, qb)
|
||||
}
|
||||
|
||||
res, err = http.Get("http://" + addr + "/status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sb, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
var out pendingJSONOut
|
||||
if err := json.Unmarshal(sb, &out); err != nil {
|
||||
t.Fatal(err, string(sb))
|
||||
}
|
||||
if out.Count != 1 || !out.HelperUp || out.Jobs[0].Title != "Held.pdf" {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyQueueHint(t *testing.T) {
|
||||
serveListenAddr = "127.0.0.1:8631"
|
||||
defer func() { serveListenAddr = "" }()
|
||||
got := notifyQueueHint("hello")
|
||||
if !strings.Contains(got, "http://127.0.0.1:8631/queue") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
// idempotent
|
||||
if notifyQueueHint(got) != got {
|
||||
t.Fatal("double append")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLoopbackAddr(t *testing.T) {
|
||||
if !isLoopbackAddr("127.0.0.1:8631") || !isLoopbackAddr("localhost:9") {
|
||||
t.Fatal("loopback")
|
||||
}
|
||||
if isLoopbackAddr("8.8.8.8:80") || isLoopbackAddr("example.com:443") {
|
||||
t.Fatal("non-loopback allowed")
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
)
|
||||
|
||||
const HelperVersion = "0.3.4"
|
||||
const HelperVersion = "0.3.6"
|
||||
|
||||
// Result paths written for one print job.
|
||||
type Result struct {
|
||||
|
|
@ -24,6 +24,8 @@ type Result struct {
|
|||
ReceiptJSON string
|
||||
ReceiptID string
|
||||
SHA256 string
|
||||
Multihash string // f1220… when SHA-256 known; empty otherwise
|
||||
Blake3 string // optional; empty when not provided upstream
|
||||
Channel auth.Channel
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +41,9 @@ type Options struct {
|
|||
OutPath string
|
||||
Channel auth.Channel
|
||||
Client *veraeapi.Client
|
||||
// Blake3 is an optional BLAKE3-256 hex digest from upstream. Empty → omit
|
||||
// on CERT/sidecar (never invent / never hash file bytes for blake3 here).
|
||||
Blake3 string
|
||||
}
|
||||
|
||||
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
|
@ -147,6 +152,14 @@ func Run(opt Options) (Result, error) {
|
|||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
// CERT multihash from known SHA-256 only (never invent). Blake3 only when
|
||||
// Options carries an upstream digest — job does not compute blake3.
|
||||
mh := pdfcert.ContentMultihash(digest)
|
||||
b3 := strings.TrimSpace(opt.Blake3)
|
||||
b3mh := ""
|
||||
if b3 != "" {
|
||||
b3mh = pdfcert.ContentBlake3Multihash(b3)
|
||||
}
|
||||
|
||||
client := *opt.Client
|
||||
client.Channel = opt.Channel
|
||||
|
|
@ -161,17 +174,20 @@ func Run(opt Options) (Result, error) {
|
|||
safeID := SanitizeReceiptID(rec.ReceiptID)
|
||||
certStem := fmt.Sprintf("%s-verae-timestamp-%s", stem, safeID)
|
||||
|
||||
// Always prefer the branded Registration Document (digests-only wording,
|
||||
// no mcp.verae.ai). Edge attachPdf is optional and may still say MCP.
|
||||
// Always prefer the branded CERT-01 certificate (digests-only, final-only
|
||||
// evidence wording, no mcp.verae.ai). Edge attachPdf may still say MCP.
|
||||
pdfBytes, err := pdfcert.Build(pdfcert.Input{
|
||||
DocumentName: origBase,
|
||||
SHA256: digest,
|
||||
ReceiptID: rec.ReceiptID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Merkle: rec.Merkle,
|
||||
Scope: "global",
|
||||
Channel: string(opt.Channel),
|
||||
ByteLength: int64(len(srcBytes)),
|
||||
DocumentName: origBase,
|
||||
SHA256: digest,
|
||||
Multihash: mh,
|
||||
Blake3: b3,
|
||||
Blake3Multihash: b3mh,
|
||||
ReceiptID: rec.ReceiptID,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
Merkle: rec.Merkle,
|
||||
Scope: "global",
|
||||
Channel: string(opt.Channel),
|
||||
ByteLength: int64(len(srcBytes)),
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
|
|
@ -197,6 +213,16 @@ func Run(opt Options) (Result, error) {
|
|||
"status": "ok",
|
||||
"saved_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
// Omit digest extras when empty — never invent.
|
||||
if mh != "" {
|
||||
sidecar["multihash"] = mh
|
||||
}
|
||||
if b3 != "" {
|
||||
sidecar["blake3"] = b3
|
||||
}
|
||||
if b3mh != "" {
|
||||
sidecar["blake3_multihash"] = b3mh
|
||||
}
|
||||
jb, err := json.MarshalIndent(sidecar, "", " ")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
|
|
@ -212,6 +238,8 @@ func Run(opt Options) (Result, error) {
|
|||
ReceiptJSON: jsonPath,
|
||||
ReceiptID: rec.ReceiptID,
|
||||
SHA256: digest,
|
||||
Multihash: mh,
|
||||
Blake3: b3,
|
||||
Channel: opt.Channel,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ func TestRunZapierWritesNamedFiles(t *testing.T) {
|
|||
if !strings.HasPrefix(string(got), "%PDF") {
|
||||
t.Fatal("certificate is not a PDF")
|
||||
}
|
||||
if !strings.Contains(string(got), "Verae Timestamp Registration Document") {
|
||||
t.Fatal("missing branded title")
|
||||
if !strings.Contains(string(got), "Verae Timestamp Certificate") && !strings.Contains(string(got), "Timestamp Certificate") {
|
||||
t.Fatal("missing branded CERT-01 title")
|
||||
}
|
||||
if strings.Contains(string(got), "mcp.verae.ai") || strings.Contains(string(got), "via MCP") {
|
||||
t.Fatal("certificate must not mention mcp.verae.ai / via MCP")
|
||||
|
|
@ -102,6 +102,65 @@ func TestRunVeraeRendersLocalCert(t *testing.T) {
|
|||
if !strings.HasPrefix(string(b), "%PDF") {
|
||||
t.Fatal("not a pdf")
|
||||
}
|
||||
// Multihash from SHA-256; no blake3 invented when Options.Blake3 empty.
|
||||
if res.SHA256 == "" || res.Multihash == "" || !strings.HasPrefix(res.Multihash, "f1220") {
|
||||
t.Fatalf("expected multihash from sha256, got sha=%q mh=%q", res.SHA256, res.Multihash)
|
||||
}
|
||||
if res.Blake3 != "" {
|
||||
t.Fatal("must not invent blake3 without Options.Blake3")
|
||||
}
|
||||
side, _ := os.ReadFile(res.ReceiptJSON)
|
||||
if !strings.Contains(string(side), `"multihash"`) || !strings.Contains(string(side), res.Multihash) {
|
||||
t.Fatal("sidecar must carry multihash when SHA-256 known")
|
||||
}
|
||||
if strings.Contains(string(side), `"blake3"`) {
|
||||
t.Fatal("sidecar must omit blake3 when not provided upstream")
|
||||
}
|
||||
if !strings.Contains(string(b), "f1220") {
|
||||
t.Fatal("certificate must include content multihash when SHA-256 present")
|
||||
}
|
||||
if strings.Contains(string(b), "f1e20") || strings.Contains(string(b), "BLAKE3") {
|
||||
t.Fatal("certificate must not invent blake3 without upstream digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPassesBlake3WhenProvided(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "memo.pdf")
|
||||
_ = os.WriteFile(src, []byte("%PDF-1.4 blake3"), 0o600)
|
||||
out := filepath.Join(dir, "out")
|
||||
b3 := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
client := &veraeapi.Client{
|
||||
Channel: auth.ChannelZapier,
|
||||
ZapierBase: "https://zap.test",
|
||||
XVeraeToken: "xVerae-x",
|
||||
HTTPDo: func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"receipt_id":"bafyB3"}`)), Header: make(http.Header)}, nil
|
||||
},
|
||||
}
|
||||
res, err := Run(Options{
|
||||
SourcePDF: src, OutDir: out, DocumentName: "Board.pdf",
|
||||
Channel: auth.ChannelZapier, Client: client, Blake3: b3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Blake3 != b3 {
|
||||
t.Fatalf("blake3: %q", res.Blake3)
|
||||
}
|
||||
side, _ := os.ReadFile(res.ReceiptJSON)
|
||||
ss := string(side)
|
||||
if !strings.Contains(ss, b3) || !strings.Contains(ss, `"blake3_multihash"`) || !strings.Contains(ss, "f1e20"+b3) {
|
||||
t.Fatal("sidecar must carry blake3 + blake3_multihash when Options.Blake3 set")
|
||||
}
|
||||
cert, _ := os.ReadFile(res.CertificatePDF)
|
||||
cs := string(cert)
|
||||
if !strings.Contains(cs, "f1e20") && !strings.Contains(cs, b3) {
|
||||
t.Fatal("certificate must render blake3 when provided upstream")
|
||||
}
|
||||
if !strings.Contains(cs, "f1220") {
|
||||
t.Fatal("certificate must still include sha2-256 multihash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNeverOverwritesSameName(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -35,24 +35,29 @@ Verae TimeStamp - https://customer.verae.ai/portal/
|
|||
This is an automated message; replies are not monitored.
|
||||
`, greet, spaced, client, ttlMinutes)
|
||||
|
||||
// Inline 2026 brand tokens (receipt-final.html shell): plum #531B59, gold #C4A85A,
|
||||
// surface #F4F1F8, line #E3DEF0, ink #1D1534, muted #5E5470, subtle #6B6478.
|
||||
e := html.EscapeString
|
||||
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark">
|
||||
<title>Your Verae verification code</title></head>
|
||||
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 0;">
|
||||
<body style="margin:0;padding:0;background:#F4F1F8;font-family:Inter,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1D1534;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#F4F1F8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;width:100%%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
|
||||
<tr><td style="background:#0b3d91;padding:20px 28px;color:#ffffff;font-size:20px;font-weight:600;letter-spacing:.3px;">Verae TimeStamp</td></tr>
|
||||
<tr><td style="padding:28px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;">%s,</p>
|
||||
<p style="margin:0 0 20px;font-size:15px;line-height:1.5;">Enter this code in %s to finish creating your Verae account:</p>
|
||||
<div style="margin:0 0 20px;padding:18px 0;text-align:center;background:#eef3fb;border-radius:8px;font-family:Consolas,Menlo,monospace;font-size:34px;font-weight:700;letter-spacing:8px;color:#0b3d91;">%s</div>
|
||||
<p style="margin:0 0 8px;font-size:14px;color:#52606d;">The code expires in <b>%d minutes</b> and can be used once.</p>
|
||||
<p style="margin:0;font-size:14px;color:#52606d;">If you did not try to create a Verae account, ignore this email — no account is created without this code.</p>
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:100%%;background:#FFFFFF;border-radius:14px;border:1px solid #E3DEF0;">
|
||||
<tr><td style="padding:24px 28px 8px;font-size:18px;font-weight:700;color:#531B59;letter-spacing:.2px;">Verae<span style="display:inline-block;width:28px;height:3px;background:#C4A85A;border-radius:2px;margin-left:10px;vertical-align:middle;"></span></td></tr>
|
||||
<tr><td style="padding:8px 28px 0;font-size:22px;font-weight:700;color:#1D1534;">Your verification code</td></tr>
|
||||
<tr><td style="padding:6px 28px 0;font-size:15px;line-height:22px;color:#5E5470;">%s,</td></tr>
|
||||
<tr><td style="padding:12px 28px 0;font-size:15px;line-height:22px;color:#5E5470;">Enter this code in %s to finish creating your Verae account:</td></tr>
|
||||
<tr><td style="padding:16px 28px 0;">
|
||||
<div style="padding:18px 0;text-align:center;background:#F7EFF8;border:1px solid #EBD9EE;border-radius:10px;font-family:JetBrains Mono,Menlo,Consolas,monospace;font-size:32px;font-weight:700;letter-spacing:8px;color:#531B59;">%s</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 28px;border-top:1px solid #e4e7eb;font-size:12px;color:#7b8794;">
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;text-decoration:none;">customer.verae.ai</a> · Automated message, replies are not monitored.
|
||||
<tr><td style="padding:16px 28px 0;font-size:13px;line-height:20px;color:#5E5470;">The code expires in <b style="color:#1D1534;">%d minutes</b> and can be used once.</td></tr>
|
||||
<tr><td style="padding:8px 28px 0;font-size:13px;line-height:20px;color:#5E5470;">If you did not try to create a Verae account, ignore this email — no account is created without this code.</td></tr>
|
||||
<tr><td style="padding:14px 28px 22px;border-top:1px solid #E3DEF0;font-size:11px;line-height:16px;color:#6B6478;">
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#531B59;text-decoration:none;font-weight:600;">customer.verae.ai</a>
|
||||
· Automated message; replies are not monitored.
|
||||
· Only a final blockchain receipt is evidence of a timestamp.
|
||||
</td></tr></table>
|
||||
</td></tr></table>
|
||||
</body></html>
|
||||
|
|
@ -81,12 +86,18 @@ If this wasn't you, you can ignore this email.
|
|||
|
||||
Verae TimeStamp - automated message; replies are not monitored.
|
||||
`
|
||||
htmlBody := `<!DOCTYPE html><html><body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;background:#f4f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:auto;background:#fff;border-radius:12px;padding:28px;">
|
||||
<h2 style="margin-top:0;color:#0b3d91;">You already have a Verae account</h2>
|
||||
<p>Someone (hopefully you) tried to create a new Verae account with this email address.</p>
|
||||
<p>In the installer choose <b>Sign in to my Verae account</b>. Forgot your password? Reset it at
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;">customer.verae.ai</a>.</p>
|
||||
<p style="color:#52606d;font-size:14px;">If this wasn't you, ignore this email.</p></div></body></html>`
|
||||
htmlBody := `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>You already have a Verae account</title></head>
|
||||
<body style="margin:0;padding:0;background:#F4F1F8;font-family:Inter,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1D1534;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F4F1F8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:100%;background:#FFFFFF;border-radius:14px;border:1px solid #E3DEF0;">
|
||||
<tr><td style="padding:24px 28px 8px;font-size:18px;font-weight:700;color:#531B59;">Verae<span style="display:inline-block;width:28px;height:3px;background:#C4A85A;border-radius:2px;margin-left:10px;vertical-align:middle;"></span></td></tr>
|
||||
<tr><td style="padding:8px 28px 0;font-size:22px;font-weight:700;color:#1D1534;">You already have a Verae account</td></tr>
|
||||
<tr><td style="padding:12px 28px 0;font-size:15px;line-height:22px;color:#5E5470;">Someone (hopefully you) tried to create a new Verae account with this email address.</td></tr>
|
||||
<tr><td style="padding:12px 28px 0;font-size:15px;line-height:22px;color:#5E5470;">In the installer choose <b style="color:#1D1534;">Sign in to my Verae account</b>. Forgot your password? Reset it at
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#531B59;font-weight:600;text-decoration:none;">customer.verae.ai</a>.</td></tr>
|
||||
<tr><td style="padding:12px 28px 0;font-size:13px;line-height:20px;color:#5E5470;">If this wasn't you, ignore this email.</td></tr>
|
||||
<tr><td style="padding:14px 28px 22px;border-top:1px solid #E3DEF0;font-size:11px;line-height:16px;color:#6B6478;">Automated message; replies are not monitored.</td></tr>
|
||||
</table></td></tr></table></body></html>`
|
||||
return Message{To: to, Subject: "You already have a Verae account", Text: text, HTML: htmlBody}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,27 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"github.com/phpdave11/gofpdf"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// Brand tokens (CERT-01 / BrandingBook): plum, gold, ink, muted, ok.
|
||||
const (
|
||||
plumR, plumG, plumB = 83, 27, 89 // #531B59
|
||||
goldR, goldG, goldB = 196, 168, 90 // #C4A85A
|
||||
goldDR, goldDG, goldDB = 110, 87, 23 // #6E5717
|
||||
inkR, inkG, inkB = 29, 21, 52 // #1D1534
|
||||
mutedR, mutedG, mutedB = 94, 84, 112 // #5E5470
|
||||
okR, okG, okB = 14, 107, 58 // #0E6B3A
|
||||
okBgR, okBgG, okBgB = 227, 245, 234 // #E3F5EA
|
||||
okBdR, okBdG, okBdB = 185, 226, 200 // #B9E2C8
|
||||
linkR, linkG, linkB = 59, 42, 140 // #3B2A8C info
|
||||
)
|
||||
|
||||
// Minimal fallback when Node/jsPDF is unavailable.
|
||||
func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) {
|
||||
pdf := gofpdf.New("P", "pt", "Letter", "")
|
||||
|
|
@ -21,11 +36,12 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin
|
|||
pageW, pageH := pdf.GetPageSize()
|
||||
margin := 48.0
|
||||
|
||||
pdf.SetDrawColor(42, 24, 56)
|
||||
pdf.SetLineWidth(2)
|
||||
// CERT-01 double frame: plum outer, gold inner
|
||||
pdf.SetDrawColor(plumR, plumG, plumB)
|
||||
pdf.SetLineWidth(1.5)
|
||||
pdf.Rect(28, 28, pageW-56, pageH-56, "D")
|
||||
pdf.SetDrawColor(196, 168, 90)
|
||||
pdf.SetLineWidth(0.6)
|
||||
pdf.SetDrawColor(goldR, goldG, goldB)
|
||||
pdf.SetLineWidth(0.5)
|
||||
pdf.Rect(34, 34, pageW-68, pageH-68, "D")
|
||||
|
||||
if p, cleanup, err := materializeLogo(); err == nil {
|
||||
|
|
@ -35,60 +51,98 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin
|
|||
}
|
||||
}
|
||||
|
||||
pdf.SetTextColor(42, 24, 56)
|
||||
pdf.SetFont("Times", "B", 18)
|
||||
pdf.SetXY(margin, 100)
|
||||
pdf.CellFormat(pageW-margin*2, 20, "Verae Timestamp Registration Document", "", 0, "C", false, 0, "")
|
||||
// Gold-d eyebrow (BrandingBook §12)
|
||||
pdf.SetTextColor(goldDR, goldDG, goldDB)
|
||||
pdf.SetFont("Helvetica", "B", 9)
|
||||
pdf.SetXY(margin, 92)
|
||||
pdf.CellFormat(pageW-margin*2, 11, "TIMESTAMP CERTIFICATE", "", 0, "C", false, 0, "")
|
||||
|
||||
pdf.SetTextColor(90, 80, 100)
|
||||
// Title — CERT-01 serif / ink
|
||||
pdf.SetTextColor(inkR, inkG, inkB)
|
||||
pdf.SetFont("Times", "B", 22)
|
||||
pdf.SetXY(margin, 106)
|
||||
pdf.CellFormat(pageW-margin*2, 24, "Verae Timestamp Certificate", "", 0, "C", false, 0, "")
|
||||
|
||||
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
pdf.SetXY(margin, 122)
|
||||
pdf.CellFormat(pageW-margin*2, 12, "Official certificate of digital content registration on the VeraeTime global ledger", "", 0, "C", false, 0, "")
|
||||
pdf.SetXY(margin, 132)
|
||||
pdf.CellFormat(pageW-margin*2, 12, "Proof that this exact document existed at the time below,", "", 0, "C", false, 0, "")
|
||||
pdf.SetXY(margin, 144)
|
||||
pdf.CellFormat(pageW-margin*2, 12, "recorded on the "+statuslang.ChainName+" (digests only)", "", 0, "C", false, 0, "")
|
||||
|
||||
pdf.SetDrawColor(196, 168, 90)
|
||||
pdf.SetLineWidth(1)
|
||||
pdf.Line(margin, 142, pageW-margin, 142)
|
||||
// Short centered gold rule (CERT-01)
|
||||
ruleW := 120.0
|
||||
pdf.SetDrawColor(goldR, goldG, goldB)
|
||||
pdf.SetLineWidth(2)
|
||||
pdf.Line(pageW/2-ruleW/2, 162, pageW/2+ruleW/2, 162)
|
||||
|
||||
// FINAL-only status banner (receipt-final / statuslang wording)
|
||||
bannerY := 174.0
|
||||
bannerH := 40.0
|
||||
pdf.SetFillColor(okBgR, okBgG, okBgB)
|
||||
pdf.SetDrawColor(okBdR, okBdG, okBdB)
|
||||
pdf.SetLineWidth(1.5)
|
||||
pdf.Rect(margin, bannerY, pageW-margin*2, bannerH, "FD")
|
||||
pdf.SetTextColor(okR, okG, okB)
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
pdf.SetXY(margin+12, bannerY+8)
|
||||
pdf.Cell(pageW-margin*2-24, 14, finalBannerTitle(merkle))
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
pdf.SetXY(margin+12, bannerY+24)
|
||||
pdf.Cell(pageW-margin*2-24, 12, statuslang.EvidenceNote)
|
||||
|
||||
if created == "" {
|
||||
created = "—"
|
||||
}
|
||||
mh, b3, b3mh := ResolveDigests(in)
|
||||
fields := [][2]string{
|
||||
{"Document name", docName},
|
||||
{"Content SHA-256", in.SHA256},
|
||||
{"Registration scope", scope},
|
||||
{"Original timestamp (UTC)", created},
|
||||
{"Receipt ID (CID)", orDash(in.ReceiptID)},
|
||||
{"Merkle / block hash", merkle},
|
||||
{"Ledger location", "VeraeTime global ledger - api.veraetime.net"},
|
||||
{"Blockchain explorer", "https://explorer.verae.ai/"},
|
||||
{"Public lookup URL", lookup},
|
||||
{"Document", docName},
|
||||
}
|
||||
if in.ByteLength > 0 {
|
||||
fields = append([][2]string{
|
||||
{"Document name", docName},
|
||||
{"Document size", fmt.Sprintf("%d bytes", in.ByteLength)},
|
||||
}, fields[1:]...)
|
||||
fields = append(fields, [2]string{"Document size", fmt.Sprintf("%d bytes", in.ByteLength)})
|
||||
}
|
||||
// CERT-01: digest lines only when data is available (never invent).
|
||||
if mh != "" {
|
||||
fields = append(fields, [2]string{"Content multihash", mh})
|
||||
}
|
||||
if strings.TrimSpace(in.SHA256) != "" {
|
||||
fields = append(fields, [2]string{"SHA-256 (hex)", in.SHA256})
|
||||
}
|
||||
if b3mh != "" {
|
||||
fields = append(fields, [2]string{"BLAKE3 multihash", b3mh})
|
||||
}
|
||||
if b3 != "" {
|
||||
fields = append(fields, [2]string{"BLAKE3 (hex)", b3})
|
||||
}
|
||||
fields = append(fields,
|
||||
[2]string{"Registration scope", scope},
|
||||
[2]string{"Original timestamp (UTC)", created},
|
||||
[2]string{"VeraeTime receipt (CID)", orDash(in.ReceiptID)},
|
||||
[2]string{"Merkle / block", merkle},
|
||||
[2]string{"Network", statuslang.ChainName + " (" + statuslang.NetworkName + ")"},
|
||||
[2]string{"Blockchain explorer", statuslang.ExplorerURL},
|
||||
[2]string{"Public lookup URL", lookup},
|
||||
)
|
||||
|
||||
isHTTP := func(s string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "http://") ||
|
||||
strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "https://")
|
||||
}
|
||||
|
||||
y := 168.0
|
||||
y := bannerY + bannerH + 18
|
||||
for _, fv := range fields {
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
pdf.SetTextColor(42, 24, 56)
|
||||
pdf.SetFont("Helvetica", "B", 9)
|
||||
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
||||
pdf.SetXY(margin, y)
|
||||
pdf.Cell(pageW-margin*2, 12, fv[0])
|
||||
y += 14
|
||||
pdf.Cell(pageW-margin*2, 11, fv[0])
|
||||
y += 12
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
linkURL := ""
|
||||
if isHTTP(fv[1]) {
|
||||
linkURL = strings.TrimSpace(fv[1])
|
||||
pdf.SetTextColor(74, 46, 140)
|
||||
pdf.SetTextColor(linkR, linkG, linkB)
|
||||
} else {
|
||||
pdf.SetTextColor(40, 40, 50)
|
||||
pdf.SetTextColor(inkR, inkG, inkB)
|
||||
}
|
||||
for _, ln := range pdf.SplitLines([]byte(fv[1]), pageW-margin*2-10) {
|
||||
pdf.SetXY(margin, y)
|
||||
|
|
@ -99,7 +153,7 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin
|
|||
}
|
||||
y += 12
|
||||
}
|
||||
y += 10
|
||||
y += 8
|
||||
if y > pageH-220 {
|
||||
break
|
||||
}
|
||||
|
|
@ -109,32 +163,33 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin
|
|||
if qrFile, qclean, qerr := writeTempPNG(qrPNG); qerr == nil {
|
||||
qrSize := 110.0
|
||||
qrX := pageW - margin - qrSize
|
||||
qrY := pageH - margin - qrSize - 36
|
||||
qrY := pageH - margin - qrSize - 48
|
||||
pdf.ImageOptions(qrFile, qrX, qrY, qrSize, qrSize, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
|
||||
pdf.LinkString(qrX, qrY, qrSize, qrSize, lookup)
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
pdf.SetTextColor(74, 46, 140)
|
||||
pdf.SetFont("Helvetica", "B", 8)
|
||||
pdf.SetTextColor(linkR, linkG, linkB)
|
||||
pdf.SetXY(qrX, qrY+qrSize+4)
|
||||
pdf.CellFormat(qrSize, 10, "Scan to look up this registration", "", 0, "C", false, 0, lookup)
|
||||
pdf.CellFormat(qrSize, 10, "Scan to verify", "", 0, "C", false, 0, lookup)
|
||||
if qclean != nil {
|
||||
defer qclean()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final-only legal + digests-only (CERT-01 footer / BrandingBook)
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
pdf.SetTextColor(90, 80, 100)
|
||||
pdf.SetXY(margin, pageH-64)
|
||||
pdf.MultiCell(pageW-margin*2-130, 10, "This document certifies that the SHA-256 digest above was registered with Verae. File bytes were not uploaded; only the digest was submitted.", "", "L", false)
|
||||
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
||||
pdf.SetXY(margin, pageH-72)
|
||||
pdf.MultiCell(pageW-margin*2-130, 10, "Only the document's SHA-256 digest was submitted; the file was not uploaded. This certificate is final receipt evidence for the digest above. A jobId or pending hold is not proof. Anyone can verify this receipt for free — no account needed.", "", "L", false)
|
||||
pdf.SetFont("Helvetica", "B", 9)
|
||||
pdf.SetTextColor(42, 24, 56)
|
||||
pdf.SetTextColor(plumR, plumG, plumB)
|
||||
pdf.SetXY(margin, pageH-36)
|
||||
pdf.Cell(40, 12, "Verae | ")
|
||||
pdf.SetTextColor(74, 46, 140)
|
||||
pdf.CellFormat(55, 12, "verae.com", "", 0, "L", false, 0, "https://verae.com")
|
||||
pdf.SetTextColor(42, 24, 56)
|
||||
pdf.Cell(12, 12, " | ")
|
||||
pdf.SetTextColor(74, 46, 140)
|
||||
pdf.Cell(55, 12, "Verae LLC · ")
|
||||
pdf.SetTextColor(linkR, linkG, linkB)
|
||||
pdf.CellFormat(40, 12, "verae.ai", "", 0, "L", false, 0, "https://verae.ai")
|
||||
pdf.SetTextColor(plumR, plumG, plumB)
|
||||
pdf.Cell(12, 12, " · ")
|
||||
pdf.SetTextColor(linkR, linkG, linkB)
|
||||
pdf.CellFormat(90, 12, "explorer.verae.ai", "", 0, "L", false, 0, "https://explorer.verae.ai/")
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -144,6 +199,17 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin
|
|||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// finalBannerTitle uses statuslang.Label("final", block) — BrandingBook chip text.
|
||||
// Certificates are issued only for evidence states (IsEvidence); hold ≠ evidence.
|
||||
func finalBannerTitle(merkle string) string {
|
||||
s := strings.TrimSpace(merkle)
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
return statuslang.Label("final", n)
|
||||
}
|
||||
// Block unknown: BrandingBook #N placeholder (still final / evidence wording).
|
||||
return statuslang.Label("final")
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
|
|
|
|||
|
|
@ -15,17 +15,70 @@ import (
|
|||
//go:embed logo.png
|
||||
var embeddedLogo []byte
|
||||
|
||||
// Input for the official Verae Timestamp Registration Document.
|
||||
// Input for the official Verae Timestamp Certificate (CERT-01).
|
||||
type Input struct {
|
||||
DocumentName string
|
||||
SHA256 string
|
||||
ReceiptID string
|
||||
CreatedAt string
|
||||
Merkle string
|
||||
Scope string
|
||||
VerifyURL string
|
||||
Channel string
|
||||
ByteLength int64
|
||||
// Multihash is the content multihash (f1220 + sha2-256 hex) when known.
|
||||
// Empty → derive from SHA256 if valid; else omit (never invent).
|
||||
Multihash string
|
||||
// Blake3 is the optional BLAKE3-256 hex digest when provided upstream.
|
||||
// Empty → omit (never invent / never compute from file bytes here).
|
||||
Blake3 string
|
||||
// Blake3Multihash is the blake3 multihash (f1e20 + hex) when known.
|
||||
// Empty → derive from Blake3 if valid 64-hex; else omit.
|
||||
Blake3Multihash string
|
||||
ReceiptID string
|
||||
CreatedAt string
|
||||
Merkle string
|
||||
Scope string
|
||||
VerifyURL string
|
||||
Channel string
|
||||
ByteLength int64
|
||||
}
|
||||
|
||||
// ContentMultihash returns sha2-256 multihash hex (code 0x12, len 0x20 → f1220…)
|
||||
// when sha256 is a 64-char hex digest; otherwise "" (caller omits the field).
|
||||
func ContentMultihash(sha256 string) string {
|
||||
return contentMultihashPrefixed("1220", sha256)
|
||||
}
|
||||
|
||||
// ContentBlake3Multihash returns blake3 multihash hex (code 0x1e, len 0x20 → f1e20…)
|
||||
// when blake3 is a 64-char hex digest; otherwise "" (caller omits the field).
|
||||
func ContentBlake3Multihash(blake3 string) string {
|
||||
return contentMultihashPrefixed("1e20", blake3)
|
||||
}
|
||||
|
||||
// contentMultihashPrefixed wraps a 64-char hex digest as multibase-base16 multihash
|
||||
// ("f" + code/len prefix + digest). Invalid/empty → "" (never invent).
|
||||
func contentMultihashPrefixed(codeLen, hexDigest string) string {
|
||||
s := strings.TrimSpace(hexDigest)
|
||||
s = strings.TrimPrefix(strings.ToLower(s), "0x")
|
||||
if len(s) != 64 {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "f" + codeLen + s
|
||||
}
|
||||
|
||||
// ResolveDigests returns CERT multihash / blake3 display values from Input.
|
||||
// Empty strings mean omit that line (never invent digests).
|
||||
func ResolveDigests(in Input) (mh, blake3Hex, blake3MH string) {
|
||||
mh = strings.TrimSpace(in.Multihash)
|
||||
if mh == "" {
|
||||
mh = ContentMultihash(in.SHA256)
|
||||
}
|
||||
blake3Hex = strings.TrimSpace(in.Blake3)
|
||||
blake3MH = strings.TrimSpace(in.Blake3Multihash)
|
||||
if blake3MH == "" {
|
||||
blake3MH = ContentBlake3Multihash(in.Blake3)
|
||||
}
|
||||
return mh, blake3Hex, blake3MH
|
||||
}
|
||||
|
||||
// LookupURL builds a public VeraeTime verify deep link (same as explorer
|
||||
|
|
@ -47,8 +100,9 @@ func LookupURL(sha256, receiptID string) string {
|
|||
return u
|
||||
}
|
||||
|
||||
// Build returns the branded registration PDF using the same jsPDF layout as
|
||||
// Verae Timestamping.app / register.html export when Node is available.
|
||||
// Build returns the branded CERT-01 certificate PDF (plum/gold, final-only
|
||||
// evidence copy). Prefers platform/registration-pdf/build.mjs when Node is
|
||||
// available; otherwise gofpdf fallback.
|
||||
func Build(in Input) ([]byte, error) {
|
||||
created := in.CreatedAt
|
||||
if created == "" {
|
||||
|
|
@ -72,6 +126,7 @@ func Build(in Input) ([]byte, error) {
|
|||
docName = "(digest only)"
|
||||
}
|
||||
|
||||
mh, b3, b3mh := ResolveDigests(in)
|
||||
row := map[string]any{
|
||||
"file": docName,
|
||||
"sha256": in.SHA256,
|
||||
|
|
@ -82,6 +137,16 @@ func Build(in Input) ([]byte, error) {
|
|||
"lookup_url": lookup,
|
||||
"size_bytes": in.ByteLength,
|
||||
}
|
||||
// Omit digest fields when empty — never invent.
|
||||
if mh != "" {
|
||||
row["multihash"] = mh
|
||||
}
|
||||
if b3 != "" {
|
||||
row["blake3"] = b3
|
||||
}
|
||||
if b3mh != "" {
|
||||
row["blake3_multihash"] = b3mh
|
||||
}
|
||||
|
||||
if b, err := buildWithNode(row); err == nil && len(b) > 1000 {
|
||||
return b, nil
|
||||
|
|
@ -120,12 +185,21 @@ func buildWithNode(row map[string]any) ([]byte, error) {
|
|||
}
|
||||
|
||||
func findBuildScript() string {
|
||||
candidates := []string{
|
||||
"/Library/Application Support/Verae/PrintStamp/registration-pdf/build.mjs",
|
||||
"/Users/marchon/research/PrintToVeraeTimeStamp/platform/registration-pdf/build.mjs",
|
||||
filepath.Join("platform", "registration-pdf", "build.mjs"),
|
||||
// Prefer tree-local build.mjs (repo / package) so CERT-01 edits win over
|
||||
// a stale install under Application Support during tests and dev runs.
|
||||
var candidates []string
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
dir := wd
|
||||
for i := 0; i < 8; i++ {
|
||||
candidates = append(candidates, filepath.Join(dir, "platform", "registration-pdf", "build.mjs"))
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
// Walk up from executable
|
||||
candidates = append(candidates, filepath.Join("platform", "registration-pdf", "build.mjs"))
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates,
|
||||
|
|
@ -133,8 +207,14 @@ func findBuildScript() string {
|
|||
filepath.Join(dir, "..", "registration-pdf", "build.mjs"),
|
||||
filepath.Join(dir, "platform", "registration-pdf", "build.mjs"),
|
||||
filepath.Join(dir, "..", "platform", "registration-pdf", "build.mjs"),
|
||||
filepath.Join(dir, "..", "..", "platform", "registration-pdf", "build.mjs"),
|
||||
)
|
||||
}
|
||||
// Installed helper copy last (production path when no tree is present).
|
||||
candidates = append(candidates,
|
||||
"/Library/Application Support/Verae/PrintStamp/registration-pdf/build.mjs",
|
||||
"/Users/marchon/research/PrintToVeraeTimeStamp/platform/registration-pdf/build.mjs",
|
||||
)
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
abs, _ := filepath.Abs(c)
|
||||
|
|
|
|||
|
|
@ -32,15 +32,30 @@ func TestBuildBrandedRegistration(t *testing.T) {
|
|||
if strings.Contains(s, "via MCP") {
|
||||
t.Fatal("must not say via MCP")
|
||||
}
|
||||
if !strings.Contains(s, "Verae Timestamp Registration Document") {
|
||||
t.Fatal("missing title")
|
||||
// CERT-01 title (full phrase or split across PDF text operators)
|
||||
if !strings.Contains(s, "Verae Timestamp Certificate") && !strings.Contains(s, "Timestamp Certificate") {
|
||||
t.Fatal("missing CERT-01 title")
|
||||
}
|
||||
if !strings.Contains(s, "only the digest was submitted") {
|
||||
if !strings.Contains(s, "only the digest was submitted") && !strings.Contains(s, "SHA-256 digest was submitted") {
|
||||
t.Fatal("missing digests-only disclaimer")
|
||||
}
|
||||
hasFinal := strings.Contains(s, "final receipt") || strings.Contains(s, "Final:") || strings.Contains(s, "finalized") || strings.Contains(s, "FINAL")
|
||||
if !hasFinal {
|
||||
t.Fatal("missing final-only evidence language")
|
||||
}
|
||||
// Multihash when SHA-256 is on the receipt/input (CERT-01).
|
||||
if !strings.Contains(s, "f1220") {
|
||||
t.Fatal("expected content multihash f1220… when SHA-256 available")
|
||||
}
|
||||
if !strings.Contains(s, "not proof") && !strings.Contains(s, "jobId") {
|
||||
t.Fatal("expected jobId/hold not-proof disclaimer")
|
||||
}
|
||||
if !strings.Contains(s, "explorer.verae.ai") {
|
||||
t.Fatal("expected explorer lookup")
|
||||
}
|
||||
if !strings.Contains(s, "Substrate") {
|
||||
t.Fatal("expected Verae Substrate chain wording")
|
||||
}
|
||||
// Clickable link annotations (jsPDF textWithLink / link, or gofpdf LinkString)
|
||||
if !strings.Contains(s, "/URI") && !strings.Contains(s, "/URI(") {
|
||||
if !strings.Contains(s, "URI") || !strings.Contains(s, "explorer.verae.ai/time") {
|
||||
|
|
@ -52,6 +67,156 @@ func TestBuildBrandedRegistration(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFinalBannerTitle(t *testing.T) {
|
||||
if got := finalBannerTitle("113198"); got != "Final: Substrate block #113,198, finalized" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := finalBannerTitle(""); got != "Final: Substrate block #N, finalized" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := finalBannerTitle("0xabc"); got != "Final: Substrate block #N, finalized" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentMultihash(t *testing.T) {
|
||||
hex := "20515740e1051f69c552b83ff1a919398feca768090930a4a502f9335273f279"
|
||||
want := "f1220" + hex
|
||||
if got := ContentMultihash(hex); got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if got := ContentMultihash("0x" + hex); got != want {
|
||||
t.Fatalf("0x prefix: %q", got)
|
||||
}
|
||||
if ContentMultihash("") != "" || ContentMultihash("not-a-hash") != "" {
|
||||
t.Fatal("empty/invalid must omit multihash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlake3Multihash(t *testing.T) {
|
||||
hex := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
want := "f1e20" + hex
|
||||
if got := ContentBlake3Multihash(hex); got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if got := ContentBlake3Multihash("0x" + hex); got != want {
|
||||
t.Fatalf("0x prefix: %q", got)
|
||||
}
|
||||
if ContentBlake3Multihash("") != "" || ContentBlake3Multihash("short") != "" {
|
||||
t.Fatal("empty/invalid must omit blake3 multihash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDigestsOmitWhenEmpty(t *testing.T) {
|
||||
mh, b3, b3mh := ResolveDigests(Input{})
|
||||
if mh != "" || b3 != "" || b3mh != "" {
|
||||
t.Fatalf("empty input must omit all digests, got mh=%q b3=%q b3mh=%q", mh, b3, b3mh)
|
||||
}
|
||||
// Invalid lengths must not invent multihash prefixes.
|
||||
mh, b3, b3mh = ResolveDigests(Input{SHA256: "abc", Blake3: "not-64-hex"})
|
||||
if mh != "" || b3mh != "" {
|
||||
t.Fatalf("invalid digests must not yield multihash, got mh=%q b3mh=%q", mh, b3mh)
|
||||
}
|
||||
if b3 != "not-64-hex" {
|
||||
t.Fatalf("raw blake3 hex is passed through when set: %q", b3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDigestsFromUpstream(t *testing.T) {
|
||||
sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
b3 := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
mh, gotB3, b3mh := ResolveDigests(Input{SHA256: sha, Blake3: b3})
|
||||
if mh != "f1220"+sha {
|
||||
t.Fatalf("mh: %q", mh)
|
||||
}
|
||||
if gotB3 != b3 {
|
||||
t.Fatalf("blake3 hex: %q", gotB3)
|
||||
}
|
||||
if b3mh != "f1e20"+b3 {
|
||||
t.Fatalf("blake3 mh: %q", b3mh)
|
||||
}
|
||||
// Explicit multihash wins over derivation.
|
||||
mh, _, b3mh = ResolveDigests(Input{
|
||||
SHA256: sha,
|
||||
Multihash: "f1220deadbeef",
|
||||
Blake3: b3,
|
||||
Blake3Multihash: "f1e20cafebabe",
|
||||
})
|
||||
if mh != "f1220deadbeef" || b3mh != "f1e20cafebabe" {
|
||||
t.Fatalf("explicit: mh=%q b3mh=%q", mh, b3mh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIncludesMultihashWhenSHAAvailable(t *testing.T) {
|
||||
hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
b, err := Build(Input{
|
||||
DocumentName: "Board.pdf",
|
||||
SHA256: hex,
|
||||
ReceiptID: "QmTestReceipt123",
|
||||
CreatedAt: "2026-10-03T12:00:00Z",
|
||||
Merkle: "1284530",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "f1220"+hex) && !strings.Contains(s, "Content multihash") {
|
||||
// PDF may split text operators; at least f1220 prefix + start of digest should appear.
|
||||
if !strings.Contains(s, "f1220") || !strings.Contains(s, "0123456789abcdef") {
|
||||
t.Fatal("expected content multihash when SHA-256 present")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, "Final: Substrate block") && !strings.Contains(s, "1,284,530") {
|
||||
t.Fatal("expected statuslang final banner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOmitsMultihashWithoutDigest(t *testing.T) {
|
||||
b, err := Build(Input{
|
||||
DocumentName: "Board.pdf",
|
||||
ReceiptID: "QmTestReceipt123",
|
||||
Merkle: "99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if strings.Contains(s, "Content multihash") || strings.Contains(s, "f1220") {
|
||||
t.Fatal("must not invent multihash without digest data")
|
||||
}
|
||||
if strings.Contains(s, "BLAKE3") || strings.Contains(s, "f1e20") || strings.Contains(s, "blake3") {
|
||||
t.Fatal("must not invent blake3 without upstream digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFallbackIncludesBlake3WhenProvided(t *testing.T) {
|
||||
// Exercise gofpdf path directly so node template (B3 lock) is not required.
|
||||
sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
b3 := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
in := Input{
|
||||
DocumentName: "Board.pdf",
|
||||
SHA256: sha,
|
||||
Blake3: b3,
|
||||
ReceiptID: "QmTestReceipt123",
|
||||
CreatedAt: "2026-10-03T12:00:00Z",
|
||||
Merkle: "99",
|
||||
}
|
||||
pdf, err := buildFallbackGofpdf(in, "Board.pdf", in.CreatedAt, "GLOBAL", in.Merkle, LookupURL(sha, in.ReceiptID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(pdf)
|
||||
if !strings.Contains(s, "f1e20"+b3) && !strings.Contains(s, "f1e20") {
|
||||
t.Fatal("expected blake3 multihash when Blake3 provided")
|
||||
}
|
||||
if !strings.Contains(s, b3) && !strings.Contains(s, "BLAKE3") {
|
||||
t.Fatal("expected blake3 hex line when Blake3 provided")
|
||||
}
|
||||
if !strings.Contains(s, "f1220") {
|
||||
t.Fatal("expected sha2-256 multihash alongside blake3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupURLNoMCP(t *testing.T) {
|
||||
u := LookupURL("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "QmTestReceipt123")
|
||||
if strings.Contains(u, "mcp.verae.ai") {
|
||||
|
|
|
|||
270
internal/statuslang/status.go
Normal file
270
internal/statuslang/status.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Package statuslang holds exact Verae 2026 receipt status strings (BrandingBook).
|
||||
// Only "final" (and "existing") is evidence — never present a jobId as proof.
|
||||
package statuslang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Exact BrandingBook receipt status labels (brand-tokens status.*).
|
||||
const (
|
||||
Queued = "Queued offline"
|
||||
Hashing = "Hashing on device…"
|
||||
Existing = "Already registered"
|
||||
Pending = "Pending receipt"
|
||||
Provisional = "Stamped, awaiting blockchain"
|
||||
Failed = "Failed – retrying"
|
||||
Action = "Failed – action needed"
|
||||
|
||||
// Notify / tray short titles (not chip labels; still brand-aligned).
|
||||
FinalReceiptReady = "Final receipt ready"
|
||||
SignInToFinish = "Sign in to finish timestamp"
|
||||
PrinterReady = "Printer ready"
|
||||
|
||||
// EvidenceNote is the standing disclaimer for queue / tray UI.
|
||||
EvidenceNote = "Only a final receipt is evidence — hold ids are local queue handles, not proof."
|
||||
|
||||
// BrandingBook proof vocabulary (name the chain fully once per view).
|
||||
ChainName = "Verae Substrate blockchain"
|
||||
NetworkName = "VeraeNet"
|
||||
VerifyBase = "https://verify.verae.ai"
|
||||
ExplorerURL = "https://explorer.verae.ai/time"
|
||||
)
|
||||
|
||||
// BrandingBook status.desc strings (tooltips / secondary copy).
|
||||
const (
|
||||
DescQueued = "Not yet sent. Will submit when online."
|
||||
DescHashing = "SHA-256 computed locally; file never leaves the device."
|
||||
DescExisting = "Hash was known: existing receipt returned instantly."
|
||||
DescPending = "Submitted. Waiting for VeraeTime + Substrate."
|
||||
DescProvisional = "VeraeTime receipt issued; not yet in a Substrate block."
|
||||
DescIncluded = "In a Substrate block; waiting for GRANDPA finality."
|
||||
DescFinal = "GRANDPA-finalized. Receipt + proof available. This is evidence."
|
||||
DescFailed = "Submission failed; retrying with backoff (never resubmits blindly)."
|
||||
DescAction = "Needs the user (e.g. key expired, 401)."
|
||||
)
|
||||
|
||||
func Included(block int) string {
|
||||
return fmt.Sprintf("Included in block #%s (not final)", comma(block))
|
||||
}
|
||||
|
||||
func Finalized(block int) string {
|
||||
return fmt.Sprintf("Final: Substrate block #%s, finalized", comma(block))
|
||||
}
|
||||
|
||||
// IncludedN / FinalizedN use literal "N" when the block number is unknown
|
||||
// (matches BrandingBook / Swift ReceiptState when block is nil).
|
||||
func IncludedN() string {
|
||||
return "Included in block #N (not final)"
|
||||
}
|
||||
|
||||
func FinalizedN() string {
|
||||
return "Final: Substrate block #N, finalized"
|
||||
}
|
||||
|
||||
// Label returns the exact BrandingBook chip string for a receipt state key.
|
||||
// block is required for included/final when known; pass 0 (or omit) to use #N.
|
||||
func Label(state string, block ...int) string {
|
||||
var n int
|
||||
have := false
|
||||
if len(block) > 0 {
|
||||
n = block[0]
|
||||
have = n > 0
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "queued":
|
||||
return Queued
|
||||
case "hashing":
|
||||
return Hashing
|
||||
case "existing":
|
||||
return Existing
|
||||
case "pending":
|
||||
return Pending
|
||||
case "provisional":
|
||||
return Provisional
|
||||
case "included":
|
||||
if have {
|
||||
return Included(n)
|
||||
}
|
||||
return IncludedN()
|
||||
case "final", "completed", "ok":
|
||||
if have {
|
||||
return Finalized(n)
|
||||
}
|
||||
return FinalizedN()
|
||||
case "failed":
|
||||
return Failed
|
||||
case "action":
|
||||
return Action
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Desc returns the BrandingBook status.desc for a state key.
|
||||
func Desc(state string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "queued":
|
||||
return DescQueued
|
||||
case "hashing":
|
||||
return DescHashing
|
||||
case "existing":
|
||||
return DescExisting
|
||||
case "pending":
|
||||
return DescPending
|
||||
case "provisional":
|
||||
return DescProvisional
|
||||
case "included":
|
||||
return DescIncluded
|
||||
case "final", "completed", "ok":
|
||||
return DescFinal
|
||||
case "failed":
|
||||
return DescFailed
|
||||
case "action":
|
||||
return DescAction
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsEvidence is true only for final and already-registered (BrandingBook rule 3).
|
||||
// Certificates, Download proof, and legal claims are allowed only in these states.
|
||||
func IsEvidence(state string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "final", "completed", "ok", "existing":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyTitle is a short notification title for printit tray / OS banners.
|
||||
func NotifyTitle(state string) string {
|
||||
switch state {
|
||||
case "queued":
|
||||
return Queued
|
||||
case "hashing":
|
||||
return Hashing
|
||||
case "existing":
|
||||
return Existing
|
||||
case "pending":
|
||||
return Pending
|
||||
case "provisional":
|
||||
return Provisional
|
||||
case "failed":
|
||||
return Failed
|
||||
case "action":
|
||||
return Action
|
||||
case "held", "signin":
|
||||
return SignInToFinish
|
||||
case "final", "completed", "ok":
|
||||
return FinalReceiptReady
|
||||
default:
|
||||
return "Verae Timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyBody formats a short tray/banner body.
|
||||
// For held/pending, extra is a local hold id (never evidence).
|
||||
// For final, extra is the final receipt id (evidence).
|
||||
// For failed, extra may be a short error hint.
|
||||
func NotifyBody(state, title, extra string) string {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
t = "document"
|
||||
}
|
||||
switch state {
|
||||
case "held", "signin", "pending":
|
||||
if extra != "" {
|
||||
// Local hold id only — not a receipt / not proof.
|
||||
return fmt.Sprintf("%s · %s · hold %s — sign in to stamp", t, Pending, short(extra))
|
||||
}
|
||||
return fmt.Sprintf("%s · %s — sign in to stamp", t, Pending)
|
||||
case "failed", "action":
|
||||
if extra != "" {
|
||||
return fmt.Sprintf("%s · %s · %s", t, Failed, short(extra))
|
||||
}
|
||||
return fmt.Sprintf("%s · %s", t, Failed)
|
||||
case "final", "completed", "ok":
|
||||
if extra != "" {
|
||||
return fmt.Sprintf("%s · receipt %s", t, short(extra))
|
||||
}
|
||||
return t
|
||||
default:
|
||||
if extra != "" {
|
||||
return fmt.Sprintf("%s · %s", t, short(extra))
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyHeldJobs is the cancel / still-waiting banner when n jobs remain held.
|
||||
func NotifyHeldJobs(n int) (title, body string) {
|
||||
title = NotifyTitle("held")
|
||||
if n <= 0 {
|
||||
return title, Pending
|
||||
}
|
||||
return title, fmt.Sprintf("%d job(s) · %s — will stamp after you sign in", n, Pending)
|
||||
}
|
||||
|
||||
// TrayHeadline is the persistent status line for tray / menu / loopback health
|
||||
// (PT-W3 / PT-06 substitute when a native NotifyIcon loop is not available).
|
||||
// held is local pending count; keyOK means an xVerae- (or equivalent) key is stored;
|
||||
// helperUp means the IPP loopback helper answered.
|
||||
func TrayHeadline(held int, keyOK, helperUp bool) string {
|
||||
switch {
|
||||
case held > 0:
|
||||
return NotifyTitle("held")
|
||||
case !keyOK:
|
||||
return SignInToFinish
|
||||
case helperUp:
|
||||
return PrinterReady
|
||||
default:
|
||||
return "Helper not running"
|
||||
}
|
||||
}
|
||||
|
||||
// TrayDetail is a one-line body under TrayHeadline (brand labels; hold ≠ evidence).
|
||||
func TrayDetail(held int, keyOK, helperUp bool) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if helperUp {
|
||||
parts = append(parts, PrinterReady)
|
||||
} else {
|
||||
parts = append(parts, "helper down")
|
||||
}
|
||||
if keyOK {
|
||||
parts = append(parts, "key saved")
|
||||
} else {
|
||||
parts = append(parts, "no signing key")
|
||||
}
|
||||
if held > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d held · %s", held, Pending))
|
||||
} else {
|
||||
parts = append(parts, "queue empty")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func short(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= 24 {
|
||||
return s
|
||||
}
|
||||
return s[:20] + "…"
|
||||
}
|
||||
|
||||
func comma(n int) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if n < 1000 {
|
||||
return s
|
||||
}
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range s {
|
||||
if i != 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
99
internal/statuslang/status_test.go
Normal file
99
internal/statuslang/status_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package statuslang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLabels(t *testing.T) {
|
||||
// Exact BrandingBook chip strings (brand-tokens status.*).
|
||||
want := map[string]string{
|
||||
"queued": "Queued offline",
|
||||
"hashing": "Hashing on device…",
|
||||
"existing": "Already registered",
|
||||
"pending": "Pending receipt",
|
||||
"provisional": "Stamped, awaiting blockchain",
|
||||
"failed": "Failed – retrying",
|
||||
"action": "Failed – action needed",
|
||||
}
|
||||
for state, label := range want {
|
||||
if Label(state) != label {
|
||||
t.Fatalf("%s: got %q want %q", state, Label(state), label)
|
||||
}
|
||||
}
|
||||
if Pending != "Pending receipt" {
|
||||
t.Fatal(Pending)
|
||||
}
|
||||
if Finalized(1284600) != "Final: Substrate block #1,284,600, finalized" {
|
||||
t.Fatal(Finalized(1284600))
|
||||
}
|
||||
if Included(99) != "Included in block #99 (not final)" {
|
||||
t.Fatal(Included(99))
|
||||
}
|
||||
if Label("included", 1284515) != "Included in block #1,284,515 (not final)" {
|
||||
t.Fatal(Label("included", 1284515))
|
||||
}
|
||||
if Label("final", 1284512) != "Final: Substrate block #1,284,512, finalized" {
|
||||
t.Fatal(Label("final", 1284512))
|
||||
}
|
||||
if Label("included") != IncludedN() || Label("final") != FinalizedN() {
|
||||
t.Fatalf("unknown block placeholders: %q %q", Label("included"), Label("final"))
|
||||
}
|
||||
if NotifyTitle("final") != FinalReceiptReady {
|
||||
t.Fatal(NotifyTitle("final"))
|
||||
}
|
||||
if NotifyTitle("held") != SignInToFinish {
|
||||
t.Fatal(NotifyTitle("held"))
|
||||
}
|
||||
b := NotifyBody("held", "Board pack.pdf", "20261013-120000-ab")
|
||||
if b == "" || b == "Board pack.pdf" {
|
||||
t.Fatal(b)
|
||||
}
|
||||
if !strings.Contains(b, Pending) || !strings.Contains(b, "hold ") {
|
||||
t.Fatalf("held body should mark local hold, got %q", b)
|
||||
}
|
||||
if strings.Contains(b, "held as") {
|
||||
t.Fatalf("must not imply hold id is a receipt: %q", b)
|
||||
}
|
||||
f := NotifyBody("final", "Board pack.pdf", "bafkreiabc123xyz")
|
||||
if !strings.Contains(f, "receipt ") {
|
||||
t.Fatalf("final body should name receipt: %q", f)
|
||||
}
|
||||
ht, hb := NotifyHeldJobs(3)
|
||||
if ht != NotifyTitle("held") || !strings.Contains(hb, "3 job") || !strings.Contains(hb, Pending) {
|
||||
t.Fatalf("held jobs notify: %q %q", ht, hb)
|
||||
}
|
||||
if TrayHeadline(2, true, true) != NotifyTitle("held") {
|
||||
t.Fatalf("held headline: %q", TrayHeadline(2, true, true))
|
||||
}
|
||||
if TrayHeadline(0, true, true) != PrinterReady {
|
||||
t.Fatalf("ready headline: %q", TrayHeadline(0, true, true))
|
||||
}
|
||||
d := TrayDetail(1, false, true)
|
||||
if !strings.Contains(d, Pending) || !strings.Contains(d, "1 held") || !strings.Contains(d, "no signing key") {
|
||||
t.Fatalf("tray detail: %q", d)
|
||||
}
|
||||
if !strings.Contains(EvidenceNote, "final receipt") {
|
||||
t.Fatal(EvidenceNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEvidenceAndDesc(t *testing.T) {
|
||||
if !IsEvidence("final") || !IsEvidence("existing") || !IsEvidence("ok") {
|
||||
t.Fatal("final/existing must be evidence")
|
||||
}
|
||||
for _, s := range []string{"pending", "provisional", "included", "queued", "hashing", "failed", "action", "held"} {
|
||||
if IsEvidence(s) {
|
||||
t.Fatalf("%s must not be evidence", s)
|
||||
}
|
||||
}
|
||||
if Desc("pending") != DescPending || Desc("final") != DescFinal {
|
||||
t.Fatalf("desc: %q %q", Desc("pending"), Desc("final"))
|
||||
}
|
||||
if ChainName != "Verae Substrate blockchain" || NetworkName != "VeraeNet" {
|
||||
t.Fatalf("chain vocab: %q %q", ChainName, NetworkName)
|
||||
}
|
||||
if VerifyBase != "https://verify.verae.ai" {
|
||||
t.Fatal(VerifyBase)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue