From 48da24a6616a2f4e7e354b5d8c9853965a180d85 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Tue, 13 Oct 2026 21:11:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=202026=20brand=20UI=20=E2=80=94=20statusl?= =?UTF-8?q?ang,=20tray/menubar,=20CERT=20pdf,=20site=20kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + README.md | 17 +- cmd/verae-print/hide_other.go | 7 + cmd/verae-print/main.go | 25 +- cmd/verae-print/menubar.go | 577 ++++++++++++++++++ cmd/verae-print/menubar_test.go | 299 +++++++++ cmd/verae-print/pending.go | 178 +++++- cmd/verae-print/platform_other.go | 11 +- cmd/verae-print/platform_windows.go | 34 +- cmd/verae-print/serve.go | 22 +- cmd/verae-print/traystatus.go | 291 +++++++++ cmd/verae-print/traystatus_test.go | 142 +++++ internal/job/job.go | 50 +- internal/job/job_test.go | 63 +- internal/mailer/template.go | 51 +- internal/pdfcert/fallback.go | 164 +++-- internal/pdfcert/pdfcert.go | 110 +++- internal/pdfcert/pdfcert_test.go | 171 +++++- internal/statuslang/status.go | 270 ++++++++ internal/statuslang/status_test.go | 99 +++ internal/winprint/ipp.go | 75 ++- internal/winprint/ipp_test.go | 70 +++ platform/macos/menubar/README.txt | 105 ++++ platform/macos/menubar/build-menubar-app.sh | 133 ++++ .../com.verae.timestamping.menubar.plist | 28 + platform/macos/menubar/run-menubar.sh | 27 + platform/macos/menubar/verae-menubar.swift | 203 ++++++ platform/registration-pdf/build.mjs | 203 ++++-- platform/smoke-packaging-metadata.sh | 163 +++++ platform/windows/README-WINDOWS.txt | 55 +- platform/windows/README.txt | 25 + .../windows/scripts/build-tray-startmenu.ps1 | 241 ++++++++ platform/windows/scripts/common.ps1 | 21 + .../windows/scripts/install-tray-startup.ps1 | 91 +++ platform/windows/scripts/verae-tray.ps1 | 232 +++++++ .../brand/logo/printit-logo.png | Bin 0 -> 27484 bytes .../brand/logo/verae-mark-mono-black.svg | 10 + .../brand/logo/verae-mark-white.svg | 8 + .../brand/logo/verae-mark.svg | 10 + site/printit.verae.ai/brand/tokens.css | 146 +++++ site/printit.verae.ai/index.html | 34 +- site/printit.verae.ai/queue.html | 82 +++ site/printit.verae.ai/status-strings.json | 66 ++ site/printit.verae.ai/verae-customer-auth.js | 197 ++++++ site/printit.verae.ai/verae-ui-kit.css | 380 ++++++++++++ 45 files changed, 5004 insertions(+), 184 deletions(-) create mode 100644 cmd/verae-print/hide_other.go create mode 100644 cmd/verae-print/menubar.go create mode 100644 cmd/verae-print/menubar_test.go create mode 100644 cmd/verae-print/traystatus.go create mode 100644 cmd/verae-print/traystatus_test.go create mode 100644 internal/statuslang/status.go create mode 100644 internal/statuslang/status_test.go create mode 100644 platform/macos/menubar/README.txt create mode 100755 platform/macos/menubar/build-menubar-app.sh create mode 100644 platform/macos/menubar/com.verae.timestamping.menubar.plist create mode 100755 platform/macos/menubar/run-menubar.sh create mode 100755 platform/macos/menubar/verae-menubar.swift create mode 100755 platform/smoke-packaging-metadata.sh create mode 100644 platform/windows/scripts/build-tray-startmenu.ps1 create mode 100644 platform/windows/scripts/install-tray-startup.ps1 create mode 100644 platform/windows/scripts/verae-tray.ps1 create mode 100644 site/printit.verae.ai/brand/logo/printit-logo.png create mode 100644 site/printit.verae.ai/brand/logo/verae-mark-mono-black.svg create mode 100644 site/printit.verae.ai/brand/logo/verae-mark-white.svg create mode 100644 site/printit.verae.ai/brand/logo/verae-mark.svg create mode 100644 site/printit.verae.ai/brand/tokens.css create mode 100644 site/printit.verae.ai/queue.html create mode 100644 site/printit.verae.ai/status-strings.json create mode 100644 site/printit.verae.ai/verae-customer-auth.js create mode 100644 site/printit.verae.ai/verae-ui-kit.css diff --git a/.gitignore b/.gitignore index 7cd60e6..048248e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 6d6de9a..93ac918 100644 --- a/README.md +++ b/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 ``` diff --git a/cmd/verae-print/hide_other.go b/cmd/verae-print/hide_other.go new file mode 100644 index 0000000..15788a4 --- /dev/null +++ b/cmd/verae-print/hide_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +import "os/exec" + +func hideWindow(c *exec.Cmd) {} diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index d6bd74d..91447b7 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -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 } diff --git a/cmd/verae-print/menubar.go b/cmd/verae-print/menubar.go new file mode 100644 index 0000000..a4423e4 --- /dev/null +++ b/cmd/verae-print/menubar.go @@ -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, "VERAE_IPP_ADDR") { + // Replace the next … 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(` + + + + Label + %s + ProgramArguments + + /bin/bash + %s + + RunAtLoad + + KeepAlive + + ProcessType + Interactive + StandardOutPath + %s/verae-menubar.launchd.out.log + StandardErrorPath + %s/verae-menubar.launchd.err.log + EnvironmentVariables + + VERAE_IPP_ADDR + %s + + + +`, 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 value immediately following NAME. +func replaceEnvString(plist, key, value string) string { + needle := "" + key + "" + i := strings.Index(plist, needle) + if i < 0 { + return plist + } + rest := plist[i+len(needle):] + start := strings.Index(rest, "") + end := strings.Index(rest, "") + if start < 0 || end < 0 || end < start { + return plist + } + return plist[:i+len(needle)] + rest[:start] + "" + 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) +} diff --git a/cmd/verae-print/menubar_test.go b/cmd/verae-print/menubar_test.go new file mode 100644 index 0000000..e268379 --- /dev/null +++ b/cmd/verae-print/menubar_test.go @@ -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", + "127.0.0.1:8631", + "KeepAlive", + "RunAtLoad", + } { + if !strings.Contains(body, want) { + t.Fatalf("plist missing %q\n%s", want, body) + } + } +} + +func TestReplaceEnvString(t *testing.T) { + in := `VERAE_IPP_ADDR + 127.0.0.1:8631` + out := replaceEnvString(in, "VERAE_IPP_ADDR", "127.0.0.1:9999") + if !strings.Contains(out, "127.0.0.1:9999") { + 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 "" +} + diff --git a/cmd/verae-print/pending.go b/cmd/verae-print/pending.go index f9836f4..47312b8 100644 --- a/cmd/verae-print/pending.go +++ b/cmd/verae-print/pending.go @@ -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(`No held jobs — print with Destination → Verae Timestamping`) + } else { + for _, m := range list { + title := m.Title + if title == "" { + title = "(untitled)" + } + rows.WriteString(fmt.Sprintf( + `%s%s%s%s`, + 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: %s`, htmlEsc(snap.QueueURL), htmlEsc(snap.QueueURL)) + } else { + meta += ` · refresh: verae-print pending --html` + } + meta += fmt.Sprintf(` · verae-print tray-status`) + return fmt.Sprintf(` + + + +Print job queue · Verae Timestamping +
+

Print job queue

+

PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. %s

+
%s%s
+
+ +%s
Hold idDocumentStateCreated
+
Not proof: %s Wait for %s.
+

%s

+
+`, 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 "" +} diff --git a/cmd/verae-print/platform_other.go b/cmd/verae-print/platform_other.go index d7fe0c5..d757231 100644 --- a/cmd/verae-print/platform_other.go +++ b/cmd/verae-print/platform_other.go @@ -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() } diff --git a/cmd/verae-print/platform_windows.go b/cmd/verae-print/platform_windows.go index 7338077..6e1c889 100644 --- a/cmd/verae-print/platform_windows.go +++ b/cmd/verae-print/platform_windows.go @@ -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() } diff --git a/cmd/verae-print/serve.go b/cmd/verae-print/serve.go index 1cfd3fe..a7b3eeb 100644 --- a/cmd/verae-print/serve.go +++ b/cmd/verae-print/serve.go @@ -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 { diff --git a/cmd/verae-print/traystatus.go b/cmd/verae-print/traystatus.go new file mode 100644 index 0000000..c0563cd --- /dev/null +++ b/cmd/verae-print/traystatus.go @@ -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 +} diff --git a/cmd/verae-print/traystatus_test.go b/cmd/verae-print/traystatus_test.go new file mode 100644 index 0000000..9db0370 --- /dev/null +++ b/cmd/verae-print/traystatus_test.go @@ -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") + } +} diff --git a/internal/job/job.go b/internal/job/job.go index 0a21d4e..730683b 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -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 } diff --git a/internal/job/job_test.go b/internal/job/job_test.go index 9660123..10a450e 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -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) { diff --git a/internal/mailer/template.go b/internal/mailer/template.go index 6cb87eb..ebef2f6 100644 --- a/internal/mailer/template.go +++ b/internal/mailer/template.go @@ -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(` - + Your Verae verification code - - + +
- - -
Verae TimeStamp
-

%s,

-

Enter this code in %s to finish creating your Verae account:

-
%s
-

The code expires in %d minutes and can be used once.

-

If you did not try to create a Verae account, ignore this email — no account is created without this code.

+ + + + + + - + +
Verae
Your verification code
%s,
Enter this code in %s to finish creating your Verae account:
+
%s
-customer.verae.ai · Automated message, replies are not monitored. +
The code expires in %d minutes and can be used once.
If you did not try to create a Verae account, ignore this email — no account is created without this code.
+customer.verae.ai +· Automated message; replies are not monitored. +· Only a final blockchain receipt is evidence of a timestamp.
@@ -81,12 +86,18 @@ If this wasn't you, you can ignore this email. Verae TimeStamp - automated message; replies are not monitored. ` - htmlBody := ` -
-

You already have a Verae account

-

Someone (hopefully you) tried to create a new Verae account with this email address.

-

In the installer choose Sign in to my Verae account. Forgot your password? Reset it at -customer.verae.ai.

-

If this wasn't you, ignore this email.

` + htmlBody := `You already have a Verae account + + +
+ + + + + + + +
Verae
You already have a Verae account
Someone (hopefully you) tried to create a new Verae account with this email address.
In the installer choose Sign in to my Verae account. Forgot your password? Reset it at +customer.verae.ai.
If this wasn't you, ignore this email.
Automated message; replies are not monitored.
` return Message{To: to, Subject: "You already have a Verae account", Text: text, HTML: htmlBody} } diff --git a/internal/pdfcert/fallback.go b/internal/pdfcert/fallback.go index a1559ab..15e0844 100644 --- a/internal/pdfcert/fallback.go +++ b/internal/pdfcert/fallback.go @@ -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 "—" diff --git a/internal/pdfcert/pdfcert.go b/internal/pdfcert/pdfcert.go index 655062c..94c5df9 100644 --- a/internal/pdfcert/pdfcert.go +++ b/internal/pdfcert/pdfcert.go @@ -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) diff --git a/internal/pdfcert/pdfcert_test.go b/internal/pdfcert/pdfcert_test.go index 14b05d6..cad57a0 100644 --- a/internal/pdfcert/pdfcert_test.go +++ b/internal/pdfcert/pdfcert_test.go @@ -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") { diff --git a/internal/statuslang/status.go b/internal/statuslang/status.go new file mode 100644 index 0000000..527e874 --- /dev/null +++ b/internal/statuslang/status.go @@ -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) +} diff --git a/internal/statuslang/status_test.go b/internal/statuslang/status_test.go new file mode 100644 index 0000000..5188948 --- /dev/null +++ b/internal/statuslang/status_test.go @@ -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) + } +} diff --git a/internal/winprint/ipp.go b/internal/winprint/ipp.go index 8dabb85..2c4305d 100644 --- a/internal/winprint/ipp.go +++ b/internal/winprint/ipp.go @@ -46,11 +46,17 @@ type IPPServer struct { SpoolDir string // where documents are written Formats []string // document-format-supported Process Processor - started time.Time - mu sync.Mutex - nextID int - jobs map[int]*ippJob - queue chan Job + // Optional GET-only extras (never called for IPP POST). Used for PT-W3/W4 + // loopback status: plain health lines, HTML job queue, JSON status. + // Keep Addr on 127.0.0.1 so these stay local to the signed-in user session. + ExtraHealth func() string + ServeQueue func(http.ResponseWriter, *http.Request) // GET /queue + ServeStatus func(http.ResponseWriter, *http.Request) // GET /status + started time.Time + mu sync.Mutex + nextID int + jobs map[int]*ippJob + queue chan Job } type ippJob struct { @@ -144,16 +150,14 @@ func (s *IPPServer) setState(id, st int, reasons, msg string) { } } -// ServeHTTP handles IPP POSTs (any path) and a plain-text GET status page. +// ServeHTTP handles IPP POSTs (any path) and GET status pages. +// POST is always IPP. GET/HEAD never touch the IPP decoder (Windows print clients +// only POST). /queue and /status are optional human/machine tray alternatives +// (PT-W3 flyout substitute); default / is plain-text health. func (s *IPPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { vlog.Debugf("http: %s %s from=%s ct=%q ua=%q len=%d te=%v", r.Method, r.URL.Path, r.RemoteAddr, r.Header.Get("Content-Type"), r.UserAgent(), r.ContentLength, r.TransferEncoding) if r.Method == http.MethodGet || r.Method == http.MethodHead { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - s.mu.Lock() - n := len(s.jobs) - s.mu.Unlock() - fmt.Fprintf(w, "Verae TimeStamp PDF IPP helper\nprinter-uri: ipp://%s/ipp/print\nformats: %s\njobs-seen: %d\nup-since: %s\npid: %d\n", - s.Addr, strings.Join(s.Formats, ","), n, s.started.Format(time.RFC3339), os.Getpid()) + s.serveGET(w, r) return } if r.Method != http.MethodPost { @@ -188,6 +192,50 @@ func (s *IPPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (s *IPPServer) serveGET(w http.ResponseWriter, r *http.Request) { + path := strings.TrimSuffix(r.URL.Path, "/") + if path == "" { + path = "/" + } + switch path { + case "/queue", "/jobs": + if s.ServeQueue != nil { + s.ServeQueue(w, r) + return + } + case "/status", "/status.json", "/health.json": + if s.ServeStatus != nil { + s.ServeStatus(w, r) + return + } + } + // Plain health (also / and /health). Safe for scripts and printer-more-info probes. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + s.mu.Lock() + n := len(s.jobs) + active := 0 + for _, j := range s.jobs { + if j.state < jsCanceled { + active++ + } + } + s.mu.Unlock() + fmt.Fprintf(w, "Verae TimeStamp PDF IPP helper\nprinter-uri: ipp://%s/ipp/print\nformats: %s\njobs-seen: %d\nipp-active: %d\nup-since: %s\npid: %d\nqueue-url: http://%s/queue\nstatus-url: http://%s/status\n", + s.Addr, strings.Join(s.Formats, ","), n, active, s.started.Format(time.RFC3339), os.Getpid(), s.Addr, s.Addr) + if s.ExtraHealth != nil { + if extra := s.ExtraHealth(); extra != "" { + if !strings.HasSuffix(extra, "\n") { + extra += "\n" + } + fmt.Fprint(w, extra) + } + } +} + func (s *IPPServer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message { resp := goipp.NewResponse(req.Version, st, req.RequestID) resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) @@ -487,7 +535,8 @@ func (s *IPPServer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attr add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(s.Name))) add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae TimeStamp PDF"))) add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("This PC (loopback)"))) - add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String("http://"+s.Addr+"/"))) + // Human queue page (GET /queue); not used by the IPP print path. + add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String("http://"+s.Addr+"/queue"))) add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(s.uuid()))) add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:TimeStamp PDF;CMD:PDF,PWGRaster;CLS:PRINTER;DES:Verae TimeStamp PDF;"))) add(ints("printer-state", goipp.TagEnum, state)) diff --git a/internal/winprint/ipp_test.go b/internal/winprint/ipp_test.go index a9bac28..3b481c6 100644 --- a/internal/winprint/ipp_test.go +++ b/internal/winprint/ipp_test.go @@ -2,10 +2,12 @@ package winprint import ( "bytes" + "io" "net" "net/http" "os" "path/filepath" + "strings" "testing" "time" @@ -86,3 +88,71 @@ func TestIPPPrintJobFlow(t *testing.T) { t.Fatalf("expected format error, got %v", goipp.Status(r.Code)) } } + +func TestGETStatusRoutesDoNotBreakIPP(t *testing.T) { + addr := freeAddr(t) + queueHits, statusHits := 0, 0 + s := &IPPServer{ + Addr: addr, SpoolDir: t.TempDir(), + Process: func(Job) error { return nil }, + ExtraHealth: func() string { return "held-count: 2\nheadline: Sign in to finish timestamp\n" }, + ServeQueue: func(w http.ResponseWriter, r *http.Request) { + queueHits++ + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte("queue")) + }, + ServeStatus: func(w http.ResponseWriter, r *http.Request) { + statusHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"held":2}`)) + }, + } + go s.ListenAndServe() + time.Sleep(200 * time.Millisecond) + + // Plain health + res, err := http.Get("http://" + addr + "/") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 || !bytes.Contains(body, []byte("held-count: 2")) || !bytes.Contains(body, []byte("queue-url:")) { + t.Fatalf("health: %s %s", res.Status, body) + } + + res, err = http.Get("http://" + addr + "/queue") + if err != nil { + t.Fatal(err) + } + qb, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 || queueHits != 1 || !bytes.Contains(qb, []byte("queue")) { + t.Fatalf("queue: status=%d hits=%d body=%s", res.StatusCode, queueHits, qb) + } + + res, err = http.Get("http://" + addr + "/status") + if err != nil { + t.Fatal(err) + } + sb, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 || statusHits != 1 || !bytes.Contains(sb, []byte(`"held":2`)) { + t.Fatalf("status: %s hits=%d body=%s", res.Status, statusHits, sb) + } + + // POST IPP still works after GET traffic + r := call(t, addr, req(goipp.OpGetPrinterAttributes, addr), nil) + if goipp.Status(r.Code) != goipp.StatusOk { + t.Fatalf("gpa after GET: %v", goipp.Status(r.Code)) + } + var moreInfo string + for _, a := range r.Printer { + if a.Name == "printer-more-info" && len(a.Values) > 0 { + moreInfo = a.Values[0].V.String() + } + } + if !strings.Contains(moreInfo, "/queue") { + t.Fatalf("printer-more-info should point at /queue, got %q", moreInfo) + } +} diff --git a/platform/macos/menubar/README.txt b/platform/macos/menubar/README.txt new file mode 100644 index 0000000..04616b5 --- /dev/null +++ b/platform/macos/menubar/README.txt @@ -0,0 +1,105 @@ +Verae menu bar (PT-06) — macOS +============================== + +NSStatusItem that polls the IPP helper GET /status and opens GET /queue. +Does not touch IPP POST. Hold ids are never evidence. + +Package version +--------------- + Source of truth: internal/job.HelperVersion (currently 0.3.6). + build-menubar-app.sh reads job.go when VERSION is unset, stamps + CFBundleShortVersionString / CFBundleVersion, and writes + Contents/Resources/VERSION + packaging-metadata.txt. + Override: VERSION=x.y.z ./build-menubar-app.sh + +Run now +------- + verae-print menubar + verae-print menubar --foreground + ./run-menubar.sh + +Install at login (LaunchAgent) +------------------------------ + verae-print menubar --install + verae-print menubar --uninstall + +--install copies durable assets to: + ~/Library/Application Support/Verae/menubar/ +and writes: + ~/Library/LaunchAgents/com.verae.timestamping.menubar.plist +using launchctl bootstrap (fallback: load -w). Optional swiftc compile so +login does not need the Swift toolchain. + +Optional unsigned .app +---------------------- + ./build-menubar-app.sh + ./build-menubar-app.sh ~/Applications/Verae\ Menubar.app + VERSION=0.3.6 ./build-menubar-app.sh + open "Verae Menubar.app" + +LSUIElement=true (menu bar only, no Dock). Bundle id: + ai.verae.timestamping.menubar +Not codesigned or notarized by default — local/dev use. +Login Items: System Settings → General → Login Items → +. +Prefer LaunchAgent for headless reliability. + +The builder copies platform/macos/pkg/entitlements.plist into +Contents/Resources/entitlements.plist for optional signing (see below). +It does not apply codesign itself and never embeds secrets. + +Codesign + notarization (document only — needs Apple certs) +----------------------------------------------------------- +You need an Apple Developer Program membership and a Developer ID +Application certificate in the keychain. This project does not ship +secrets or signing.env values. When certs are available: + + 1) Build the unsigned app + ./build-menubar-app.sh ~/Applications/Verae\ Menubar.app + + 2) Codesign with hardened runtime + entitlements + APP="$HOME/Applications/Verae Menubar.app" + ENT="$APP/Contents/Resources/entitlements.plist" + # Or: platform/macos/pkg/entitlements.plist + codesign --force --deep --options runtime --timestamp \ + --entitlements "$ENT" \ + --sign "Developer ID Application: YOUR ORG (TEAMID)" \ + "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + spctl --assess --type execute -vv "$APP" # may fail until notarized + + 3) Zip for notarytool (preserve symlinks / bundle layout) + ditto -c -k --keepParent "$APP" /tmp/VeraeMenubar.zip + + 4) Submit to Apple notary service (App Store Connect API key or + keychain profile previously configured with `xcrun notarytool store-credentials`) + xcrun notarytool submit /tmp/VeraeMenubar.zip \ + --keychain-profile "verae-notary" \ + --wait + # Alternative: --apple-id … --team-id … --password @keychain:AC_PASSWORD + + 5) Staple the ticket so Gatekeeper works offline + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + spctl --assess --type execute -vv "$APP" + + 6) Optional: distribute as a signed .pkg instead of a bare .app — + see platform/macos/pkg/build-pkg.sh (Developer ID Installer + productbuild). + That path also requires signing.env / Apple certs and is separate from + this menu-bar stub. + +Gatekeeper notes +---------------- +* Unsigned .app: right-click → Open the first time, or remove quarantine: + xattr -dr com.apple.quarantine "Verae Menubar.app" +* Hardened runtime is required for notarization; entitlements must match + network client use (loopback GET /status only — no IPP POST from the tray). +* Do not paste API keys, app-specific passwords, or .p12 material into the + repo. Keep credentials in the keychain / CI secrets store. + +Files +----- + verae-menubar.swift NSStatusItem source + run-menubar.sh compile-or-interpret entry + com.verae.timestamping.menubar.plist LaunchAgent template + build-menubar-app.sh unsigned .app bundle builder + README.txt this file (incl. notarization) diff --git a/platform/macos/menubar/build-menubar-app.sh b/platform/macos/menubar/build-menubar-app.sh new file mode 100755 index 0000000..4559f10 --- /dev/null +++ b/platform/macos/menubar/build-menubar-app.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Build an unsigned .app that runs the Verae menu-bar status item (PT-06). +# Not notarized — for local / dev login items. Prefer LaunchAgent for headless. +# See README.txt for codesign + notarization steps when Apple certs are available. +# +# Usage: +# ./build-menubar-app.sh +# ./build-menubar-app.sh /path/to/Verae\ Menubar.app +# VERAE_IPP_ADDR=127.0.0.1:8631 ./build-menubar-app.sh +# VERSION=0.3.6 ./build-menubar-app.sh +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$DIR/../../.." && pwd)" +ADDR="${VERAE_IPP_ADDR:-127.0.0.1:8631}" +OUT="${1:-$DIR/Verae Menubar.app}" +SRC="$DIR/verae-menubar.swift" +RUN_SH="$DIR/run-menubar.sh" +# Package version = internal/job.HelperVersion (single source of truth). +if [[ -z "${VERSION:-}" ]]; then + if [[ -f "$ROOT/internal/job/job.go" ]]; then + VERSION="$(grep -E 'HelperVersion[[:space:]]*=' "$ROOT/internal/job/job.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" + fi + VERSION="${VERSION:-0.3.6}" +fi +BUNDLE_ID="ai.verae.timestamping.menubar" +ENTITLEMENTS_SRC="$DIR/../pkg/entitlements.plist" + +if [[ ! -f "$SRC" ]]; then + echo "missing $SRC" >&2 + exit 1 +fi + +# Fresh bundle so stale binaries / plists do not linger. +rm -rf "$OUT" +CONTENTS="$OUT/Contents" +MACOS="$CONTENTS/MacOS" +RES="$CONTENTS/Resources" +mkdir -p "$MACOS" "$RES" + +# Prefer a compiled binary inside the bundle (no runtime swift dependency). +BIN="$MACOS/verae-menubar" +if command -v swiftc >/dev/null 2>&1; then + echo "compiling $SRC → $BIN" + swiftc -O -o "$BIN" "$SRC" + chmod 755 "$BIN" +else + echo "swiftc not found — bundling interpreter launcher" >&2 + cp -f "$SRC" "$RES/verae-menubar.swift" + cat >"$BIN" <<'EOBIN' +#!/bin/bash +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$DIR/Resources/verae-menubar.swift" +ADDR="${VERAE_IPP_ADDR:-127.0.0.1:8631}" +if [[ $# -ge 1 && -n "${1:-}" ]]; then ADDR="$1"; fi +export VERAE_IPP_ADDR="$ADDR" +exec /usr/bin/swift "$SRC" "$ADDR" +EOBIN + chmod 755 "$BIN" +fi + +# Thin wrapper so open -a / double-click sets VERAE_IPP_ADDR and stays AppKit. +LAUNCHER="$MACOS/Verae Menubar" +cat >"$LAUNCHER" <"$RES/VERSION" +printf 'helper_version=%s\npackage_version=%s\nsource=internal/job.HelperVersion\n' \ + "$VERSION" "$VERSION" >"$RES/packaging-metadata.txt" + +# Classic Mac package type marker (APPL). +printf 'APPL????' >"$CONTENTS/PkgInfo" + +cat >"$CONTENTS/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Verae Menubar + CFBundleIdentifier + $BUNDLE_ID + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Verae Menubar + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + LSMinimumSystemVersion + 12.0 + LSUIElement + + NSHighResolutionCapable + + CFBundleDisplayName + Verae Menubar + NSHumanReadableCopyright + Copyright © Verae Inc. + + +PLIST + +# LSUIElement = menu-bar only (no Dock icon). +echo "Built unsigned app: $OUT" +echo " Bundle id: $BUNDLE_ID version: $VERSION" +echo " Run: open \"$OUT\"" +echo " Login item: System Settings → General → Login Items → + → select the .app" +echo " Or LaunchAgent: verae-print menubar --install" +echo " Codesign/notarize: see $DIR/README.txt (requires Apple Developer ID — not applied here)" +echo "Polls http://$ADDR/status · Open queue → /queue" +echo "Only a final receipt is evidence — hold ids are local queue handles, not proof." diff --git a/platform/macos/menubar/com.verae.timestamping.menubar.plist b/platform/macos/menubar/com.verae.timestamping.menubar.plist new file mode 100644 index 0000000..a368d0d --- /dev/null +++ b/platform/macos/menubar/com.verae.timestamping.menubar.plist @@ -0,0 +1,28 @@ + + + + + Label + com.verae.timestamping.menubar + ProgramArguments + + /bin/bash + MENUBAR_SCRIPT_PLACEHOLDER + + RunAtLoad + + KeepAlive + + ProcessType + Interactive + StandardOutPath + LOG_DIR_PLACEHOLDER/verae-menubar.launchd.out.log + StandardErrorPath + LOG_DIR_PLACEHOLDER/verae-menubar.launchd.err.log + EnvironmentVariables + + VERAE_IPP_ADDR + 127.0.0.1:8631 + + + diff --git a/platform/macos/menubar/run-menubar.sh b/platform/macos/menubar/run-menubar.sh new file mode 100755 index 0000000..80d7a75 --- /dev/null +++ b/platform/macos/menubar/run-menubar.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# LaunchAgent-friendly entry for the Verae menu-bar status item (PT-06). +# Prefers a compiled binary next to this script; falls back to `swift` interpret. +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +ADDR="${VERAE_IPP_ADDR:-127.0.0.1:8631}" +BIN="$DIR/verae-menubar" +SRC="$DIR/verae-menubar.swift" +LOG_DIR="${HOME}/Library/Logs" +mkdir -p "$LOG_DIR" +LOG="$LOG_DIR/verae-menubar.log" + +if [[ ! -x "$BIN" && -f "$SRC" ]]; then + if command -v swiftc >/dev/null 2>&1; then + # One-shot compile into the same folder (no Xcode project). + swiftc -O -o "$BIN" "$SRC" 2>>"$LOG" || true + fi +fi + +if [[ -x "$BIN" ]]; then + exec "$BIN" "$ADDR" >>"$LOG" 2>&1 +fi +if command -v swift >/dev/null 2>&1 && [[ -f "$SRC" ]]; then + exec swift "$SRC" "$ADDR" >>"$LOG" 2>&1 +fi +echo "verae-menubar: need swiftc/swift or a compiled $BIN" >&2 +exit 1 diff --git a/platform/macos/menubar/verae-menubar.swift b/platform/macos/menubar/verae-menubar.swift new file mode 100755 index 0000000..e216a1b --- /dev/null +++ b/platform/macos/menubar/verae-menubar.swift @@ -0,0 +1,203 @@ +#!/usr/bin/env swift +// PT-06 / PT-W3 menu-bar presence for Verae Timestamping (macOS). +// Polls loopback GET /status on the IPP helper; opens /queue from the menu. +// Does not touch IPP POST. Brand labels mirror internal/statuslang. +// +// Run: swift verae-menubar.swift +// or: ./run-menubar.sh +// or: verae-print menubar +// LaunchAgent: com.verae.timestamping.menubar.plist + +import Foundation +import AppKit + +let evidenceNote = "Only a final receipt is evidence — hold ids are local queue handles, not proof." +let pendingLabel = "Pending receipt" +let printerReady = "Printer ready" +let signInTitle = "Sign in to finish timestamp" +let helperDown = "Helper not running" + +struct TrayStatus { + var helperUp = false + var held = 0 + var keyOk = false + var headline = helperDown + var detail = "helper down · queue empty" + var queueURL = "" +} + +func envAddr() -> String { + if let a = ProcessInfo.processInfo.environment["VERAE_IPP_ADDR"], !a.isEmpty { + return a + } + return "127.0.0.1:8631" +} + +func headline(held: Int, keyOK: Bool, helperUp: Bool) -> String { + if held > 0 { return signInTitle } + if !keyOK { return signInTitle } + if helperUp { return printerReady } + return helperDown +} + +func detail(held: Int, keyOK: Bool, helperUp: Bool) -> String { + var parts: [String] = [] + parts.append(helperUp ? printerReady : "helper down") + parts.append(keyOK ? "key saved" : "no signing key") + if held > 0 { + parts.append("\(held) held · \(pendingLabel)") + } else { + parts.append("queue empty") + } + return parts.joined(separator: " · ") +} + +func fetchStatus(addr: String) -> TrayStatus { + var st = TrayStatus() + guard let url = URL(string: "http://\(addr)/status") else { return st } + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 0.5 + cfg.timeoutIntervalForResource = 0.6 + cfg.waitsForConnectivity = false + let session = URLSession(configuration: cfg) + let sem = DispatchSemaphore(value: 0) + var data: Data? + var code = 0 + let task = session.dataTask(with: url) { d, resp, _ in + data = d + if let http = resp as? HTTPURLResponse { code = http.statusCode } + sem.signal() + } + task.resume() + _ = sem.wait(timeout: .now() + 0.7) + task.cancel() + session.invalidateAndCancel() + guard code == 200, let data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + st.headline = headline(held: 0, keyOK: false, helperUp: false) + st.detail = detail(held: 0, keyOK: false, helperUp: false) + return st + } + st.helperUp = true + if let c = obj["count"] as? Int { + st.held = c + } else if let c = obj["count"] as? Double { + st.held = Int(c) + } else if let jobs = obj["jobs"] as? [Any] { + st.held = jobs.count + } + st.keyOk = (obj["keyOk"] as? Bool) ?? false + st.headline = (obj["headline"] as? String) ?? headline(held: st.held, keyOK: st.keyOk, helperUp: true) + st.detail = (obj["detail"] as? String) ?? detail(held: st.held, keyOK: st.keyOk, helperUp: true) + st.queueURL = (obj["queueUrl"] as? String) ?? "http://\(addr)/queue" + return st +} + +func openQueue(addr: String, st: TrayStatus) { + let target: String + if st.helperUp, !st.queueURL.isEmpty { + target = st.queueURL + } else { + target = "http://\(addr)/queue" + } + if let u = URL(string: target) { + NSWorkspace.shared.open(u) + } +} + +final class MenubarApp: NSObject, NSApplicationDelegate { + let addr: String + var statusItem: NSStatusItem! + var current = TrayStatus() + var timer: Timer? + + init(addr: String) { + self.addr = addr + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let btn = statusItem.button { + btn.title = "Verae" + btn.toolTip = "Verae Timestamping" + } + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true) { [weak self] _ in + self?.refresh() + } + if let t = timer { + RunLoop.main.add(t, forMode: .common) + } + } + + func refresh() { + current = fetchStatus(addr: addr) + let tip = "Verae · \(current.headline)\n\(current.detail)\n\(evidenceNote)" + statusItem.button?.toolTip = tip + // Compact title: held count or ready mark. + if current.held > 0 { + statusItem.button?.title = "Verae·\(current.held)" + } else if current.helperUp { + statusItem.button?.title = "Verae" + } else { + statusItem.button?.title = "Verae?" + } + rebuildMenu() + } + + func rebuildMenu() { + let menu = NSMenu() + let head = NSMenuItem(title: current.headline, action: nil, keyEquivalent: "") + head.isEnabled = false + menu.addItem(head) + let det = NSMenuItem(title: current.detail, action: nil, keyEquivalent: "") + det.isEnabled = false + menu.addItem(det) + menu.addItem(NSMenuItem.separator()) + + let openTitle = current.helperUp ? "Open print job queue" : "Open queue (helper down)" + let open = NSMenuItem(title: openTitle, action: #selector(doOpen), keyEquivalent: "o") + open.target = self + menu.addItem(open) + + let pendTitle: String + if current.held > 0 { + pendTitle = "Pending: \(current.held) held · \(pendingLabel)" + } else { + pendTitle = "Pending: queue empty" + } + let pend = NSMenuItem(title: pendTitle, action: #selector(doOpen), keyEquivalent: "") + pend.target = self + menu.addItem(pend) + + menu.addItem(NSMenuItem.separator()) + let note = NSMenuItem(title: "Hold id ≠ evidence", action: nil, keyEquivalent: "") + note.isEnabled = false + menu.addItem(note) + + let refreshItem = NSMenuItem(title: "Refresh now", action: #selector(doRefresh), keyEquivalent: "r") + refreshItem.target = self + menu.addItem(refreshItem) + + menu.addItem(NSMenuItem.separator()) + let quit = NSMenuItem(title: "Quit Verae menu bar", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + menu.addItem(quit) + + statusItem.menu = menu + } + + @objc func doOpen() { + openQueue(addr: addr, st: current) + } + + @objc func doRefresh() { + refresh() + } +} + +let addr = CommandLine.arguments.dropFirst().first ?? envAddr() +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = MenubarApp(addr: addr) +app.delegate = delegate +app.run() diff --git a/platform/registration-pdf/build.mjs b/platform/registration-pdf/build.mjs index bb57f97..ef8d34d 100644 --- a/platform/registration-pdf/build.mjs +++ b/platform/registration-pdf/build.mjs @@ -1,30 +1,41 @@ #!/usr/bin/env node /** - * Official Verae Timestamp Registration Document — same layout as - * mcp.verae.ai register.html / app.html jsPDF export. + * Official Verae Timestamp Certificate — CERT-01 layout (print helper). * - * Wording overrides (print helper): + * Brand: plum #531B59 / gold #C4A85A / ink #1D1534 (BrandingBook + receipt-final). + * Wording: + * - final-only evidence (jobId / pending hold is not proof) + * - digests only — file bytes not uploaded * - no mcp.verae.ai URLs - * - "only the digest was submitted" (no "via MCP") - * - footer: Verae | verae.com | explorer.verae.ai + * - footer: Verae LLC · verae.ai · explorer.verae.ai * * Usage: node build.mjs - * input.json: { file, size_bytes, sha256, scope, receipt_id, created_at, merkle, lookup_url? } + * input.json: { file, size_bytes, sha256, multihash?, blake3?, blake3_multihash?, + * scope, receipt_id, created_at, merkle, lookup_url? } + * blake3 / blake3_multihash lines render only when present (never invented). */ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); + +// CERT-01 / BrandingBook tokens +const plum = [83, 27, 89]; // #531B59 +const gold = [196, 168, 90]; // #C4A85A +const goldD = [110, 87, 23]; // #6E5717 +const ink = [29, 21, 52]; // #1D1534 +const muted = [94, 84, 112]; // #5E5470 +const ok = [14, 107, 58]; // #0E6B3A +const okBg = [227, 245, 234]; // #E3F5EA +const okBd = [185, 226, 200]; // #B9E2C8 +const linkColor = [59, 42, 140]; // #3B2A8C info async function loadJsPDF() { try { const { jsPDF } = await import("jspdf"); return jsPDF; } catch { - // npx / global fallbacks not used — deps installed beside this script throw new Error("jspdf not installed; run: npm install --prefix platform/registration-pdf"); } } @@ -58,6 +69,41 @@ function findLogo() { return null; } +function commaInt(n) { + const s = String(n); + if (n < 1000) return s; + return s.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +/** BrandingBook / statuslang.Label("final", block) chip text. */ +function finalBannerTitle(merkle) { + const s = String(merkle || "").trim(); + if (/^\d+$/.test(s)) { + const n = parseInt(s, 10); + if (n > 0) return `Final: Substrate block #${commaInt(n)}, finalized`; + } + return "Final: Substrate block #N, finalized"; +} + +/** Multihash hex ("f" + codeLen + 64-hex) when digest valid; else "". */ +function contentMultihashPrefixed(codeLen, digest, explicit) { + const ex = String(explicit || "").trim(); + if (ex) return ex; + let s = String(digest || "").trim().toLowerCase().replace(/^0x/, ""); + if (!/^[0-9a-f]{64}$/.test(s)) return ""; + return "f" + codeLen + s; +} + +/** sha2-256 multihash hex (f1220…) when sha256 is 64-char hex; else "". */ +function contentMultihash(sha, explicit) { + return contentMultihashPrefixed("1220", sha, explicit); +} + +/** blake3 multihash hex (f1e20…) when blake3 is 64-char hex; else "". */ +function contentBlake3Multihash(blake3, explicit) { + return contentMultihashPrefixed("1e20", blake3, explicit); +} + async function buildPdf(row) { const jsPDF = await loadJsPDF(); const QRCode = await loadQR(); @@ -78,11 +124,12 @@ async function buildPdf(row) { ? row.lookup_url : lookupURL(row.sha256, row.receipt_id); - doc.setDrawColor(42, 24, 56); - doc.setLineWidth(2); + // CERT-01 double frame: plum outer, gold inner + doc.setDrawColor(...plum); + doc.setLineWidth(1.5); doc.rect(28, 28, pageW - 56, pageH - 56); - doc.setLineWidth(0.6); - doc.setDrawColor(196, 168, 90); + doc.setLineWidth(0.5); + doc.setDrawColor(...gold); doc.rect(34, 34, pageW - 68, pageH - 68); if (logoData) { @@ -91,54 +138,107 @@ async function buildPdf(row) { } catch (_) {} } + // Gold-d eyebrow + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(...goldD); + doc.text("TIMESTAMP CERTIFICATE", pageW / 2, 98, { align: "center" }); + + // Title — CERT-01 serif / ink doc.setFont("times", "bold"); - doc.setFontSize(18); - doc.setTextColor(42, 24, 56); - doc.text("Verae Timestamp Registration Document", pageW / 2, 110, { align: "center" }); + doc.setFontSize(22); + doc.setTextColor(...ink); + doc.text("Verae Timestamp Certificate", pageW / 2, 122, { align: "center" }); doc.setFont("helvetica", "normal"); doc.setFontSize(10); - doc.setTextColor(90, 80, 100); + doc.setTextColor(...muted); doc.text( - "Official certificate of digital content registration on the VeraeTime global ledger", + "Proof that this exact document existed at the time below,", pageW / 2, - 128, + 140, + { align: "center" }, + ); + doc.text( + "recorded on the Verae Substrate blockchain (digests only)", + pageW / 2, + 152, { align: "center" }, ); - doc.setDrawColor(196, 168, 90); - doc.setLineWidth(1); - doc.line(margin, 142, pageW - margin, 142); + // Short centered gold rule + const ruleW = 120; + doc.setDrawColor(...gold); + doc.setLineWidth(2); + doc.line(pageW / 2 - ruleW / 2, 166, pageW / 2 + ruleW / 2, 166); + // FINAL-only status banner + const bannerY = 178; + const bannerH = 40; + doc.setFillColor(...okBg); + doc.setDrawColor(...okBd); + doc.setLineWidth(1.5); + doc.roundedRect(margin, bannerY, pageW - margin * 2, bannerH, 6, 6, "FD"); + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(...ok); + doc.text(finalBannerTitle(row.merkle), margin + 12, bannerY + 16); + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.text( + // statuslang.EvidenceNote + "Only a final receipt is evidence — hold ids are local queue handles, not proof.", + margin + 12, + bannerY + 30, + ); + + const mh = contentMultihash(row.sha256, row.multihash); + const b3 = String(row.blake3 || "").trim(); + const b3mh = contentBlake3Multihash(row.blake3, row.blake3_multihash); const fields = [ - ["Document name", row.file || row.document_name || "(digest only)"], - ["Content SHA-256", row.sha256 || ""], - ["Registration scope", String(row.scope || "global").toUpperCase()], - ["Original timestamp (UTC)", row.created_at || "—"], - ["Receipt ID (CID)", row.receipt_id || "—"], - ["Merkle / block hash", row.merkle || "See ledger lookup via receipt CID"], - ["Ledger location", "VeraeTime global ledger · api.veraetime.net"], - ["Blockchain explorer", "https://explorer.verae.ai/"], - ["Public lookup URL", lookup], + ["Document", row.file || row.document_name || "(digest only)"], ]; if (row.size_bytes != null && row.size_bytes !== "") { - fields.splice(1, 0, ["Document size", `${row.size_bytes} bytes`]); + fields.push(["Document size", `${row.size_bytes} bytes`]); } + // CERT-01: digest lines only when data is available — never invent. + if (mh) { + fields.push(["Content multihash", mh]); + } + if (row.sha256) { + fields.push(["SHA-256 (hex)", row.sha256]); + } + if (b3mh) { + fields.push(["BLAKE3 multihash", b3mh]); + } + if (b3) { + fields.push(["BLAKE3 (hex)", b3]); + } + fields.push( + ["Registration scope", String(row.scope || "global").toUpperCase()], + ["Original timestamp (UTC)", row.created_at || "—"], + ["VeraeTime receipt (CID)", row.receipt_id || "—"], + ["Merkle / block", row.merkle || "See ledger lookup via receipt CID"], + ["Network", "Verae Substrate blockchain (VeraeNet)"], // statuslang.ChainName (NetworkName) + ["Blockchain explorer", "https://explorer.verae.ai/time"], // statuslang.ExplorerURL + ["Public lookup URL", lookup], + ); - const linkColor = [74, 46, 140]; // Verae purple — visible as a link const isHttpURL = (s) => /^https?:\/\//i.test(String(s || "").trim()); - let y = 168; + let y = bannerY + bannerH + 18; doc.setFontSize(10); for (const [label, value] of fields) { doc.setFont("helvetica", "bold"); - doc.setTextColor(42, 24, 56); + doc.setFontSize(9); + doc.setTextColor(...muted); doc.text(label, margin, y); doc.setFont("helvetica", "normal"); + doc.setFontSize(10); const text = String(value ?? ""); const maxW = pageW - margin * 2 - 10; const lines = doc.splitTextToSize(text, maxW); - const valueY = y + 14; + const valueY = y + 13; if (isHttpURL(text)) { doc.setTextColor(...linkColor); let ly = valueY; @@ -147,10 +247,10 @@ async function buildPdf(row) { ly += 12; } } else { - doc.setTextColor(40, 40, 50); + doc.setTextColor(...ink); doc.text(lines, margin, valueY); } - y += 14 + lines.length * 12 + 10; + y += 13 + lines.length * 12 + 8; if (y > pageH - 220) break; } @@ -158,38 +258,39 @@ async function buildPdf(row) { errorCorrectionLevel: "M", margin: 1, width: 280, - color: { dark: "#2a1838", light: "#ffffff" }, + color: { dark: "#1D1534", light: "#ffffff" }, }); const qrSize = 110; const qrX = pageW - margin - qrSize; - const qrY = pageH - margin - qrSize - 36; + const qrY = pageH - margin - qrSize - 48; doc.addImage(qr, "PNG", qrX, qrY, qrSize, qrSize); - // Clickable hotspot over the QR (same destination as the public lookup URL) doc.link(qrX, qrY, qrSize, qrSize, { url: lookup }); + doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...linkColor); - doc.textWithLink("Scan to look up this registration", qrX + qrSize / 2, qrY + qrSize + 14, { + doc.textWithLink("Scan to verify", qrX + qrSize / 2, qrY + qrSize + 14, { align: "center", url: lookup, }); + doc.setFont("helvetica", "normal"); doc.setFontSize(8); - doc.setTextColor(90, 80, 100); + doc.setTextColor(...muted); doc.text( - "This document certifies that the SHA-256 digest above was registered with Verae. File bytes were not uploaded; only the digest was submitted.", + "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.", margin, - pageH - 52, + pageH - 60, { maxWidth: pageW - margin * 2 - qrSize - 16 }, ); doc.setFont("helvetica", "bold"); - doc.setTextColor(42, 24, 56); + doc.setTextColor(...plum); const footY = pageH - 36; - doc.text("Verae · ", margin, footY); - let fx = margin + doc.getTextWidth("Verae · "); + doc.text("Verae LLC · ", margin, footY); + let fx = margin + doc.getTextWidth("Verae LLC · "); doc.setTextColor(...linkColor); - doc.textWithLink("verae.com", fx, footY, { url: "https://verae.com" }); - fx += doc.getTextWidth("verae.com"); - doc.setTextColor(42, 24, 56); + doc.textWithLink("verae.ai", fx, footY, { url: "https://verae.ai" }); + fx += doc.getTextWidth("verae.ai"); + doc.setTextColor(...plum); doc.text(" · ", fx, footY); fx += doc.getTextWidth(" · "); doc.setTextColor(...linkColor); diff --git a/platform/smoke-packaging-metadata.sh b/platform/smoke-packaging-metadata.sh new file mode 100755 index 0000000..8a89221 --- /dev/null +++ b/platform/smoke-packaging-metadata.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# A4 smoke: tray/menubar packaging metadata matches internal/job.HelperVersion. +# No elevated MSI path required. IPP POST must stay untouched by tray scripts. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail=0 +pass() { printf 'PASS %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fail=1; } + +JOB_GO="$ROOT/internal/job/job.go" +if [[ ! -f "$JOB_GO" ]]; then + bad "missing $JOB_GO" + exit 1 +fi +HELPER="$(grep -E 'HelperVersion[[:space:]]*=' "$JOB_GO" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" +if [[ -z "$HELPER" || ! "$HELPER" =~ ^[0-9]+\.[0-9]+ ]]; then + bad "could not parse HelperVersion from job.go (got '$HELPER')" + exit 1 +fi +pass "HelperVersion=$HELPER" + +check_file() { + local f="$1"; shift + if [[ ! -f "$f" ]]; then + bad "missing $f" + return + fi + local body + body="$(cat "$f")" + local w + for w in "$@"; do + if ! grep -Fq -- "$w" <<<"$body"; then + bad "$f missing expected: $w" + return + fi + done + pass "$f" +} + +# macOS menubar builder + README +check_file "$ROOT/platform/macos/menubar/build-menubar-app.sh" \ + "HelperVersion" \ + "CFBundleShortVersionString" \ + "packaging-metadata.txt" \ + "VERSION" \ + "LSUIElement" \ + "ai.verae.timestamping.menubar" + +check_file "$ROOT/platform/macos/menubar/README.txt" \ + "HelperVersion" \ + "Package version" \ + "packaging-metadata.txt" \ + "notarytool" + +# Windows tray install scripts +check_file "$ROOT/platform/windows/scripts/common.ps1" \ + "Get-VeraePackageVersion" \ + "HelperVersion" \ + "$HELPER" + +check_file "$ROOT/platform/windows/scripts/install-tray-startup.ps1" \ + "PackageVersion" \ + "HelperVersion" \ + "no elevated MSI" \ + "GET /status" + +check_file "$ROOT/platform/windows/scripts/build-tray-startmenu.ps1" \ + "PackageVersion" \ + "HelperVersion" \ + "no elevated MSI" \ + "WiX" \ + "GET /status" + +check_file "$ROOT/platform/windows/scripts/verae-tray.ps1" \ + "PackageVersion" \ + "HelperVersion" \ + "GET /status" + +check_file "$ROOT/platform/windows/README.txt" \ + "HelperVersion" \ + "no elevated MSI" \ + "Package version" + +check_file "$ROOT/platform/windows/README-WINDOWS.txt" \ + "HelperVersion" \ + "Package version" \ + "no elevated MSI" + +# Fallback defaults in scripts must equal current HelperVersion (or resolve from job.go). +# build-menubar-app.sh fallback: +if grep -Eq "VERSION=\"\\\$\{VERSION:-$HELPER\}\"|VERSION=\"\\\$\{VERSION:-.*\}\"" \ + "$ROOT/platform/macos/menubar/build-menubar-app.sh"; then + # Prefer: script reads job.go; fallback string should match HELPER when present. + if grep -Fq "VERSION=\"\${VERSION:-$HELPER}\"" "$ROOT/platform/macos/menubar/build-menubar-app.sh" \ + || grep -Fq "VERSION=\"\${VERSION:-$HELPER}\"" "$ROOT/platform/macos/menubar/build-menubar-app.sh"; then + pass "menubar fallback VERSION matches HelperVersion" + elif grep -Fq "VERSION=\"\${VERSION:-$HELPER}\"" "$ROOT/platform/macos/menubar/build-menubar-app.sh"; then + pass "menubar fallback VERSION matches HelperVersion" + else + # Accept job.go resolution path + any fallback that equals HELPER + if grep -q 'HelperVersion' "$ROOT/platform/macos/menubar/build-menubar-app.sh" \ + && grep -Fq "$HELPER" "$ROOT/platform/macos/menubar/build-menubar-app.sh"; then + pass "menubar script references HelperVersion and $HELPER" + else + bad "menubar build script fallback does not mention $HELPER" + fi + fi +else + if grep -q 'HelperVersion' "$ROOT/platform/macos/menubar/build-menubar-app.sh" \ + && grep -Fq "$HELPER" "$ROOT/platform/macos/menubar/build-menubar-app.sh"; then + pass "menubar script references HelperVersion and $HELPER" + else + bad "menubar build script missing HelperVersion/$HELPER" + fi +fi + +# Tray scripts must not implement IPP POST (pure GET presence). +for f in \ + "$ROOT/platform/windows/scripts/verae-tray.ps1" \ + "$ROOT/platform/windows/scripts/install-tray-startup.ps1" \ + "$ROOT/platform/windows/scripts/build-tray-startmenu.ps1" \ + "$ROOT/platform/macos/menubar/verae-menubar.swift" \ + "$ROOT/platform/macos/menubar/build-menubar-app.sh" +do + if grep -Ei 'ipp.?post|Print-Job|send-document|application/ipp' "$f" 2>/dev/null \ + | grep -Eiv 'POST stays|untouched|not touch|Does not touch|never|pure|no IPP POST' >/dev/null; then + # Allow documentation that says they do NOT touch IPP POST + if grep -Ei 'does not touch IPP POST|IPP POST is unchanged|IPP POST untouched|never.*IPP POST|no IPP POST' "$f" >/dev/null; then + pass "IPP pure (docs only): $(basename "$f")" + else + bad "possible IPP POST coupling in $f" + fi + else + pass "IPP pure: $(basename "$f")" + fi +done + +# Optional: go build smoke when go is available (does not require Windows). +if command -v go >/dev/null 2>&1; then + if go build -o /dev/null ./cmd/verae-print 2>/tmp/a4-go-build.err; then + pass "go build ./cmd/verae-print" + else + bad "go build ./cmd/verae-print" + cat /tmp/a4-go-build.err >&2 || true + fi + if go test ./cmd/verae-print -count=1 -run 'Menubar|Packaging|TrayStartmenu|FindRepo' 2>/tmp/a4-go-test.err; then + pass "go test menubar/packaging subset" + else + bad "go test menubar/packaging subset" + cat /tmp/a4-go-test.err >&2 || true + fi +else + pass "go not on PATH — skipped build/test smoke" +fi + +if [[ "$fail" -ne 0 ]]; then + echo "A4 packaging metadata smoke FAILED" + exit 1 +fi +echo "A4 packaging metadata smoke PASSED (HelperVersion=$HELPER, no elevated MSI, IPP pure)" diff --git a/platform/windows/README-WINDOWS.txt b/platform/windows/README-WINDOWS.txt index 60f39cb..3a151ca 100644 --- a/platform/windows/README-WINDOWS.txt +++ b/platform/windows/README-WINDOWS.txt @@ -65,8 +65,59 @@ Settings (%USERPROFILE%\.verae\config.json) Restart the helper after changing settings (sign out/in, or Start > Verae > Start print helper). -Start menu > Verae ------------------- +Tray icon (PT-W3) +----------------- +Long-running NotifyIcon that polls the helper GET /status and shows brand +labels (Printer ready / Sign in to finish timestamp / held count). Context +menu: Open print job queue, Pending count, Exit. Balloon tip click opens +http://127.0.0.1:8631/queue. Hold ids are never evidence. + +Package version = internal/job.HelperVersion (currently 0.3.6). Stamped by +verae-tray.ps1, install-tray-startup.ps1, and build-tray-startmenu.ps1 +(via common.ps1 Get-VeraePackageVersion). Override with VERSION=x.y.z. +User-level only — no elevated MSI required for tray presence. + + verae-print menubar + powershell -NoProfile -ExecutionPolicy Bypass -STA -File scripts\verae-tray.ps1 + +Install at login (user Startup folder shortcut — not a full MSI product): + + verae-print menubar --install + verae-print menubar --uninstall + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-tray-startup.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-tray-startup.ps1 -Uninstall + +Creates: + %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Verae Tray.lnk +pointing at verae-tray.ps1 (hidden PowerShell, STA). Tray starts at next +login; run "verae-print menubar" to start it now. IPP POST is unchanged +(print path stays pure). + +Start Menu folder + Startup (WiX/NSIS-free stub) +----------------------------------------------- +Practical packaging without WiX Toolset or a commercial / elevated MSI. +Package version follows HelperVersion (see above). Creates user-level +shortcuts under Start > Verae and optionally the Startup .lnk: + + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-tray-startmenu.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-tray-startmenu.ps1 -Uninstall + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-tray-startmenu.ps1 -NoStartup + +Creates (user profile, no elevation): + %APPDATA%\Microsoft\Windows\Start Menu\Programs\Verae\ + Verae Tray.lnk + Verae - Open print job queue.lnk (or .url) + Verae - Setup signing key.lnk (when verae-print.exe is found) + Verae - Start print helper.lnk (when verae-print.exe is found) + Verae - Customer portal.url + + Startup\Verae Tray.lnk (unless -NoStartup) + +WiX (candle/light) is optional and not required for tray presence. The full +NSIS product remains installer-windows.nsi; a future MSI can supersede this +stub. Hold ids are never evidence. + +Start menu > Verae (full NSIS install) +-------------------------------------- Setup signing key, Start print helper, Logs folder, Collect diagnostics (right-click > Run as administrator for complete results; the zip is saved on your Desktop), Uninstall. diff --git a/platform/windows/README.txt b/platform/windows/README.txt index b87f203..902d666 100644 --- a/platform/windows/README.txt +++ b/platform/windows/README.txt @@ -31,6 +31,31 @@ Print destination A Windows virtual printer (Print → Verae Timestamping) is not in this build yet. Workflow today: print to PDF, then `verae-print stamp`. +Tray at login (PT-W3, not full MSI) +----------------------------------- +NotifyIcon polls the helper GET /status; Open queue → /queue. +Hold ids are never evidence. +Package version = internal/job.HelperVersion (currently 0.3.6), stamped by +install-tray-startup.ps1 / build-tray-startmenu.ps1 / verae-tray.ps1. +User-level Startup + Start Menu only — no elevated MSI required. IPP POST +untouched (GET /status and /queue only). Override: set VERSION=x.y.z. + + verae-print menubar + verae-print menubar --install # Startup folder: Verae Tray.lnk + verae-print menubar --uninstall + +Or: + + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-tray-startup.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-tray-startup.ps1 -Uninstall + +Start Menu folder (WiX-free stub; also installs Startup unless -NoStartup): + + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-tray-startmenu.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-tray-startmenu.ps1 -Uninstall + +WiX Toolset is not required. Full NSIS product: installer-windows.nsi. + Links ----- Downloads: https://printit.verae.ai/ diff --git a/platform/windows/scripts/build-tray-startmenu.ps1 b/platform/windows/scripts/build-tray-startmenu.ps1 new file mode 100644 index 0000000..7e09f11 --- /dev/null +++ b/platform/windows/scripts/build-tray-startmenu.ps1 @@ -0,0 +1,241 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Practical Start Menu + Startup tray packaging (PT-W3) without WiX/NSIS/MSI. + Stamps package version from internal/job.HelperVersion (A4). + +.DESCRIPTION + Creates user-level shortcuts under: + %APPDATA%\Microsoft\Windows\Start Menu\Programs\Verae\ + and (unless -NoStartup) a login Startup shortcut via install-tray-startup.ps1: + %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Verae Tray.lnk + + This is intentionally a lightweight presence stub — not a commercial MSI. + IPP POST is untouched; shortcuts only launch the tray (GET /status) or open + the loopback queue page. + + Full product installers (NSIS installer-windows.nsi, future WiX MSI) remain + separate. If WiX Toolset / candle+light are not installed, this script is the + supported path for Start Menu + Startup tray presence. + +.PARAMETER Uninstall + Remove Verae Start Menu folder shortcuts and optional Startup shortcut. + +.PARAMETER Addr + Helper loopback host:port (default 127.0.0.1:8631 or $env:VERAE_IPP_ADDR). + +.PARAMETER TrayScript + Path to verae-tray.ps1. Default: same folder as this script. + +.PARAMETER VeraePrint + Optional path to verae-print.exe. When found, adds Setup signing key and + Start print helper shortcuts. Auto-detected next to scripts or on PATH. + +.PARAMETER NoStartup + Skip Startup-folder shortcut (Start Menu only). + +.PARAMETER StartMenuFolder + Start Menu Programs subfolder name (default Verae). + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File build-tray-startmenu.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File build-tray-startmenu.ps1 -Uninstall + verae-print menubar --install # Startup only; this script adds Start Menu folder +#> +param( + [switch]$Uninstall, + [string]$Addr = $(if ($env:VERAE_IPP_ADDR) { $env:VERAE_IPP_ADDR } else { "127.0.0.1:8631" }), + [string]$TrayScript = "", + [string]$VeraePrint = "", + [switch]$NoStartup, + [string]$StartMenuFolder = "Verae" +) + +$ErrorActionPreference = "Stop" + +# Package version = internal/job.HelperVersion (A4 packaging metadata). +$common = Join-Path $PSScriptRoot "common.ps1" +if (Test-Path -LiteralPath $common) { . $common } +if (Get-Command Get-VeraePackageVersion -ErrorAction SilentlyContinue) { + $PackageVersion = Get-VeraePackageVersion +} elseif ($env:VERSION -and $env:VERSION.Trim()) { + $PackageVersion = $env:VERSION.Trim() +} else { + $PackageVersion = "0.3.6" +} + +function Get-ProgramsDir { + $base = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" + if (-not (Test-Path -LiteralPath $base)) { + New-Item -ItemType Directory -Force -Path $base | Out-Null + } + return $base +} + +function Get-PsExe { + $psExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe" + if (-not (Test-Path $psExe)) { $psExe = "powershell.exe" } + return $psExe +} + +function New-Lnk { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target, + [string]$Arguments = "", + [string]$WorkDir = "", + [string]$Description = "", + [int]$WindowStyle = 1 + ) + $dir = Split-Path -Parent $Path + if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + $wsh = New-Object -ComObject WScript.Shell + try { + $sc = $wsh.CreateShortcut($Path) + $sc.TargetPath = $Target + if ($Arguments) { $sc.Arguments = $Arguments } + if ($WorkDir) { $sc.WorkingDirectory = $WorkDir } + if ($Description) { $sc.Description = $Description } + $sc.WindowStyle = $WindowStyle + $sc.Save() + } finally { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) + } + Write-Host " + $Path" +} + +function Find-VeraePrint([string]$Hint) { + if ($Hint -and (Test-Path -LiteralPath $Hint)) { + return (Resolve-Path -LiteralPath $Hint).Path + } + $candidates = @( + (Join-Path $PSScriptRoot "..\..\..\bin\verae-print.exe"), + (Join-Path $PSScriptRoot "..\bin\verae-print.exe"), + (Join-Path $PSScriptRoot "verae-print.exe"), + (Join-Path (Split-Path -Parent $PSScriptRoot) "bin\verae-print.exe") + ) + foreach ($c in $candidates) { + if (Test-Path -LiteralPath $c) { return (Resolve-Path -LiteralPath $c).Path } + } + $cmd = Get-Command verae-print.exe -ErrorAction SilentlyContinue + if ($cmd -and $cmd.Source) { return $cmd.Source } + $cmd2 = Get-Command verae-print -ErrorAction SilentlyContinue + if ($cmd2 -and $cmd2.Source) { return $cmd2.Source } + return "" +} + +$Programs = Get-ProgramsDir +$VeraeMenu = Join-Path $Programs $StartMenuFolder +$StartupInstaller = Join-Path $PSScriptRoot "install-tray-startup.ps1" + +if ($Uninstall) { + Write-Host "Removing Start Menu folder: $VeraeMenu" + if (Test-Path -LiteralPath $VeraeMenu) { + Get-ChildItem -LiteralPath $VeraeMenu -Filter "*.lnk" -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force; Write-Host " - $($_.FullName)" } + # Remove folder only when empty (do not wipe other products' shortcuts). + $left = @(Get-ChildItem -LiteralPath $VeraeMenu -Force -ErrorAction SilentlyContinue) + if ($left.Count -eq 0) { + Remove-Item -LiteralPath $VeraeMenu -Force -ErrorAction SilentlyContinue + Write-Host " removed empty folder" + } else { + Write-Host " left non-empty folder ($($left.Count) items)" + } + } else { + Write-Host " (folder not present)" + } + if (-not $NoStartup -and (Test-Path -LiteralPath $StartupInstaller)) { + & $StartupInstaller -Uninstall + } else { + $startupLnk = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup\Verae Tray.lnk" + if (Test-Path -LiteralPath $startupLnk) { + Remove-Item -LiteralPath $startupLnk -Force + Write-Host "Removed Startup shortcut: $startupLnk" + } + } + Write-Host "Done. IPP path unchanged. Hold ids are never evidence." + exit 0 +} + +if (-not $TrayScript) { + $TrayScript = Join-Path $PSScriptRoot "verae-tray.ps1" +} +if (-not (Test-Path -LiteralPath $TrayScript)) { + Write-Error "verae-tray.ps1 not found: $TrayScript" +} +$TrayScript = (Resolve-Path -LiteralPath $TrayScript).Path +$TrayDir = Split-Path -Parent $TrayScript +$psExe = Get-PsExe +$printExe = Find-VeraePrint $VeraePrint + +Write-Host "Start Menu folder: $VeraeMenu" +Write-Host " Addr: $Addr" +Write-Host " Tray: $TrayScript" +Write-Host " Version: $PackageVersion (internal/job.HelperVersion)" +if ($printExe) { Write-Host " verae-print: $printExe" } else { Write-Host " verae-print: (not found — tray/queue shortcuts only)" } + +# --- Start Menu\Programs\Verae --- +$trayArgs = "-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File `"$TrayScript`" -Addr $Addr" +New-Lnk -Path (Join-Path $VeraeMenu "Verae Tray.lnk") ` + -Target $psExe -Arguments $trayArgs -WorkDir $TrayDir -WindowStyle 7 ` + -Description "Verae Timestamping tray v$PackageVersion — polls GET /status; Open queue → /queue. Hold id ≠ evidence." + +$queueUrl = "http://$Addr/queue" +# Prefer verae-print tray-status --open when available (no default browser .url needed). +if ($printExe) { + New-Lnk -Path (Join-Path $VeraeMenu "Verae - Open print job queue.lnk") ` + -Target $printExe -Arguments "tray-status --open --addr $Addr" ` + -WorkDir (Split-Path -Parent $printExe) ` + -Description "Open loopback print job queue (GET /queue). Hold id ≠ evidence." + New-Lnk -Path (Join-Path $VeraeMenu "Verae - Setup signing key.lnk") ` + -Target $printExe -Arguments "setup" ` + -WorkDir (Split-Path -Parent $printExe) ` + -Description "Create or paste an xVerae- signing key (portal / login)." + New-Lnk -Path (Join-Path $VeraeMenu "Verae - Start print helper.lnk") ` + -Target $printExe -Arguments "serve" ` + -WorkDir (Split-Path -Parent $printExe) ` + -Description "Start IPP helper + loopback GET /status and /queue." +} else { + # URL shortcut when binary is absent (dev tree / scripts-only layout). + $urlPath = Join-Path $VeraeMenu "Verae - Open print job queue.url" + New-Item -ItemType Directory -Force -Path $VeraeMenu | Out-Null + @( + "[InternetShortcut]" + "URL=$queueUrl" + ) | Set-Content -LiteralPath $urlPath -Encoding ASCII + Write-Host " + $urlPath" +} + +$portalUrl = "https://customer.verae.ai/portal/" +$portalPath = Join-Path $VeraeMenu "Verae - Customer portal.url" +@( + "[InternetShortcut]" + "URL=$portalUrl" +) | Set-Content -LiteralPath $portalPath -Encoding ASCII +Write-Host " + $portalPath" + +# --- Startup (login) --- +if (-not $NoStartup) { + if (Test-Path -LiteralPath $StartupInstaller) { + Write-Host "Installing Startup shortcut via install-tray-startup.ps1 ..." + & $StartupInstaller -Addr $Addr -TrayScript $TrayScript + } else { + $startupDir = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup" + $startupLnk = Join-Path $startupDir "Verae Tray.lnk" + New-Lnk -Path $startupLnk -Target $psExe -Arguments $trayArgs -WorkDir $TrayDir -WindowStyle 7 ` + -Description "Verae Timestamping tray at login — polls GET /status. Hold id ≠ evidence." + } +} else { + Write-Host "Skipped Startup (-NoStartup)." +} + +Write-Host "" +Write-Host "Packaging notes (not a full MSI product):" +Write-Host " • Package version: $PackageVersion (stamped from internal/job.HelperVersion)." +Write-Host " • This script uses WScript.Shell .lnk files only (no WiX, no NSIS, no elevated MSI)." +Write-Host " • WiX Toolset (candle/light) is optional and not required for tray presence." +Write-Host " • Commercial MSI / elevated Start Menu under ProgramData can ship later." +Write-Host " • Existing NSIS product: platform/windows/installer-windows.nsi (separate path)." +Write-Host " • IPP POST untouched — tray only uses GET /status and /queue." +Write-Host "Run tray now: verae-print menubar (or double-click Start > Verae > Verae Tray)" +Write-Host "Only a final receipt is evidence — hold ids are local queue handles, not proof." diff --git a/platform/windows/scripts/common.ps1 b/platform/windows/scripts/common.ps1 index 09e6ec7..267ef61 100644 --- a/platform/windows/scripts/common.ps1 +++ b/platform/windows/scripts/common.ps1 @@ -51,3 +51,24 @@ function Write-VeraeIni([string]$Path, [string]$Section, [System.Collections.IDi } Set-Content -Path $Path -Value $lines -Encoding ASCII } + +# Package version for tray / Start Menu stubs (A4). Prefer $env:VERSION, else +# internal/job.HelperVersion from the repo tree, else the last-known fallback. +function Get-VeraePackageVersion { + if ($env:VERSION -and $env:VERSION.Trim()) { return $env:VERSION.Trim() } + $candidates = @( + (Join-Path $PSScriptRoot "..\..\..\internal\job\job.go"), + (Join-Path $PSScriptRoot "..\..\internal\job\job.go"), + (Join-Path (Split-Path -Parent $PSScriptRoot) "..\..\internal\job\job.go") + ) + foreach ($c in $candidates) { + if (-not (Test-Path -LiteralPath $c)) { continue } + $line = Select-String -Path $c -Pattern 'HelperVersion\s*=\s*"([^"]+)"' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($line -and $line.Matches.Count -gt 0) { + return $line.Matches[0].Groups[1].Value + } + } + # Fallback must match internal/job.HelperVersion when job.go is unavailable. + return "0.3.6" +} diff --git a/platform/windows/scripts/install-tray-startup.ps1 b/platform/windows/scripts/install-tray-startup.ps1 new file mode 100644 index 0000000..416b91e --- /dev/null +++ b/platform/windows/scripts/install-tray-startup.ps1 @@ -0,0 +1,91 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install or remove a Startup-folder shortcut for the Verae tray (PT-W3). + Package version stamped from internal/job.HelperVersion (no elevated MSI). + +.DESCRIPTION + Creates %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Verae Tray.lnk + that runs verae-tray.ps1 (NotifyIcon + GET /status). Not a full MSI product — + user-level login presence only. IPP POST is untouched. + +.PARAMETER Uninstall + Remove the Startup shortcut instead of creating it. + +.PARAMETER Addr + Helper loopback host:port passed to the tray (default 127.0.0.1:8631). + +.PARAMETER TrayScript + Path to verae-tray.ps1. Default: same folder as this script. + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File install-tray-startup.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File install-tray-startup.ps1 -Uninstall + verae-print menubar --install +#> +param( + [switch]$Uninstall, + [string]$Addr = $(if ($env:VERAE_IPP_ADDR) { $env:VERAE_IPP_ADDR } else { "127.0.0.1:8631" }), + [string]$TrayScript = "" +) + +$ErrorActionPreference = "Stop" + +# Package version = internal/job.HelperVersion (A4). No elevated MSI required. +$common = Join-Path $PSScriptRoot "common.ps1" +if (Test-Path -LiteralPath $common) { . $common } +if (Get-Command Get-VeraePackageVersion -ErrorAction SilentlyContinue) { + $PackageVersion = Get-VeraePackageVersion +} elseif ($env:VERSION -and $env:VERSION.Trim()) { + $PackageVersion = $env:VERSION.Trim() +} else { + $PackageVersion = "0.3.6" +} + +$ShortcutName = "Verae Tray.lnk" +$StartupDir = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup" +$ShortcutPath = Join-Path $StartupDir $ShortcutName + +if ($Uninstall) { + if (Test-Path -LiteralPath $ShortcutPath) { + Remove-Item -LiteralPath $ShortcutPath -Force + Write-Host "Removed Startup shortcut: $ShortcutPath" + } else { + Write-Host "No Startup shortcut at: $ShortcutPath" + } + exit 0 +} + +if (-not $TrayScript) { + $TrayScript = Join-Path $PSScriptRoot "verae-tray.ps1" +} +if (-not (Test-Path -LiteralPath $TrayScript)) { + Write-Error "verae-tray.ps1 not found: $TrayScript" +} + +$TrayScript = (Resolve-Path -LiteralPath $TrayScript).Path +New-Item -ItemType Directory -Force -Path $StartupDir | Out-Null + +$psExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe" +if (-not (Test-Path $psExe)) { $psExe = "powershell.exe" } + +$args = "-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File `"$TrayScript`" -Addr $Addr" + +$wsh = New-Object -ComObject WScript.Shell +$sc = $wsh.CreateShortcut($ShortcutPath) +$sc.TargetPath = $psExe +$sc.Arguments = $args +$sc.WorkingDirectory = Split-Path -Parent $TrayScript +$sc.WindowStyle = 7 # minimized +$sc.Description = "Verae Timestamping tray v$PackageVersion — polls GET /status; Open queue → /queue. Hold id ≠ evidence." +$sc.Save() +[System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null + +Write-Host "Installed Startup shortcut: $ShortcutPath" +Write-Host " Target: $psExe" +Write-Host " Script: $TrayScript" +Write-Host " Addr: $Addr" +Write-Host " Version: $PackageVersion (HelperVersion / package metadata)" +Write-Host "Tray starts at next login. Run now: verae-print menubar" +Write-Host "User-level Startup only — no elevated MSI. IPP POST untouched." +Write-Host "Only a final receipt is evidence — hold ids are local queue handles, not proof." diff --git a/platform/windows/scripts/verae-tray.ps1 b/platform/windows/scripts/verae-tray.ps1 new file mode 100644 index 0000000..685aae0 --- /dev/null +++ b/platform/windows/scripts/verae-tray.ps1 @@ -0,0 +1,232 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + PT-W3 tray presence for Verae Timestamping (Windows NotifyIcon). + +.DESCRIPTION + Long-running System.Windows.Forms.NotifyIcon that polls the IPP helper's + GET /status (JSON) on loopback and shows brand statuslang labels in the + tooltip and context menu. Does not touch IPP POST. + + Menu: + Open print job queue -> http://127.0.0.1:8631/queue (or tray-status --open) + Pending: N held -> same (hold id ≠ evidence) + Refresh now + Exit + +.PARAMETER Addr + Helper loopback host:port (default 127.0.0.1:8631 or $env:VERAE_IPP_ADDR). + +.PARAMETER IntervalSec + Poll interval for /status (default 8). + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -STA -File verae-tray.ps1 + verae-print menubar +#> +param( + [string]$Addr = $(if ($env:VERAE_IPP_ADDR) { $env:VERAE_IPP_ADDR } else { "127.0.0.1:8631" }), + [int]$IntervalSec = 8 +) + +$ErrorActionPreference = "Stop" + +# Package version = internal/job.HelperVersion (A4 metadata; display only). +$common = Join-Path $PSScriptRoot "common.ps1" +if (Test-Path -LiteralPath $common) { . $common } +if (Get-Command Get-VeraePackageVersion -ErrorAction SilentlyContinue) { + $PackageVersion = Get-VeraePackageVersion +} elseif ($env:VERSION -and $env:VERSION.Trim()) { + $PackageVersion = $env:VERSION.Trim() +} else { + $PackageVersion = "0.3.6" +} + +# WinForms NotifyIcon needs an STA thread. +if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne "STA") { + $args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-STA", "-File", $MyInvocation.MyCommand.Path) + if ($PSBoundParameters.ContainsKey("Addr")) { $args += @("-Addr", $Addr) } + if ($PSBoundParameters.ContainsKey("IntervalSec")) { $args += @("-IntervalSec", "$IntervalSec") } + Start-Process -FilePath "powershell.exe" -ArgumentList $args -WindowStyle Hidden + exit 0 +} + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +# Brand labels (mirror internal/statuslang — keep in sync). +$EvidenceNote = "Only a final receipt is evidence — hold ids are local queue handles, not proof." +$PendingLabel = "Pending receipt" +$PrinterReady = "Printer ready" +$SignInTitle = "Sign in to finish timestamp" +$HelperDown = "Helper not running" + +function Get-TrayHeadline([int]$held, [bool]$keyOK, [bool]$helperUp) { + if ($held -gt 0) { return $SignInTitle } + if (-not $keyOK) { return $SignInTitle } + if ($helperUp) { return $PrinterReady } + return $HelperDown +} + +function Get-TrayDetail([int]$held, [bool]$keyOK, [bool]$helperUp) { + $parts = @() + if ($helperUp) { $parts += $PrinterReady } else { $parts += "helper down" } + if ($keyOK) { $parts += "key saved" } else { $parts += "no signing key" } + if ($held -gt 0) { $parts += "$held held · $PendingLabel" } else { $parts += "queue empty" } + $parts += "v$PackageVersion" + return ($parts -join " · ") +} + +function Get-StatusUri([string]$a) { return "http://$a/status" } +function Get-QueueUri([string]$a) { return "http://$a/queue" } + +function Get-HelperStatus([string]$a) { + $out = [ordered]@{ + helperUp = $false + held = 0 + keyOk = $false + headline = $HelperDown + detail = "helper down · queue empty" + queueUrl = "" + note = $EvidenceNote + } + try { + $req = [System.Net.HttpWebRequest]::Create((Get-StatusUri $a)) + $req.Method = "GET" + $req.Timeout = 800 + $req.ReadWriteTimeout = 800 + $req.Proxy = $null + $resp = $req.GetResponse() + try { + $sr = New-Object System.IO.StreamReader($resp.GetResponseStream()) + $raw = $sr.ReadToEnd() + $sr.Close() + $j = $raw | ConvertFrom-Json + $held = 0 + if ($null -ne $j.count) { $held = [int]$j.count } + elseif ($j.jobs) { $held = @($j.jobs).Count } + $keyOK = [bool]$j.keyOk + $out.helperUp = $true + $out.held = $held + $out.keyOk = $keyOK + if ($j.headline) { $out.headline = [string]$j.headline } + else { $out.headline = Get-TrayHeadline $held $keyOK $true } + if ($j.detail) { $out.detail = [string]$j.detail } + else { $out.detail = Get-TrayDetail $held $keyOK $true } + if ($j.queueUrl) { $out.queueUrl = [string]$j.queueUrl } + else { $out.queueUrl = Get-QueueUri $a } + if ($j.note) { $out.note = [string]$j.note } + } finally { + $resp.Close() + } + } catch { + $out.headline = Get-TrayHeadline 0 $false $false + $out.detail = Get-TrayDetail 0 $false $false + $out.queueUrl = "" + } + return [pscustomobject]$out +} + +function Open-Queue([string]$a, $st) { + if ($st -and $st.helperUp -and $st.queueUrl) { + Start-Process $st.queueUrl + return + } + # Helper down: try verae-print tray-status --open, else open queue URL anyway. + $vp = Get-Command verae-print -ErrorAction SilentlyContinue + if ($vp) { + Start-Process -FilePath $vp.Source -ArgumentList @("tray-status", "--open", "--addr", $a) -WindowStyle Hidden + return + } + Start-Process (Get-QueueUri $a) +} + +$script:lastHeld = -1 +$script:lastHeadline = "" +$icon = [System.Drawing.SystemIcons]::Application +try { + # Prefer information icon for a calmer tray presence. + $icon = [System.Drawing.SystemIcons]::Information +} catch {} + +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = $icon +$notify.Visible = $true +$notify.Text = "Verae Timestamping" +$notify.BalloonTipTitle = "Verae Timestamping" +$notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info + +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$miOpen = $menu.Items.Add("Open print job queue") +$miPending = $menu.Items.Add("Pending: …") +$miPending.Enabled = $false +[void]$menu.Items.Add("-") +$miRefresh = $menu.Items.Add("Refresh now") +$miNote = $menu.Items.Add("Hold id ≠ evidence") +$miNote.Enabled = $false +[void]$menu.Items.Add("-") +$miExit = $menu.Items.Add("Exit") +$notify.ContextMenuStrip = $menu + +function Update-TrayUI { + $st = Get-HelperStatus $Addr + $tip = "Verae · $($st.headline)" + if ($tip.Length -gt 63) { $tip = $tip.Substring(0, 60) + "…" } + $notify.Text = $tip + $miPending.Text = if ($st.held -gt 0) { + "Pending: $($st.held) held · $PendingLabel" + } else { + "Pending: queue empty" + } + $miOpen.Text = if ($st.helperUp) { "Open print job queue" } else { "Open queue (helper down)" } + + # Balloon when held count rises (sign-in needed) — click opens /queue. + if ($script:lastHeld -ge 0 -and $st.held -gt $script:lastHeld) { + $notify.BalloonTipTitle = $SignInTitle + $notify.BalloonTipText = "$($st.held) job(s) · $PendingLabel — sign in to stamp · $($st.queueUrl)" + if ($notify.BalloonTipText.Length -gt 250) { + $notify.BalloonTipText = $notify.BalloonTipText.Substring(0, 247) + "…" + } + $notify.ShowBalloonTip(8000) + } elseif ($script:lastHeadline -ne "" -and $st.headline -ne $script:lastHeadline -and $st.held -eq 0 -and $st.helperUp -and $st.keyOk) { + $notify.BalloonTipTitle = $PrinterReady + $notify.BalloonTipText = $st.detail + $notify.ShowBalloonTip(4000) + } + $script:lastHeld = $st.held + $script:lastHeadline = $st.headline + $script:currentStatus = $st +} + +$openHandler = { + Open-Queue $Addr $script:currentStatus +} +$miOpen.add_Click($openHandler) +$miPending.add_Click($openHandler) +$notify.add_DoubleClick($openHandler) +$notify.add_BalloonTipClicked($openHandler) + +$miRefresh.add_Click({ Update-TrayUI }) +$miExit.add_Click({ + $timer.Stop() + $notify.Visible = $false + $notify.Dispose() + [System.Windows.Forms.Application]::Exit() +}) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = [Math]::Max(2000, $IntervalSec * 1000) +$timer.add_Tick({ Update-TrayUI }) + +# Initial paint + optional first balloon if jobs already held. +Update-TrayUI +if ($script:currentStatus -and $script:currentStatus.held -gt 0) { + $notify.BalloonTipTitle = $SignInTitle + $notify.BalloonTipText = "$($script:currentStatus.held) job(s) · $PendingLabel — $($script:currentStatus.detail)" + $notify.ShowBalloonTip(6000) +} +$timer.Start() + +# Keep process alive without a visible form. +[System.Windows.Forms.Application]::Run() diff --git a/site/printit.verae.ai/brand/logo/printit-logo.png b/site/printit.verae.ai/brand/logo/printit-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4c011e8bd1fe92faea2df0c4c69b8369cc3326bc GIT binary patch literal 27484 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 literal 0 HcmV?d00001 diff --git a/site/printit.verae.ai/brand/logo/verae-mark-mono-black.svg b/site/printit.verae.ai/brand/logo/verae-mark-mono-black.svg new file mode 100644 index 0000000..930092c --- /dev/null +++ b/site/printit.verae.ai/brand/logo/verae-mark-mono-black.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/site/printit.verae.ai/brand/logo/verae-mark-white.svg b/site/printit.verae.ai/brand/logo/verae-mark-white.svg new file mode 100644 index 0000000..ce6d1b9 --- /dev/null +++ b/site/printit.verae.ai/brand/logo/verae-mark-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/site/printit.verae.ai/brand/logo/verae-mark.svg b/site/printit.verae.ai/brand/logo/verae-mark.svg new file mode 100644 index 0000000..b80fe1d --- /dev/null +++ b/site/printit.verae.ai/brand/logo/verae-mark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/site/printit.verae.ai/brand/tokens.css b/site/printit.verae.ai/brand/tokens.css new file mode 100644 index 0000000..b6a8c5a --- /dev/null +++ b/site/printit.verae.ai/brand/tokens.css @@ -0,0 +1,146 @@ +/* GENERATED from BrandingBook.md (brand-tokens v1.0.0). Do not edit — run ./regenerate.sh */ +:root{ + --plum:#531B59; + --plum-d:#3E1343; + --plum-mid:#6C5B81; + --plum-200:#D9CCE3; + --plum-100:#EBD9EE; + --plum-50:#F7EFF8; + --violet:#6B3FD6; + --print-purple:#4A2E8C; + --gold:#C4A85A; + --gold-d:#6E5717; + --gold-50:#FFF8E6; + --cube:#5CE1E6; + --cube-navy:#1E3D59; + --cube-ink:#174759; + --ink:#1D1534; + --ink-2:#2C2140; + --ink-soft:#3B3250; + --muted:#5E5470; + --subtle:#6B6478; + --faint:#8A8298; + --disabled:#9A90A9; + --line-strong:#CFC7DA; + --line:#E3DEF0; + --line-soft:#F0ECF4; + --surface-2:#F4F1F8; + --surface-3:#FAF8FC; + --bg:#FBFAFD; + --card:#FFFFFF; + --on-primary:#FFFFFF; + --canvas:#E9E6EE; + --night:#0F0A14; + --night-card:#1A1224; + --night-line:#3A2A4A; + --night-ink:#F4EEF8; + --night-muted:#A898B8; + --ok:#0E6B3A; + --ok-bg:#E3F5EA; + --ok-bd:#B9E2C8; + --inc:#8A3D00; + --inc-bg:#FFE8D3; + --inc-bd:#F3C9A3; + --pend:#475467; + --pend-bg:#EEF0F3; + --pend-bd:#D5DAE1; + --warn:#7A4F00; + --warn-bg:#FFF3D6; + --warn-bd:#EDD49A; + --err:#B42318; + --err-bg:#FDE8E6; + --err-bd:#F4BCB6; + --info:#3B2A8C; + --info-bg:#ECE8FB; + --info-bd:#CFC6F3; + --off:#334155; + --off-bg:#E5EAF1; + --off-bd:#C9D2DE; + --focus:#6B3FD6; + --callout:#E0218A; + --font-sans:'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-serif:'Fraunces', Georgia, serif; + --font-mono:'JetBrains Mono', 'SF Mono', 'Roboto Mono', ui-monospace, monospace; + --font-apple:-apple-system, 'SF Pro', 'Inter', sans-serif; + --font-android:'Roboto', 'Inter', sans-serif; + --font-windows:'Segoe UI Variable', 'Segoe UI', 'Selawik', 'Inter', sans-serif; + --font-gnome:'Adwaita Sans', 'Cantarell', 'Inter', sans-serif; + --font-icons:'MSR'; + --fs-display:34px; --lh-display:41px; --fw-display:700; --tr-display:-0.02em; + --fs-title-1:28px; --lh-title-1:34px; --fw-title-1:700; --tr-title-1:-0.01em; + --fs-title-2:22px; --lh-title-2:28px; --fw-title-2:700; --tr-title-2:0em; + --fs-title-3:18px; --lh-title-3:24px; --fw-title-3:600; --tr-title-3:0em; + --fs-body-lg:17px; --lh-body-lg:24px; --fw-body-lg:400; --tr-body-lg:0em; + --fs-body:15px; --lh-body:22px; --fw-body:400; --tr-body:0em; + --fs-body-sm:13px; --lh-body-sm:18px; --fw-body-sm:400; --tr-body-sm:0em; + --fs-caption:12px; --lh-caption:16px; --fw-caption:500; --tr-caption:0em; + --fs-overline:11px; --lh-overline:14px; --fw-overline:600; --tr-overline:0.06em; + --fs-mono:13px; --lh-mono:18px; --fw-mono:500; --tr-mono:-0.01em; + --fs-certificate-title:28px; --lh-certificate-title:34px; --fw-certificate-title:600; --tr-certificate-title:-0.01em; + --space-0:0px; + --space-1:2px; + --space-2:4px; + --space-3:8px; + --space-4:12px; + --space-5:16px; + --space-6:20px; + --space-7:24px; + --space-8:32px; + --space-9:40px; + --space-10:48px; + --space-11:64px; + --radius-xs:4px; + --radius-sm:6px; + --radius-md:10px; + --radius-lg:12px; + --radius-xl:14px; + --radius-2xl:18px; + --radius-pill:999px; + --elev-e0:none; + --elev-e1:0 1px 2px rgba(29,21,52,.06), 0 1px 1px rgba(29,21,52,.04); + --elev-e2:0 4px 14px rgba(29,21,52,.10), 0 1px 3px rgba(29,21,52,.06); + --elev-e3:0 12px 40px rgba(30,10,40,.25), 0 2px 8px rgba(30,10,40,.12); + --dur-fast:120ms; + --dur-base:200ms; + --dur-slow:320ms; + --ease-standard:cubic-bezier(0.2, 0, 0, 1); + --ease-emphasized:cubic-bezier(0.16, 1, 0.3, 1); + --ease-exit:cubic-bezier(0.3, 0, 1, 1); + --touch-ios-min:44px; + --touch-android-min:48px; + --touch-windows-min:40px; + --touch-web-min:44px; + --touch-glove-min:72px; +} +[data-theme="dark"]{--plum:#D7B4DE;--plum-d:#EBD9EE;--plum-50:#2A1A30;--plum-100:#3A2342;--on-primary:#1D1534;--ink:#F4EEF8;--muted:#B3A6C2;--line:#3A2A4A;--line-soft:#2A2036;--surface-2:#241A30;--bg:#0F0A14;--card:#1A1224;--ok:#7FD9A5;--ok-bg:#12301F;--inc:#F5B27A;--inc-bg:#33200F;--pend:#BAC4D2;--pend-bg:#1F2531;--warn:#EBC874;--warn-bg:#2D2410;--err:#FF968C;--err-bg:#361615;--info:#BDB1FF;--info-bg:#221D40;--off:#B0BCCD;--off-bg:#1C2330;--focus:#BDB1FF;} +@media (prefers-color-scheme: dark){:root:not([data-theme="light"]):not([data-theme="mock"]){--plum:#D7B4DE;--plum-d:#EBD9EE;--plum-50:#2A1A30;--plum-100:#3A2342;--on-primary:#1D1534;--ink:#F4EEF8;--muted:#B3A6C2;--line:#3A2A4A;--line-soft:#2A2036;--surface-2:#241A30;--bg:#0F0A14;--card:#1A1224;--ok:#7FD9A5;--ok-bg:#12301F;--inc:#F5B27A;--inc-bg:#33200F;--pend:#BAC4D2;--pend-bg:#1F2531;--warn:#EBC874;--warn-bg:#2D2410;--err:#FF968C;--err-bg:#361615;--info:#BDB1FF;--info-bg:#221D40;--off:#B0BCCD;--off-bg:#1C2330;--focus:#BDB1FF;}} +@media (prefers-reduced-motion: reduce){:root{--dur-fast:0ms;--dur-base:0ms;--dur-slow:0ms}} + +/* Semantic aliases for Verae 2026 app shells (portal, admin, staff, ITAD, Sign, Verify) */ +:root { + --panel: var(--card); + --accent: var(--plum); + --accent-ink: var(--on-primary); + --accent-soft: var(--plum-50); + --soft: var(--plum-50); + --danger: var(--err); + --radius: var(--radius-lg); + --shadow: 0 1px 2px rgba(29,21,52,.06), 0 8px 24px rgba(29,21,52,.08); + --font-ui: var(--font-sans); +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0F0A14; + --card: #1A1224; + --panel: #1A1224; + --ink: #F4EEF8; + --muted: #B3A6C2; + --line: #3A2A4A; + --accent: #D7B4DE; + --accent-ink: #1D1534; + --accent-soft: #2A1A30; + --soft: #2A1A30; + --danger: #FF968C; + --ok: #7FD9A5; + } +} diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html index 8c4e14f..987cd3e 100644 --- a/site/printit.verae.ai/index.html +++ b/site/printit.verae.ai/index.html @@ -3,13 +3,24 @@ + + Print it — Verae Timestamping + + +
+
Print job queue
+

PT-04 (macOS) · PT-W4 (Windows) · Held jobs wait for a valid xVerae- signing key. Only a final receipt is evidence — a hold id or jobId is never proof.

+ +
+

On the machine running the helper

+
verae-print pending
+verae-print pending --json
+verae-print pending --html > queue-local.html
+verae-print tray-status
+verae-print tray-status --open
+verae-print menubar
+verae-print menubar --install
+

Text table, JSON, HTML, tray snapshot, or a real tray/menu-bar presence (Windows NotifyIcon / macOS NSStatusItem) that polls held count + brand labels. With the helper running, open the live loopback queue:

+
http://127.0.0.1:8631/queue
+http://127.0.0.1:8631/status
+

GET only on the IPP loopback port — print clients still POST IPP; hold ids are never evidence.

+
+ +
+

Status labels (BrandingBook / statuslang)

+ + + + + + + + + +
StateLabelOS notify title
Held / no keyPending receiptSign in to finish timestamp
Offline queueQueued offlineQueued offline
In flightStamped, awaiting blockchain—
FailedFailed – retryingFailed – retrying
FinalFinal receipt readyFinal receipt ready
+
+ Not proof: rows in this queue (and local hold ids) only mean the PDF is saved for retry. + Evidence is the final receipt and certificate PDF after blockchain finality. +
+
+ +
+
+ + diff --git a/site/printit.verae.ai/status-strings.json b/site/printit.verae.ai/status-strings.json new file mode 100644 index 0000000..99f12af --- /dev/null +++ b/site/printit.verae.ai/status-strings.json @@ -0,0 +1,66 @@ +{ + "version": "1.0.0", + "source": "verae-2026-design/ui-designs/BrandingBook.md brand-tokens status + proof", + "go_package": "apps/PrintToVeraeTimeStamp/internal/statuslang", + "rules": { + "jobId_never_proof": true, + "evidence_states_only": ["existing", "final"], + "block_thousands_separator_ui": true, + "forbidden_terms": ["jobId", "job id", "job ID"] + }, + "labels": { + "queued": "Queued offline", + "hashing": "Hashing on device…", + "existing": "Already registered", + "pending": "Pending receipt", + "provisional": "Stamped, awaiting blockchain", + "included": "Included in block #{n} (not final)", + "included_unknown": "Included in block #N (not final)", + "final": "Final: Substrate block #{n}, finalized", + "final_unknown": "Final: Substrate block #N, finalized", + "failed": "Failed – retrying", + "action": "Failed – action needed" + }, + "desc": { + "queued": "Not yet sent. Will submit when online.", + "hashing": "SHA-256 computed locally; file never leaves the device.", + "existing": "Hash was known: existing receipt returned instantly.", + "pending": "Submitted. Waiting for VeraeTime + Substrate.", + "provisional": "VeraeTime receipt issued; not yet in a Substrate block.", + "included": "In a Substrate block; waiting for GRANDPA finality.", + "final": "GRANDPA-finalized. Receipt + proof available. This is evidence.", + "failed": "Submission failed; retrying with backoff (never resubmits blindly).", + "action": "Needs the user (e.g. key expired, 401)." + }, + "evidence": { + "queued": false, + "hashing": false, + "existing": true, + "pending": false, + "provisional": false, + "included": false, + "final": true, + "failed": false, + "action": false + }, + "notify": { + "final_receipt_ready": "Final receipt ready", + "sign_in_to_finish": "Sign in to finish timestamp", + "printer_ready": "Printer ready", + "evidence_note": "Only a final receipt is evidence — hold ids are local queue handles, not proof." + }, + "examples": { + "pending": "Pending receipt", + "included": "Included in block #1,284,515 (not final)", + "final": "Final: Substrate block #1,284,512, finalized", + "existing": "Already registered" + }, + "proof": { + "verify_base": "https://verify.verae.ai", + "explorer": "https://explorer.verae.ai/time", + "chain_name": "Verae Substrate blockchain", + "network_name": "VeraeNet", + "multihash_prefix": "f1220", + "default_digest": "sha2-256" + } +} diff --git a/site/printit.verae.ai/verae-customer-auth.js b/site/printit.verae.ai/verae-customer-auth.js new file mode 100644 index 0000000..808c823 --- /dev/null +++ b/site/printit.verae.ai/verae-customer-auth.js @@ -0,0 +1,197 @@ +/** + * Verae customer token helpers for browser apps. + * + * After Verae account setup / login: + * - verae.portal.session — short-lived portal Bearer (UI API) + * - verae.customer.token — long-lived xVerae- key (stamp / API; x-api-key) + * + * Web-only apps MUST keep the customer token in localStorage (this module). + * Native apps use Keychain / secure storage instead. + */ +(function (global) { + 'use strict'; + + var SESSION_KEY = 'verae.portal.session'; + var SESSION_LEGACY = 'zappier-portal-token'; + var CUSTOMER_KEY = 'verae.customer.token'; + var DEFAULT_PORTAL = 'https://zap.verae.ai'; + + function isCustomerToken(t) { + if (!t || typeof t !== 'string') return false; + var s = t.trim(); + return s.indexOf('xVerae-') === 0 || s.indexOf('key-') === 0; + } + + function getSession() { + try { + return localStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_LEGACY) || ''; + } catch (e) { + return ''; + } + } + + function setSession(token) { + try { + if (token) { + localStorage.setItem(SESSION_KEY, token); + localStorage.setItem(SESSION_LEGACY, token); + } else { + localStorage.removeItem(SESSION_KEY); + localStorage.removeItem(SESSION_LEGACY); + } + } catch (e) { /* private mode */ } + } + + function getCustomerToken() { + try { + return localStorage.getItem(CUSTOMER_KEY) || ''; + } catch (e) { + return ''; + } + } + + function setCustomerToken(token) { + try { + if (token && isCustomerToken(token)) { + localStorage.setItem(CUSTOMER_KEY, token.trim()); + } else if (!token) { + localStorage.removeItem(CUSTOMER_KEY); + } + } catch (e) { /* private mode */ } + } + + function clearAll() { + setSession(''); + setCustomerToken(''); + } + + function portalBase(base) { + // Empty string = same origin (portal hosted on zap.verae.ai itself). + if (base === '') return ''; + var b = (base == null ? DEFAULT_PORTAL : String(base)).replace(/\/$/, ''); + return b; + } + + /** + * After portal login/signup response: save session, then ensure an xVerae- + * customer token is in localStorage (use signup apiKey when present, else mint). + */ + async function afterAuth(data, opts) { + opts = opts || {}; + var base = portalBase(opts.baseUrl); + if (!data || !data.token) throw new Error('No portal session token in auth response'); + setSession(data.token); + + var fromSignup = data.apiKey || (data.customer && data.customer.apiKey) || ''; + if (isCustomerToken(fromSignup)) { + setCustomerToken(fromSignup); + return { session: data.token, customerToken: fromSignup.trim(), minted: false }; + } + + var existing = getCustomerToken(); + if (isCustomerToken(existing) && !opts.forceMint) { + return { session: data.token, customerToken: existing, minted: false }; + } + + var name = + opts.tokenName || + 'web · ' + (typeof navigator !== 'undefined' ? navigator.platform || 'browser' : 'browser') + + ' · ' + + new Date().toISOString().slice(0, 10); + + var res = await fetch(base + '/portal/api/tokens', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer ' + data.token, + }, + body: JSON.stringify({ name: name }), + }); + var body = {}; + try { + body = await res.json(); + } catch (e) { + body = {}; + } + if (!res.ok || !isCustomerToken(body.token)) { + throw new Error((body && body.error) || 'Could not mint Verae customer token'); + } + setCustomerToken(body.token); + return { session: data.token, customerToken: body.token.trim(), minted: true }; + } + + async function login(email, password, opts) { + opts = opts || {}; + var base = portalBase(opts.baseUrl); + var body = { email: email, password: password }; + if (opts.totpCode) body.totpCode = opts.totpCode; + var res = await fetch(base + '/portal/api/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + var data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) { + var err = new Error(data.error || 'login failed'); + err.code = data.error; + err.status = res.status; + throw err; + } + return afterAuth(data, opts); + } + + async function signup(name, email, password, opts) { + opts = opts || {}; + var base = portalBase(opts.baseUrl); + var res = await fetch(base + '/portal/api/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: name, + email: email, + password: password, + acceptTos: opts.acceptTos !== false, + }), + }); + var data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) { + var err = new Error(data.error || 'signup failed'); + err.code = data.error; + err.status = res.status; + err.detail = data.detail; + throw err; + } + return afterAuth(data, opts); + } + + /** Headers for metered API calls (timestamps, etc.). */ + function apiHeaders(extra) { + var h = Object.assign({ 'content-type': 'application/json' }, extra || {}); + var ct = getCustomerToken(); + if (isCustomerToken(ct)) h['x-api-key'] = ct.trim(); + var sess = getSession(); + if (sess) h.authorization = 'Bearer ' + sess; + return h; + } + + global.VeraeCustomerAuth = { + SESSION_KEY: SESSION_KEY, + CUSTOMER_KEY: CUSTOMER_KEY, + DEFAULT_PORTAL: DEFAULT_PORTAL, + isCustomerToken: isCustomerToken, + getSession: getSession, + setSession: setSession, + getCustomerToken: getCustomerToken, + setCustomerToken: setCustomerToken, + clearAll: clearAll, + afterAuth: afterAuth, + login: login, + signup: signup, + apiHeaders: apiHeaders, + portalBase: portalBase, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/site/printit.verae.ai/verae-ui-kit.css b/site/printit.verae.ai/verae-ui-kit.css new file mode 100644 index 0000000..68ffdbf --- /dev/null +++ b/site/printit.verae.ai/verae-ui-kit.css @@ -0,0 +1,380 @@ +/* Verae 2026 UI kit — design tokens + BASE_CSS + semantic aliases + app-shell (ZP-09 mobile) */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0'); +/* GENERATED from BrandingBook.md (brand-tokens v1.0.0). Do not edit — run ./regenerate.sh */ +:root{ + --plum:#531B59; + --plum-d:#3E1343; + --plum-mid:#6C5B81; + --plum-200:#D9CCE3; + --plum-100:#EBD9EE; + --plum-50:#F7EFF8; + --violet:#6B3FD6; + --print-purple:#4A2E8C; + --gold:#C4A85A; + --gold-d:#6E5717; + --gold-50:#FFF8E6; + --cube:#5CE1E6; + --cube-navy:#1E3D59; + --cube-ink:#174759; + --ink:#1D1534; + --ink-2:#2C2140; + --ink-soft:#3B3250; + --muted:#5E5470; + --subtle:#6B6478; + --faint:#8A8298; + --disabled:#9A90A9; + --line-strong:#CFC7DA; + --line:#E3DEF0; + --line-soft:#F0ECF4; + --surface-2:#F4F1F8; + --surface-3:#FAF8FC; + --bg:#FBFAFD; + --card:#FFFFFF; + --on-primary:#FFFFFF; + --canvas:#E9E6EE; + --night:#0F0A14; + --night-card:#1A1224; + --night-line:#3A2A4A; + --night-ink:#F4EEF8; + --night-muted:#A898B8; + --ok:#0E6B3A; + --ok-bg:#E3F5EA; + --ok-bd:#B9E2C8; + --inc:#8A3D00; + --inc-bg:#FFE8D3; + --inc-bd:#F3C9A3; + --pend:#475467; + --pend-bg:#EEF0F3; + --pend-bd:#D5DAE1; + --warn:#7A4F00; + --warn-bg:#FFF3D6; + --warn-bd:#EDD49A; + --err:#B42318; + --err-bg:#FDE8E6; + --err-bd:#F4BCB6; + --info:#3B2A8C; + --info-bg:#ECE8FB; + --info-bd:#CFC6F3; + --off:#334155; + --off-bg:#E5EAF1; + --off-bd:#C9D2DE; + --focus:#6B3FD6; + --callout:#E0218A; + --font-sans:'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-serif:'Fraunces', Georgia, serif; + --font-mono:'JetBrains Mono', 'SF Mono', 'Roboto Mono', ui-monospace, monospace; + --font-apple:-apple-system, 'SF Pro', 'Inter', sans-serif; + --font-android:'Roboto', 'Inter', sans-serif; + --font-windows:'Segoe UI Variable', 'Segoe UI', 'Selawik', 'Inter', sans-serif; + --font-gnome:'Adwaita Sans', 'Cantarell', 'Inter', sans-serif; + --font-icons:'MSR'; + --fs-display:34px; --lh-display:41px; --fw-display:700; --tr-display:-0.02em; + --fs-title-1:28px; --lh-title-1:34px; --fw-title-1:700; --tr-title-1:-0.01em; + --fs-title-2:22px; --lh-title-2:28px; --fw-title-2:700; --tr-title-2:0em; + --fs-title-3:18px; --lh-title-3:24px; --fw-title-3:600; --tr-title-3:0em; + --fs-body-lg:17px; --lh-body-lg:24px; --fw-body-lg:400; --tr-body-lg:0em; + --fs-body:15px; --lh-body:22px; --fw-body:400; --tr-body:0em; + --fs-body-sm:13px; --lh-body-sm:18px; --fw-body-sm:400; --tr-body-sm:0em; + --fs-caption:12px; --lh-caption:16px; --fw-caption:500; --tr-caption:0em; + --fs-overline:11px; --lh-overline:14px; --fw-overline:600; --tr-overline:0.06em; + --fs-mono:13px; --lh-mono:18px; --fw-mono:500; --tr-mono:-0.01em; + --fs-certificate-title:28px; --lh-certificate-title:34px; --fw-certificate-title:600; --tr-certificate-title:-0.01em; + --space-0:0px; + --space-1:2px; + --space-2:4px; + --space-3:8px; + --space-4:12px; + --space-5:16px; + --space-6:20px; + --space-7:24px; + --space-8:32px; + --space-9:40px; + --space-10:48px; + --space-11:64px; + --radius-xs:4px; + --radius-sm:6px; + --radius-md:10px; + --radius-lg:12px; + --radius-xl:14px; + --radius-2xl:18px; + --radius-pill:999px; + --elev-e0:none; + --elev-e1:0 1px 2px rgba(29,21,52,.06), 0 1px 1px rgba(29,21,52,.04); + --elev-e2:0 4px 14px rgba(29,21,52,.10), 0 1px 3px rgba(29,21,52,.06); + --elev-e3:0 12px 40px rgba(30,10,40,.25), 0 2px 8px rgba(30,10,40,.12); + --dur-fast:120ms; + --dur-base:200ms; + --dur-slow:320ms; + --ease-standard:cubic-bezier(0.2, 0, 0, 1); + --ease-emphasized:cubic-bezier(0.16, 1, 0.3, 1); + --ease-exit:cubic-bezier(0.3, 0, 1, 1); + --touch-ios-min:44px; + --touch-android-min:48px; + --touch-windows-min:40px; + --touch-web-min:44px; + --touch-glove-min:72px; +} +[data-theme="dark"]{--plum:#D7B4DE;--plum-d:#EBD9EE;--plum-50:#2A1A30;--plum-100:#3A2342;--on-primary:#1D1534;--ink:#F4EEF8;--muted:#B3A6C2;--line:#3A2A4A;--line-soft:#2A2036;--surface-2:#241A30;--bg:#0F0A14;--card:#1A1224;--ok:#7FD9A5;--ok-bg:#12301F;--inc:#F5B27A;--inc-bg:#33200F;--pend:#BAC4D2;--pend-bg:#1F2531;--warn:#EBC874;--warn-bg:#2D2410;--err:#FF968C;--err-bg:#361615;--info:#BDB1FF;--info-bg:#221D40;--off:#B0BCCD;--off-bg:#1C2330;--focus:#BDB1FF;} +@media (prefers-color-scheme: dark){:root:not([data-theme="light"]):not([data-theme="mock"]){--plum:#D7B4DE;--plum-d:#EBD9EE;--plum-50:#2A1A30;--plum-100:#3A2342;--on-primary:#1D1534;--ink:#F4EEF8;--muted:#B3A6C2;--line:#3A2A4A;--line-soft:#2A2036;--surface-2:#241A30;--bg:#0F0A14;--card:#1A1224;--ok:#7FD9A5;--ok-bg:#12301F;--inc:#F5B27A;--inc-bg:#33200F;--pend:#BAC4D2;--pend-bg:#1F2531;--warn:#EBC874;--warn-bg:#2D2410;--err:#FF968C;--err-bg:#361615;--info:#BDB1FF;--info-bg:#221D40;--off:#B0BCCD;--off-bg:#1C2330;--focus:#BDB1FF;}} +@media (prefers-reduced-motion: reduce){:root{--dur-fast:0ms;--dur-base:0ms;--dur-slow:0ms}} + +/* Semantic aliases for Verae 2026 app shells (portal, admin, staff, ITAD, Sign, Verify) */ +:root { + --panel: var(--card); + --accent: var(--plum); + --accent-ink: var(--on-primary); + --accent-soft: var(--plum-50); + --soft: var(--plum-50); + --danger: var(--err); + --radius: var(--radius-lg); + --shadow: 0 1px 2px rgba(29,21,52,.06), 0 8px 24px rgba(29,21,52,.08); + --font-ui: var(--font-sans); +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0F0A14; + --card: #1A1224; + --panel: #1A1224; + --ink: #F4EEF8; + --muted: #B3A6C2; + --line: #3A2A4A; + --accent: #D7B4DE; + --accent-ink: #1D1534; + --accent-soft: #2A1A30; + --soft: #2A1A30; + --danger: #FF968C; + --ok: #7FD9A5; + } +} + + + +*{box-sizing:border-box;margin:0;padding:0} +html,body{background:var(--bg)} +body{font-family:var(--font-sans);color:var(--ink);-webkit-font-smoothing:antialiased} +.ms{font-family:'MSR';font-weight:normal;font-style:normal;font-size:20px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;direction:ltr;font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24;vertical-align:middle;user-select:none} +.ms.f{font-variation-settings:'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 24} +.mono{font-family:var(--font-mono);font-size:.92em;letter-spacing:-.01em} +.muted{color:var(--muted)} .small{font-size:12px} .xs{font-size:11px} .b{font-weight:600} .bb{font-weight:700} +.row{display:flex;align-items:center;gap:8px} .col{display:flex;flex-direction:column;gap:8px} +.grow{flex:1;min-width:0} .wrap{flex-wrap:wrap} .sp{justify-content:space-between} .ell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.canvas{position:relative;display:inline-flex;align-items:center;justify-content:center;background:linear-gradient(160deg,var(--line-soft),#E2DDE9)} +.ribbon{position:absolute;top:10px;left:14px;font:600 11px var(--font-sans);color:var(--muted);letter-spacing:.04em;text-transform:uppercase;display:flex;gap:8px;align-items:center} +.ribbon .ex{background:var(--warn-bg);color:var(--warn);border:1px solid #E9CF8F;border-radius:4px;padding:2px 6px} +/* callouts (annotated renders only) */ +.co{position:absolute;display:none;z-index:999;width:22px;height:22px;border-radius:50%;background:var(--callout);color:#fff;font:700 12px/22px var(--font-sans);text-align:center;box-shadow:0 0 0 2px #fff,0 2px 6px rgba(0,0,0,.35);transform:translate(-6px,-9px);pointer-events:none} +body.annot .co{display:block} +/* badges */ +.badge{display:inline-flex;align-items:center;gap:5px;border-radius:var(--radius-pill);padding:3px 10px 3px 7px;font-size:12px;font-weight:600;line-height:16px;border:1px solid transparent;white-space:nowrap} +.badge .ms{font-size:15px} +.badge.sm{font-size:11px;padding:2px 8px 2px 5px}.badge.sm .ms{font-size:13px} +.b-ok{background:var(--ok-bg);color:var(--ok);border-color:var(--ok-bd)}.b-warn{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-bd)} +.b-inc{background:var(--inc-bg);color:var(--inc);border-color:var(--inc-bd)}.b-pend{background:var(--pend-bg);color:var(--pend);border-color:var(--pend-bd)} +.b-err{background:var(--err-bg);color:var(--err);border-color:var(--err-bd)}.b-info{background:var(--info-bg);color:var(--info);border-color:var(--info-bd)} +.b-off{background:var(--off-bg);color:var(--off);border-color:var(--off-bd)} +.spin{animation:none} +.mh{display:inline-flex;align-items:center;gap:4px;font-family:var(--font-mono);font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:var(--radius-sm);padding:2px 6px;color:var(--ink-2)} +.mh .pfx{color:var(--plum);font-weight:600} +.mh .ms{font-size:14px;color:var(--muted)} +/* generic controls (platform classes refine) */ +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font-weight:600;font-size:14px;border-radius:var(--radius-md);padding:0 16px;height:40px;border:1px solid transparent;white-space:nowrap;cursor:default} +.btn .ms{font-size:18px} +.btn.primary{background:var(--plum);color:var(--on-primary)} +.btn.secondary{background:var(--card);color:var(--plum);border-color:#CDBFD4} +.btn.tonal{background:var(--plum-100);color:var(--plum-d)} +.btn.text{background:transparent;color:var(--plum);padding:0 8px} +.btn.danger{background:#fff;color:var(--err);border-color:var(--err-bd)} +.btn.dangerf{background:var(--err);color:#fff} +.btn.lg{height:52px;font-size:16px;border-radius:var(--radius-xl)} +.btn.xl{height:var(--touch-glove-min);font-size:20px;border-radius:var(--radius-2xl)} +.btn.block{display:flex;width:100%} +.btn.dis{opacity:.45} +.field{display:flex;flex-direction:column;gap:4px} +.field label{font-size:12px;font-weight:600;color:var(--muted)} +.inp{height:42px;border:1px solid var(--line-strong);border-radius:var(--radius-md);background:#fff;padding:0 12px;display:flex;align-items:center;gap:8px;font-size:14px;color:var(--ink)} +.inp.ph{color:var(--faint)}.inp.focus{border:2px solid var(--violet);padding:0 11px}.inp.err{border:2px solid var(--err)} +.inp .ms{color:var(--muted);font-size:18px} +.help{font-size:12px;color:var(--muted)} .errt{font-size:12px;color:var(--err);display:flex;gap:4px;align-items:center} +.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-xl)} +.pad{padding:14px}.pad2{padding:20px} +.banner{display:flex;gap:10px;align-items:flex-start;border-radius:var(--radius-lg);padding:10px 12px;font-size:13px;line-height:1.35} +.banner .ms{font-size:20px} +.bn-off{background:var(--off-bg);color:var(--off)}.bn-err{background:var(--err-bg);color:var(--err)}.bn-warn{background:var(--warn-bg);color:var(--warn)}.bn-ok{background:var(--ok-bg);color:var(--ok)}.bn-info{background:var(--info-bg);color:var(--info)} +.list{background:var(--card);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--line)} +.li{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line-soft);min-height:52px;position:relative} +.li:last-child{border-bottom:0} +.li .lead{width:36px;height:36px;border-radius:9px;display:flex;align-items:center;justify-content:center;background:var(--plum-50);color:var(--plum);flex:none} +.li .t{font-size:15px;font-weight:500}.li .s{font-size:12px;color:var(--muted);margin-top:2px} +.li.sel{background:var(--line-soft)} +.chev{color:#A39AB0} +.sec-h{font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:14px 4px 6px} +.sw{width:44px;height:26px;border-radius:13px;background:var(--line-strong);position:relative;flex:none}.sw::after{content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3)} +.sw.on{background:var(--plum)}.sw.on::after{left:21px} +.chk{width:18px;height:18px;border-radius:4px;border:2px solid var(--disabled);display:inline-flex;align-items:center;justify-content:center;flex:none}.chk.on{background:var(--plum);border-color:var(--plum);color:#fff}.chk.on::after{content:'✓';font-size:12px;font-weight:700} +.radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--disabled);flex:none;position:relative}.radio.on{border-color:var(--plum)}.radio.on::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--plum)} +.seg{display:inline-flex;background:var(--line-soft);border-radius:9px;padding:2px}.seg span{padding:5px 12px;font-size:13px;font-weight:500;border-radius:7px;color:var(--muted)}.seg span.on{background:#fff;color:var(--ink);box-shadow:0 1px 2px rgba(0,0,0,.12)} +.chip{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 10px;border-radius:8px;border:1px solid var(--line-strong);font-size:12px;font-weight:500;background:#fff;color:var(--ink)}.chip.on{background:var(--plum-100);border-color:var(--plum-100);color:var(--plum-d)} +.chip .ms{font-size:16px} +.prog{height:6px;border-radius:3px;background:var(--canvas);overflow:hidden}.prog>i{display:block;height:100%;background:var(--plum)} +.tl{display:flex;flex-direction:column;gap:0} +.tl .st{display:flex;gap:10px;position:relative;padding-bottom:14px} +.tl .st:not(:last-child)::before{content:'';position:absolute;left:11px;top:24px;bottom:0;width:2px;background:#DDD5E6} +.tl .st.done:not(:last-child)::before{background:var(--ok)} +.tl .dot{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex:none;background:var(--pend-bg);color:var(--pend)} +.tl .dot .ms{font-size:16px} +.tl .done .dot{background:var(--ok-bg);color:var(--ok)}.tl .cur .dot{background:var(--inc-bg);color:var(--inc);box-shadow:0 0 0 3px var(--inc-bg)} +.tl .st .tt{font-size:13px;font-weight:600}.tl .st .ts{font-size:12px;color:var(--muted)} +.tl .todo .tt{color:var(--subtle);font-weight:500} +table.t{width:100%;border-collapse:collapse;font-size:13px} +table.t th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--line);background:var(--surface-3)} +table.t td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle} +table.t tr.sel td{background:var(--night-ink)} +.kv{display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:13px}.kv>div:nth-child(odd){color:var(--muted)} +.qr svg{display:block} +.avatar{width:32px;height:32px;border-radius:50%;background:var(--plum-100);color:var(--plum-d);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex:none} +.cubeic{width:36px;height:36px;border-radius:9px;background:linear-gradient(140deg,#E6FAFB,#CDEFF1);display:flex;align-items:center;justify-content:center;color:#0E6F76;flex:none} +.kbd{font:600 11px var(--font-mono);border:1px solid var(--line-strong);border-bottom-width:2px;border-radius:4px;padding:0 4px;color:var(--muted);background:#fff} +.field-in{display:inline-block;min-width:44px;padding:4px 8px;border:1px solid var(--line-strong);border-radius:6px;background:#fff;font-weight:600} +.dz{border:2px dashed #B9A6C4;border-radius:14px;background:var(--surface-3);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:var(--plum);text-align:center} +.skel{background:linear-gradient(90deg,var(--line-soft),var(--surface-2),var(--line-soft));border-radius:6px;height:12px} +.scrim{position:absolute;inset:0;background:rgba(20,10,30,.38)} +.shadow{box-shadow:var(--elev-e3)} +.hr{height:1px;background:var(--line);margin:8px 0} +.notice{font-size:11px;color:var(--muted);line-height:1.35} +.stepper{display:flex;gap:6px}.stepper i{flex:1;height:4px;border-radius:2px;background:var(--line)}.stepper i.on{background:var(--plum)} + +/* ---------------- iOS (Apple HIG) */ +.p-ios .kv,.p-and .kv{grid-template-columns:92px 1fr;font-size:12.5px} +.p-ios{font-family:var(--font-sans);font-feature-settings:'ss01','cv11';background:var(--surface-2)} +.p-ios .btn{border-radius:12px;height:50px;font-size:17px} +.p-ios .btn.sm{height:34px;font-size:15px;border-radius:17px;padding:0 14px} +.p-ios .list{border:0;border-radius:12px} +.p-ios .li{border-bottom:0;min-height:44px;padding:9px 16px} +.p-ios .li:not(:last-child)::after{content:'';position:absolute;left:64px;right:0;bottom:0;height:.5px;background:#C6C6C8} +.p-ios .li .lead{width:30px;height:30px;border-radius:7px;background:var(--plum);color:#fff}.p-ios .li .lead .ms{font-size:18px} +.p-ios .li .t{font-size:16px;font-weight:400} +.p-ios .sec-h{text-transform:uppercase;font-weight:400;color:#6D6D72;font-size:12.5px;margin:18px 16px 6px} +.p-ios .inp{border:0;border-radius:10px;height:44px;font-size:16px} +.p-ios .sw{width:51px;height:31px;border-radius:16px}.p-ios .sw::after{width:27px;height:27px;top:2px;left:2px}.p-ios .sw.on{background:#34C759}.p-ios .sw.on::after{left:22px} +/* ---------------- Android (Material 3) */ +.p-and{font-family:var(--font-android);background:var(--surface-3)} +.p-and .btn{border-radius:20px;height:40px;font-size:14px;font-weight:500;letter-spacing:.01em} +.p-and .btn.lg{height:56px;border-radius:28px}.p-and .btn.xl{height:72px;border-radius:24px;font-size:20px} +.p-and .list{border:0;border-radius:0;background:transparent} +.p-and .li{border-bottom:0;padding:8px 16px;min-height:56px} +.p-and .li .lead{border-radius:50%;background:#F2DDF4;color:var(--plum-d);width:40px;height:40px} +.p-and .li .t{font-size:16px;font-weight:400}.p-and .li .s{font-size:14px} +.p-and .inp{border-radius:4px;height:56px;border:1px solid #7C7381;background:transparent;font-size:16px} +.p-and .card{border-radius:12px;border:0;background:var(--plum-50)} +.p-and .chip{border-radius:8px;height:32px} +.p-and .sec-h{text-transform:none;letter-spacing:0;font-size:14px;color:var(--plum);font-weight:500;margin:16px 16px 4px} +/* ---------------- macOS (Apple HIG) */ +.p-mac{font-family:var(--font-sans);font-size:13px;background:#fff} +.p-mac .btn{height:24px;border-radius:6px;font-size:13px;font-weight:500;padding:0 10px;box-shadow:0 .5px 1px rgba(0,0,0,.2)} +.p-mac .btn.secondary{border-color:#D6D3DA;color:var(--ink)} +.p-mac .btn.lg{height:32px;font-size:14px} +.p-mac .inp{height:26px;border-radius:6px;font-size:13px;border-color:#D3CFD8} +.p-mac .li{min-height:36px;padding:6px 10px}.p-mac .li .t{font-size:13px}.p-mac .li .lead{width:28px;height:28px} +.p-mac table.t th{background:#fff;font-size:11px;text-transform:none;letter-spacing:0;color:var(--subtle);padding:5px 8px}.p-mac table.t td{padding:6px 8px} +.p-mac table.t tr:nth-child(even) td{background:var(--surface-3)}.p-mac table.t tr.sel td{background:var(--plum);color:#fff} +.p-mac table.t tr.sel td .muted{color:var(--plum-100)} +/* ---------------- Windows (Fluent 2) */ +.p-win{font-family:var(--font-windows);font-size:14px;background:#F3F3F3} +.p-win .btn{border-radius:4px;height:32px;font-size:14px;font-weight:400;padding:0 12px;border:1px solid #E5E5E5} +.p-win .btn.primary{border-color:var(--plum-d)} +.p-win .btn.secondary{color:#1B1B1B;border-color:#D1D1D1;border-bottom-color:#B5B5B5} +.p-win .inp{height:32px;border-radius:4px;border-color:#E5E5E5;border-bottom:1px solid #8A8A8A;font-size:14px} +.p-win .inp.focus{border:1px solid #E5E5E5;border-bottom:2px solid var(--plum)} +.p-win .card{border-radius:8px;border-color:#E5E5E5} +.p-win .list{border-radius:8px;border-color:#E5E5E5} +.p-win .li .lead{border-radius:4px} +.p-win .sw{width:40px;height:20px;border-radius:10px;background:#fff;border:1px solid #8A8A8A}.p-win .sw::after{width:12px;height:12px;top:3px;left:4px;background:#5D5D5D;box-shadow:none}.p-win .sw.on{background:var(--plum);border-color:var(--plum)}.p-win .sw.on::after{left:23px;background:#fff} +/* ---------------- GNOME (libadwaita) */ +.p-gno{font-family:var(--font-gnome);font-size:14px;background:#FAFAFA} +.p-gno .btn{border-radius:6px;height:34px;font-size:14px;font-weight:700;padding:0 14px;background:#E8E8E8;color:#2E3436;border:0} +.p-gno .btn.primary{background:var(--plum);color:var(--on-primary)} +.p-gno .btn.pill{border-radius:999px;height:42px;padding:0 28px} +.p-gno .btn.flat{background:transparent} +.p-gno .btn.dangerf{background:#C01C28} +.p-gno .list{border-radius:12px;border:0;box-shadow:0 0 0 1px rgba(0,0,0,.06),0 1px 3px rgba(0,0,0,.08)} +.p-gno .li{min-height:52px;border-bottom:1px solid #EBEBEB}.p-gno .li .t{font-size:14.5px} +.p-gno .li .lead{background:transparent;color:#2E3436;width:24px} +.p-gno .inp{border:0;background:#EBEBEB;border-radius:6px;height:36px} +.p-gno .sw{width:48px;height:26px}.p-gno .sw.on{background:var(--plum)} +.p-gno .sec-h{text-transform:none;letter-spacing:0;color:#2E3436;font-size:15px;font-weight:700;margin:18px 2px 8px} +.p-gno .card{border:0;border-radius:12px;box-shadow:0 0 0 1px rgba(0,0,0,.06),0 1px 3px rgba(0,0,0,.08)} +/* ---------------- Web */ +.p-web{font-family:var(--font-sans);font-size:14px;background:var(--bg)} +.p-web .btn{border-radius:8px;height:38px} +.p-web .btn.sm{height:30px;font-size:13px;padding:0 10px} + +.ms{font-family:'Material Symbols Rounded',MSR,sans-serif} +body{font-family:Inter,var(--font-sans),system-ui,sans-serif} +/* App shell (web products) */ +.app-shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh} +.app-shell aside{background:var(--card);border-right:1px solid var(--line);padding:16px 12px;display:flex;flex-direction:column;gap:4px;position:sticky;top:0;height:100vh;z-index:20} +.app-shell main{padding:28px 32px 48px;max-width:1100px;min-width:0} +.app-brand{display:flex;align-items:center;gap:10px;padding:4px 8px 16px} +.app-brand .mark{width:32px;height:32px;border-radius:10px;background:linear-gradient(140deg,var(--plum),var(--violet));color:#fff;display:grid;place-items:center;font-weight:800;font-size:14px} +.app-nav button,.app-nav a.nav{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:0;background:transparent;padding:10px 12px;border-radius:10px;color:var(--muted);font:600 14px Inter,system-ui;cursor:pointer;text-decoration:none;min-height:var(--touch-web-min,44px)} +.app-nav button:hover,.app-nav a.nav:hover{background:var(--surface-2);color:var(--ink)} +.app-nav button.active,.app-nav a.nav.active{background:var(--plum-50);color:var(--plum)} +.app-nav .ms{font-size:20px} +.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0} +.kpi{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-xl);padding:14px 16px} +.kpi .k{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)} +.kpi .v{font-size:26px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums} +.kpi .s{font-size:12px;color:var(--muted);margin-top:2px} +.t{width:100%;border-collapse:collapse} +.t th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);border-bottom:1px solid var(--line);padding:8px 10px} +.t td{border-bottom:1px solid var(--line-soft);padding:10px;font-size:14px} +.t tr:hover td{background:var(--plum-50)} +.h1{font-size:24px;font-weight:700;letter-spacing:-.01em} +.lede{color:var(--muted);font-size:14px;margin:4px 0 16px} +.auth-screen{min-height:100vh;display:grid;place-items:center;background:linear-gradient(160deg,var(--plum-50),var(--canvas));padding:24px} +.auth-card{width:min(400px,100%);max-width:100%;background:var(--card);border-radius:16px;padding:28px 24px;box-shadow:var(--elev-e2,0 8px 24px rgba(29,21,52,.08));border:1px solid var(--line)} +/* Mobile chrome (ZP-09) — topbar + collapsible drawer; desktop unchanged */ +.app-topbar{display:none;align-items:center;gap:10px;padding:10px 14px;background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:15;min-height:52px} +.app-topbar .topbar-title{font-weight:700;font-size:15px;color:var(--ink);flex:1;min-width:0} +.nav-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--touch-web-min,44px);height:var(--touch-web-min,44px);border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--plum);cursor:pointer;flex:none;padding:0} +.nav-toggle .ms{font-size:22px} +.nav-toggle:focus-visible{outline:2px solid var(--focus);outline-offset:2px} +.nav-scrim{display:none;position:fixed;inset:0;background:rgba(20,10,30,.38);z-index:18;border:0;padding:0;cursor:pointer} +.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch} +@media(max-width:900px){ + /* flex column so fixed drawer/scrim do not leave empty grid rows (beats #shell.on) */ + .app-shell,#shell.on.app-shell{display:flex;flex-direction:column;grid-template-columns:unset} + .app-shell>.app-topbar{display:flex;flex:none;order:0} + .app-shell>aside{ + position:fixed;left:0;top:0;bottom:0;width:min(280px,86vw);height:100vh;max-height:100dvh; + transform:translateX(-105%);transition:transform var(--dur-base,200ms) var(--ease-standard,cubic-bezier(0.2,0,0,1)); + box-shadow:var(--elev-e3);border-right:1px solid var(--line);z-index:22;padding-top:12px + } + .app-shell.nav-open>aside{transform:translateX(0)} + .app-shell.nav-open>.nav-scrim{display:block} + .app-shell>main{padding:16px 14px 48px;max-width:none;flex:1;order:1;width:100%} + .kpi-grid{grid-template-columns:1fr 1fr;gap:10px;margin:12px 0} + .kpi .v{font-size:22px} + .h1{font-size:20px} + .btn{min-height:var(--touch-web-min,44px)} + .btn.sm{min-height:40px} + .auth-screen{padding:16px} + .auth-card{width:min(400px,100%);padding:24px 18px} + .auth-card input,.auth-card .inp{min-height:44px;font-size:16px} + .auth-card .btn.primary,.auth-card button.primary{min-height:var(--touch-web-min,44px)} + .kv{grid-template-columns:1fr;gap:4px 0} + .qr-box{flex-direction:column} + .filterbar{gap:12px} + .filterbar input,.filterbar select{min-height:44px;font-size:16px} + .row-actions{white-space:normal} + .row-actions button{margin:4px 4px 4px 0;min-height:40px} +} +@media(max-width:560px){ + .kpi-grid{grid-template-columns:1fr} + .kpi{padding:12px 14px} + .status-toast,#status{left:12px;right:12px;max-width:none;bottom:12px} +} +@media(prefers-reduced-motion:reduce){ + .app-shell>aside{transition:none} +}