Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Cursor Agent
5ed87da68f
Keep the macOS receipts SMB share across reboots.
Some checks failed
installers / netprint (push) Has been cancelled
installers / macos-pkg (push) Has been cancelled
installers / publish (push) Has been cancelled
Save the installer choice as shareReceiptsSMB, recreate the share when it is missing, and show the live smb:// address on the receipts page.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:34:51 +00:00
Cursor Agent
970061d689
Register the menu-bar Objective-C methods with the selector type purego expects.
The status-item class was never registered, so the tray treated a failed setup as success. Smoke now fails closed until the class exists.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:23:23 +00:00
Cursor Agent
86ebbbaea9
Fix the menu-bar helper so swiftc accepts it on macOS 14.
The status item types are visible to the menu class, and the JSON helper calls match its signature.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:20:46 +00:00
Cursor Agent
969415e5c6
Keep the Mac menu bar up and open the live receipts page.
Load AppKit on the main thread, log why the tray would exit, and fall back to an AppKit status item if NSApplication run returns. Open Receipts uses the status URL. Installers can share that folder read-only over SMB.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:18:24 +00:00
Cursor Agent
f96ade41bd
Ship the Mac menu bar as an app bundle and use one real stamp path.
The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 02:09:48 +00:00
Cursor Agent
e685b0241e
Build the macOS package with pkgbuild and leave CUPS localhost alone.
The arm64 pkg and the Swift AirDrop helper are built on a macos-14 runner, then checked with pkgutil and installer. Receipts move to /Users/Shared/Verae Receipts, and port 631 is bound only on LAN addresses.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 01:47:53 +00:00
Cursor Agent
a63ad55343
Skip the AirDrop helper compile unless swiftc is on macOS.
Linux CI has a Swift compiler that cannot import AppKit. The menu-bar app opens the share sheet itself when verae-airdrop is not in the package.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 01:29:53 +00:00
Cursor Agent
927229ab4f
AirDrop stamped PDFs from the macOS menu bar.
The menu-bar app, not the root daemon, opens the AirDrop share sheet for each finished job when the setting is on. The unsigned arm64 package installs the daemon and LaunchAgent, and Darwin DNSServiceRegister advertises the _universal and _print subtypes.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 01:27:23 +00:00
Cursor Agent
a7e42e6034
Return the stamped PDF to the device that sent it.
POST /api/stamp runs the print pipeline and responds with the certificate, or a zip of the original, the certificate, and the receipt. The receipts page links an iPhone shortcut, and mDNS advertises _verae._tcp without a second IPP name.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 01:07:27 +00:00
Cursor Agent
dcc987da6e
Mail stamped PDFs and serve a phone receipts page.
After a job is stored as completed, email the certificate, the original, and the receipt summary. A mail failure is logged and retried, and it does not fail the print. The SMTP password stays in DPAPI or a root-only file. Phones on Private and Domain networks can open /receipts for the stamped PDF.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 00:56:38 +00:00
Cursor Agent
17bb067010
Keep one IPP advertisement and accept link-local IPv6.
DnsServiceRegister stays the only owner of the _ipp._tcp instance. The shared UDP responder answers subtype PTRs only, so mDNS no longer renames a second copy to Name(1). The IPP port listens on IPv4 and on [::] with IPV6_V6ONLY, and the firewall rules allow fe80::/10, so a Mac connect to the host's link-local address completes.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 00:34:34 +00:00
Cursor Agent
e099cee78c
Fix Windows AirPrint discovery, media, PWG pages, tray lifetime, and Print-Job reads.
DnsServiceRegister publishes only the primary _ipp._tcp name. A shared UDP 5353 socket answers subtype PTRs that point at that name, plus SRV, TXT, and address records. PWG RaS2 headers are big-endian, IPP advertises letter and A4 media-col, Print-Job reads Content-Length after 100 Continue, and the tray starts once behind a mutex.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 00:15:02 +00:00
Cursor Agent
1d8dbac81a
Advertise AirPrint subtypes and keep job folders, formats, and the service log consistent.
Windows DnsServiceRegister now registers the Bonjour service-type form and the RFC subtype name so a Mac browse of _ipp._tcp,_universal can see the printer. document-format-supported matches the TXT pdl list, including JPEG. Job ids persist in next-job and job-N directories. The service log is written and flushed even when stderr fails. The tray is started by Task Scheduler or Explorer so setup is not its parent.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 22:16:54 +00:00
Cursor Agent
0808cdd781
Stop the network printer before replacing its binary on upgrade.
A silent Windows setup copied verae-print.exe while VeraeNetPrint and the tray still had the files open, skipped the locked copies, and left the old binary to reject the new flags. launch-tray on that binary then started a second server. Stop and wait, then copy, and abort if a file is still locked. macOS bootouts the LaunchDaemon before the payload is copied, and Debian stops the unit from preinst. launch-tray only starts the tray.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 22:00:24 +00:00
Cursor Agent
141abf5623
Advertise the Windows printer through DNS-SD and log the service.
VeraeNetPrint had no UDP sockets because Dnscache owns port 5353 and the bind error never reached a log or the status API. Register with DnsServiceRegister first, fall back to a shared UDP socket, write %ProgramData%\Verae\logs\netprint.log, and surface mDNS errors on /api/v1/status. The installer also starts the tray for the logged-in user, and Public networks plus a local queue stay off unless opted in.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 21:48:31 +00:00
Cursor Agent
2f7f7aa873
Keep the network printer off the driver helper port and install folder.
Use 8632 when 631 is taken, install standalone Windows and macOS builds beside the driver instead of over it, open the LAN firewall ports, and give silent NSIS dialogs a default plus an install log.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 19:51:03 +00:00
Cursor Agent
bc7b09061b
Add an optional LAN timestamp printer and tray control.
IPP Everywhere jobs reuse the existing Verae stamp path. Standalone installers and an off-by-default driver option share one service, config, and login-time tray, which talks to a loopback control API.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 19:30:21 +00:00
2fef6575ba site: Debian .deb 0.3.4 2026-10-04 14:16:36 -04:00
179c974b60 sign-in: in-app password reset (emailed code) on Mac/Linux and Windows; v0.3.4
- auth.PasswordForgot / auth.PasswordReset (+ httptest tests); ErrResetUnavailable
  on 404/401/405/5xx so dialogs offer https://customer.verae.ai/portal/
- keyflow_gui: 'Forgot password?' on the password step and after a failed sign-in
- Windows: Forgot password? link, forgot/reset/unavailable forms
- bump HelperVersion, deb and pkg default VERSION to 0.3.4
2026-10-04 14:14:55 -04:00
f8e74b56db site: Debian section mentions 0.3.3 and the sign-in window 2026-10-04 13:58:02 -04:00
333ce3ef74 deb: default VERSION 0.3.3 2026-10-04 13:57:40 -04:00
George Lambert
133315ab53 Linux: zenity sign-in/key dialog; CUPS backend opens it in the user's session when a key is missing or rejected; apps-menu launcher; 0.3.3 2026-10-04 13:57:40 -04:00
095e1e42ba deb: upgrades keep the Verae_Timestamping queue and its jobs (postinst creates only if missing, prerm removes only on remove); default VERSION 0.3.2 2026-10-04 13:51:56 -04:00
7a938892d1 linux CUPS backend: install root 0700 (CUPS ran it as lp → every job failed status 1), find /usr/bin/verae-print, chown spool to job user, clear ERROR: for missing signing key, debug trace via /etc/verae-print/debug 2026-10-04 13:45:56 -04:00
386f9d1a2f site: check in printit.verae.ai (welcome dialog, mobile layout, Learn more FAQ) 2026-10-04 13:29:42 -04:00
143 changed files with 18813 additions and 328 deletions

96
.github/workflows/installers.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: installers
on:
push:
branches: ["**"]
pull_request:
jobs:
netprint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.1"
- name: Toolchain
run: sudo apt-get update && sudo apt-get install -y nsis cpio g++ git
- name: Build driver and network-printer installers
run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh
- uses: actions/upload-artifact@v4
with:
name: verae-netprint-linux
path: |
build/netprint/*.deb
build/netprint/*.exe
build/netprint/macos/**
if-no-files-found: error
macos-pkg:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.1"
- name: Build arm64 binary, AirDrop helper, and package
run: |
set -euo pipefail
VERSION=0.3.4
OUT="$PWD/build/netprint/macos"
mkdir -p "$OUT"
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/verae-print" ./cmd/verae-print
chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh
VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh
cp -f "$OUT/verae-print" "$OUT/verae-print-arm64"
APP="$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS"
test -x "$APP/verae-airdrop"
test -x "$APP/verae-menubar"
test -x "$APP/verae-print"
test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist"
test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg"
"$APP/verae-menubar" --smoke | tee "$OUT/menubar-smoke.txt"
"$APP/verae-print" tray --smoke | tee "$OUT/tray-smoke.txt"
grep -E 'smoke ok|no window server' "$OUT/menubar-smoke.txt"
grep -E 'smoke ok|no window server' "$OUT/tray-smoke.txt"
- name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo
run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg
- uses: actions/upload-artifact@v4
with:
name: verae-netprint-macos
path: |
build/netprint/macos/*.pkg
build/netprint/macos/verae-print
build/netprint/macos/verae-print-arm64
if-no-files-found: error
publish:
needs: [netprint, macos-pkg]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: verae-netprint-linux
path: dist
- uses: actions/download-artifact@v4
with:
name: verae-netprint-macos
path: macpkg
- name: SHA256SUMS
run: |
set -euo pipefail
rm -f dist/macos/*.pkg dist/SHA256SUMS
mkdir -p dist/macos
cp -f macpkg/*.pkg macpkg/verae-print macpkg/verae-print-arm64 dist/macos/
cd dist
sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh | tee SHA256SUMS
test -f macos/VeraeNetPrint-macos-arm64-0.3.4.pkg
- uses: actions/upload-artifact@v4
with:
name: verae-netprint-installers
path: |
dist/*.deb
dist/*.exe
dist/SHA256SUMS
dist/macos/**
if-no-files-found: error

13
Makefile Normal file
View file

@ -0,0 +1,13 @@
.PHONY: test netprint-debs netprint-windows netprint-macos installers
VERSION ?= 0.3.4
test:
go test ./...
netprint-debs:
VERSION=$(VERSION) ./platform/linux/deb/build-deb.sh amd64
VERSION=$(VERSION) ./platform/linux/deb/build-netprint-deb.sh amd64
netprint-windows netprint-macos installers:
VERSION=$(VERSION) ./scripts/build-netprint-installers.sh

View file

@ -26,6 +26,14 @@ verae-print setup
verae-print stamp document.pdf
```
**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. On Windows the service advertises through DNS-SD (`DnsServiceRegister`) because the DNS Client owns UDP 5353, writes `%ProgramData%\Verae\logs\netprint.log`, and the setup starts the tray for the logged-in user. Public networks and a local Windows queue stay off unless you opt in. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md).
| OS | Standalone | With the driver (off unless you opt in) |
|----|------------|------------------------------------------|
| Windows | `VeraeNetPrint-windows-amd64-setup.exe` | Checkbox on the driver setup’s options page |
| macOS | `VeraeNetPrint-macos.pkg` | Customize → Network timestamp printer |
| Debian | `sudo apt install ./verae-netprint_amd64.deb` | `sudo apt install ./verae-print_amd64.deb` and answer the prompt, or install `verae-netprint` later |
**Linux (tarball, any distro):**
```bash
@ -64,6 +72,13 @@ printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once
chmod 600 ~/.verae/xverae-token
```
Build the network-printer installers (Debian packages, Windows NSIS setups, macOS binaries and a `.pkg` when `pkgbuild` is available):
```bash
make installers
# artifacts: build/netprint/
```
## Usage
In any app: **File → Print → Destination → Verae Timestamping → Print**.

View file

@ -0,0 +1,19 @@
// Command verae-netprint-tray is the Windows notification-area build of the
// network-printer menu. It is the same control code as `verae-print tray`,
// linked as a GUI-subsystem binary so it does not open a console.
package main
import (
"context"
"fmt"
"os"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint"
)
func main() {
if err := netprint.RunTray(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View file

@ -1,4 +1,4 @@
//go:build !windows && !darwin
//go:build !windows && !darwin && !linux
package main

View file

@ -3,10 +3,52 @@ package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"unicode/utf16"
)
func TestNetworkStampUsesDesktopPath(t *testing.T) {
netprintSrc, err := os.ReadFile("netprint_cmd.go")
if err != nil {
t.Fatal(err)
}
core, err := os.ReadFile("stampcore.go")
if err != nil {
t.Fatal(err)
}
text := string(netprintSrc)
if !strings.Contains(text, "stampWithClient(") || strings.Contains(text, "mock-cert") || strings.Contains(text, `DocumentName: "document.pdf"`) {
t.Fatal("netprint stamp is not the desktop stamp path")
}
if !strings.Contains(string(core), "func stampWithClient(") || !strings.Contains(string(core), "stampWithClient(pdf, t.Title") {
t.Fatal("stampFile does not use stampWithClient")
}
}
func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) {
if code := runNetprint([]string{"launch-server"}); code != 2 {
t.Fatalf("unknown command exit %d, want 2", code)
}
if runtime.GOOS == "windows" {
return
}
// Linux has no tray launcher. The command must fail before any listen.
if code := runNetprint([]string{"launch-tray"}); code != 1 {
t.Fatalf("launch-tray exit %d, want 1", code)
}
}
func TestTrayUnknownArgument(t *testing.T) {
old := os.Args
t.Cleanup(func() { os.Args = old })
os.Args = []string{"verae-print", "tray", "--nope"}
if code := runTrayCmd(); code != 2 {
t.Fatalf("tray exit %d, want 2", code)
}
}
func TestReadTextFileUTF16(t *testing.T) {
s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n"
u := utf16.Encode([]rune(s))

View file

@ -0,0 +1,10 @@
//go:build !windows && !linux
package main
import "fmt"
func runKeyDialog() int {
fmt.Println("key-dialog is for Windows and Linux; on macOS run: verae-print mac-key-setup")
return 1
}

View file

@ -45,6 +45,8 @@ function Btn([string]$text, [int]$x, [int]$w, [string]$res) {
switch ($kind) {
'login' {
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false ''
$fl = New-Object Windows.Forms.LinkLabel; $fl.Text = 'Forgot password?'; $fl.Left = 170; $fl.Top = $script:y; $fl.Width = 200
$fl.Add_LinkClicked({ $script:result = 'forgot'; $f.Close() }); $f.Controls.Add($fl); $script:y += 24
$script:y += 8
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
Btn 'Create account' 125 120 'create' | Out-Null
@ -53,7 +55,35 @@ switch ($kind) {
$f.Height = $script:y + 75
if ($pre) { $f.Add_Shown({ $p.Focus() }) }
[void]$f.ShowDialog()
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" }
elseif ($script:result -eq 'forgot') { "forgot|$(B $e.Text)" } else { $script:result }
}
'forgot' {
$e = Field 'Email' $false $pre
$script:y += 8
$ok = Btn 'Send code' 15 100 'send'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'send') { "send|$(B $e.Text)" } else { 'cancel' }
}
'reset' {
$k = Field 'Code from the email' $false ''; $p = Field 'New password' $true ''; $p2 = Field 'Confirm new password' $true ''
$script:y += 8
$ok = Btn 'Reset password' 15 130 'reset'; $f.AcceptButton = $ok
Btn 'Send a new code' 155 130 'resend' | Out-Null
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'reset') { "reset|$(B $k.Text)|$(B $p.Text)|$(B $p2.Text)" } else { $script:result }
}
'unavail' {
$ok = Btn 'Open customer.verae.ai' 15 200 'open'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'open') { Start-Process 'https://customer.verae.ai/portal/' }
'cancel'
}
'create' {
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
@ -143,9 +173,21 @@ func acquireKeyInteractive(reason string) bool {
}
if err != nil {
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err)
msg = "Sign-in failed: " + err.Error()
msg = "Sign-in failed: " + err.Error() + "\r\nForgot your password? Click \"Forgot password?\" below."
continue
}
case "forgot":
if e := strings.TrimSpace(f[0]); e != "" {
email = e
}
k, ok, m := winForgotFlow(caps, email)
if m != "" {
msg = m
}
if !ok {
continue
}
key = k
case "create":
k, ok := winCreateFlow(caps, email)
if !ok {
@ -231,6 +273,95 @@ func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
return "", false
}
// winResetUnavailable: in-app reset not supported; offer the web portal.
func winResetUnavailable() {
_, _, _ = winForm("unavail", "Password reset isn't available in the app yet.\r\n\r\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "")
}
// winForgotFlow: emailed-code password reset. Returns (key, ok, message for the sign-in form).
func winForgotFlow(caps auth.Capabilities, email string) (string, bool, string) {
msg := "Reset your Verae password.\r\nWe'll email a 6-digit code to this address."
for i := 0; i < 10; i++ {
act, f, err := winForm("forgot", msg, email)
if err != nil || act != "send" {
return "", false, ""
}
email = strings.TrimSpace(f[0])
if !auth.ValidEmail(email) {
msg = "Please enter a valid email address."
continue
}
vlog.Printf("sign-in: password reset requested for %s", auth.MaskEmail(email))
if ferr := auth.PasswordForgot(email); ferr != nil {
vlog.Errorf("sign-in: password forgot %s: %v", auth.MaskEmail(email), ferr)
if errors.Is(ferr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
msg = "Could not send a reset code: " + ferr.Error()
continue
}
cmsg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes). Check your spam folder.\r\n" + caps.Policy.Describe()
for j := 0; j < 15; j++ {
act, cf, err := winForm("reset", cmsg, "")
if err != nil || act == "cancel" || act == "" {
return "", false, ""
}
if act == "resend" {
if rerr := auth.PasswordForgot(email); rerr != nil {
if errors.Is(rerr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
cmsg = "Could not send a new code: " + rerr.Error()
} else {
cmsg = "A new code was sent to " + email + ".\r\n" + caps.Policy.Describe()
}
continue
}
code, pw := strings.TrimSpace(cf[0]), cf[1]
switch {
case len(code) != 6 || strings.Trim(code, "0123456789") != "":
cmsg = "Enter the 6-digit code from the email.\r\n" + caps.Policy.Describe()
continue
case pw != cf[2]:
cmsg = "The passwords do not match.\r\n" + caps.Policy.Describe()
continue
}
if perr := caps.Policy.Check(pw, email); perr != nil {
cmsg = perr.Error() + "\r\n" + caps.Policy.Describe()
continue
}
key, rerr := auth.PasswordReset(email, code, pw, tokenName())
cf[1], cf[2] = "", ""
if rerr != nil {
vlog.Errorf("sign-in: password reset %s: %v", auth.MaskEmail(email), rerr)
if errors.Is(rerr, auth.ErrResetUnavailable) {
winResetUnavailable()
return "", false, ""
}
cmsg = "Could not reset the password: " + rerr.Error()
continue
}
vlog.Printf("sign-in: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "")
if key != "" {
return key, true, ""
}
key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName())
pw = ""
if lerr != nil {
if errors.Is(lerr, auth.ErrTOTPRequired) {
return "", false, "Your password was changed. Sign in with your new password and your 6-digit authenticator code."
}
return "", false, "Your password was changed, but signing in failed: " + lerr.Error() + "\r\nSign in with your new password."
}
return key, true, ""
}
return "", false, ""
}
return "", false, ""
}
// pasteFlow: masked key field + portal button; validated before saving.
func pasteFlow(reason string) bool {
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below."

View file

@ -0,0 +1,365 @@
//go:build darwin || linux
package main
// Shared native sign-in flow (macOS AppleScript dialogs, Linux zenity dialogs).
// Platform files provide ask/say/choose/openURL, machineWord and keyStoreDesc.
// Passwords travel only over the dialog's stdout pipe and HTTPS; never argv,
// files or logs. Keys are logged masked only.
import (
"errors"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
var errCancel = errors.New("cancelled")
const (
optKeep = "Keep the existing key"
optLogin = "Sign in to my Verae account (email + password)"
optCreate = "Create a new Verae account"
optPaste = "Paste an xVerae- key"
optSkip = "Skip for now"
)
// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key.
func acquireKeyInteractive(reason string) bool {
if !guiAvailable() {
vlog.Printf("sign-in needed but no desktop session for dialogs: %s (run: verae-print key-dialog)", reason)
return false
}
_, saved := keyFlow(reason)
return saved
}
// keyFlow returns a masked result string and whether a NEW key was saved.
func keyFlow(reason string) (string, bool) {
existing, _ := auth.LoadXVeraeToken()
caps := auth.GetCapabilities()
vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "")
var opts []string
intro := "No Verae signing key was found for this " + machineWord + " user. You need one to timestamp documents."
if existing != "" {
st, det := auth.ValidateXVeraeKey(existing)
vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det)
switch st {
case "valid":
intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)."
opts = append(opts, optKeep)
case "invalid":
intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key."
default:
intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)."
opts = append(opts, optKeep)
}
}
opts = append(opts, optLogin, optCreate, optPaste, optSkip)
if reason != "" {
intro = reason + "\n\n" + intro
}
prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (" + keyStoreDesc + ")."
for {
c, err := choose(prompt, opts)
if err != nil {
c = optSkip
}
var key string
switch c {
case optKeep:
return "kept " + auth.MaskKey(existing), false
case optLogin:
key, err = macLogin(caps)
case optCreate:
key, err = macCreate(caps)
case optPaste:
key, err = macPaste()
default:
btn, _, aerr := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or run Verae Print Stamp from your apps menu).", "", false, false, []string{"Go back", "Skip"}, "Go back")
if aerr != nil || btn == "Skip" {
return "skipped", false
}
continue
}
if errors.Is(err, errCancel) {
continue // back to the menu
}
if err != nil {
vlog.Errorf("mac-key-setup: %v", err)
say("Could not get a key: " + err.Error())
continue
}
if err := auth.SaveXVeraeToken(key); err != nil {
vlog.Errorf("mac-key-setup: save failed: %v", err)
say("The key could not be saved: " + err.Error())
continue
}
vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key))
say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".")
return "saved " + auth.MaskKey(key), true
}
}
const btnForgot = "Forgot password?"
func macLogin(caps auth.Capabilities) (string, error) {
email := ""
for {
btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", btnForgot, "Sign in"}, "Sign in")
if err != nil || btn == "Back" {
continue
}
if btn == btnForgot {
key, ferr := macForgot(caps, email)
if errors.Is(ferr, errCancel) {
continue
}
return key, ferr
}
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(err, auth.ErrTOTPRequired) {
btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify")
if cerr != nil || btn == "Back" {
continue
}
key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName())
}
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
b, _, _ := ask("Sign-in failed: "+err.Error(), "", false, false, []string{btnForgot, "Try again"}, "Try again")
if b == btnForgot {
key, ferr := macForgot(caps, email)
if errors.Is(ferr, errCancel) {
continue
}
return key, ferr
}
continue
}
return key, nil
}
}
// offerPortalReset tells the user in-app reset is unavailable and offers the web portal.
func offerPortalReset() {
b, _, _ := ask("Password reset isn't available in the app yet.\n\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "", false, false, []string{"Back", "Open portal"}, "Open portal")
if b == "Open portal" {
openURL(auth.PortalResetURL)
}
}
// macForgot: emailed-code password reset. Returns a minted key, or errCancel.
func macForgot(caps auth.Capabilities, email string) (string, error) {
for {
btn, e, err := ask("Reset your Verae password\n\nWe'll email a 6-digit code to this address:", email, false, true, []string{"Back", "Send code"}, "Send code")
if err != nil || btn == "Back" {
return "", errCancel
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
vlog.Printf("mac-key-setup: password reset requested for %s", auth.MaskEmail(email))
if ferr := auth.PasswordForgot(email); ferr != nil {
vlog.Errorf("mac-key-setup: password forgot %s: %v", auth.MaskEmail(email), ferr)
if errors.Is(ferr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not send a reset code: " + ferr.Error())
continue
}
msg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes).\nIt may take a minute; check spam.\n\nCode:"
codeLoop:
for {
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Next"}, "Next")
if err != nil || btn == "Cancel" {
return "", errCancel
}
if btn == "Send a new code" {
if rerr := auth.PasswordForgot(email); rerr != nil {
if errors.Is(rerr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not send a new code: " + rerr.Error())
} else {
say("A new code is on its way.")
}
continue
}
code = strings.TrimSpace(code)
if len(code) != 6 || strings.Trim(code, "0123456789") != "" {
say("Enter the 6-digit code from the email.")
continue
}
for {
btn, pw, err := ask("Choose a new password.\n"+caps.Policy.Describe(), "", true, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue codeLoop
}
if perr := caps.Policy.Check(pw, email); perr != nil {
say(perr.Error())
continue
}
btn, pw2, err := ask("Confirm the new password:", "", true, true, []string{"Back", "Reset password"}, "Reset password")
if err != nil || btn == "Back" {
continue
}
if pw != pw2 {
say("The passwords do not match.")
continue
}
pw2 = ""
key, rerr := auth.PasswordReset(email, code, pw, tokenName())
if rerr != nil {
pw = ""
vlog.Errorf("mac-key-setup: password reset %s: %v", auth.MaskEmail(email), rerr)
if errors.Is(rerr, auth.ErrResetUnavailable) {
offerPortalReset()
return "", errCancel
}
say("Could not reset the password: " + rerr.Error())
continue codeLoop
}
vlog.Printf("mac-key-setup: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "")
if key != "" {
return key, nil
}
// No key minted (e.g. 2FA account): sign in with the new password.
key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(lerr, auth.ErrTOTPRequired) {
b, totp, cerr := ask("Your password was changed.\n\nThis account uses two-factor authentication. Enter the 6-digit code from your authenticator app:", "", false, true, []string{"Cancel", "Verify"}, "Verify")
if cerr != nil || b == "Cancel" {
pw = ""
return "", errCancel
}
key, lerr = auth.InstallLogin(caps, email, pw, strings.TrimSpace(totp), tokenName())
}
pw = ""
if lerr != nil {
say("Your password was changed, but signing in failed: " + lerr.Error() + "\n\nChoose \"Sign in\" and use your new password.")
return "", errCancel
}
return key, nil
}
}
}
}
func macCreate(caps auth.Capabilities) (string, error) {
var name, email string
for {
btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
name = strings.TrimSpace(n)
if name == "" {
continue
}
btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
policy := caps.Policy.Describe()
btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
if perr := caps.Policy.Check(pw, email); perr != nil {
say(perr.Error())
continue
}
btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account")
if err != nil || btn == "Back" {
continue
}
if pw != pw2 {
say("The passwords do not match.")
continue
}
pw2 = ""
vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI)
rr, err := auth.InstallRegister(caps, name, email, pw, tokenName())
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err)
say("Could not create the account: " + err.Error())
continue
}
if rr.PendingID == "" {
return rr.Key, nil // server without email verification (legacy signup)
}
vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email))
msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:"
for {
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify")
if err != nil || btn == "Cancel" {
return "", errCancel
}
if btn == "Send a new code" {
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
say("Could not send a new code: " + rerr.Error())
} else {
say("A new code was sent.")
}
continue
}
key, verr := auth.InstallVerify(rr.PendingID, code, tokenName())
if verr != nil {
say(verr.Error())
continue
}
return key, nil
}
}
}
func macPaste() (string, error) {
for {
btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save")
if err != nil || btn == "Back" {
return "", errCancel
}
if btn == "Open portal" {
openURL(auth.PortalSignupURL)
continue
}
k = strings.TrimSpace(k)
st, det := auth.ValidateXVeraeKey(k)
vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det)
switch st {
case "valid":
return k, nil
case "invalid":
say("Verae rejected this key (" + det + "). Please check it and paste again.")
case "badformat":
say("That does not look like a Verae key: " + det)
default:
b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway")
if b == "Save anyway" {
return k, nil
}
}
}
}

View file

@ -3,14 +3,12 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
@ -22,6 +20,15 @@ import (
const dlgTitle = "Verae Print Stamp"
const (
machineWord = "Mac"
keyStoreDesc = "Keychain + ~/.verae"
)
func openURL(u string) { _ = exec.Command("open", u).Start() }
func guiAvailable() bool { return true }
// AppleScript gets all user-visible text via argv (no string interpolation →
// no injection). Output: "<button>\t<text returned>" or "CANCEL".
const dialogScript = `on run argv
@ -60,8 +67,6 @@ const chooseScript = `on run argv
return item 1 of r
end run`
var errCancel = errors.New("cancelled")
func osa(script string, args ...string) (string, error) {
cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...)
out, err := cmd.Output()
@ -103,14 +108,6 @@ func choose(msg string, opts []string) (string, error) {
return out, nil
}
const (
optKeep = "Keep the existing key"
optLogin = "Sign in to my Verae account (email + password)"
optCreate = "Create a new Verae account"
optPaste = "Paste an xVerae- key"
optSkip = "Skip for now"
)
func runMacKeySetup(args []string) int {
fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError)
askOpen := fs.Bool("ask-open", true, "also ask what to open after each print")
@ -143,217 +140,6 @@ func runMacKeySetup(args []string) int {
return 0
}
// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key.
func acquireKeyInteractive(reason string) bool {
_, saved := keyFlow(reason)
return saved
}
// keyFlow returns a masked result string and whether a NEW key was saved.
func keyFlow(reason string) (string, bool) {
existing, _ := auth.LoadXVeraeToken()
caps := auth.GetCapabilities()
vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "")
var opts []string
intro := "No Verae signing key was found for this Mac user. You need one to timestamp documents."
if existing != "" {
st, det := auth.ValidateXVeraeKey(existing)
vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det)
switch st {
case "valid":
intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)."
opts = append(opts, optKeep)
case "invalid":
intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key."
default:
intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)."
opts = append(opts, optKeep)
}
}
opts = append(opts, optLogin, optCreate, optPaste, optSkip)
if reason != "" {
intro = reason + "\n\n" + intro
}
prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (Keychain + ~/.verae)."
for {
c, err := choose(prompt, opts)
if err != nil {
c = optSkip
}
var key string
switch c {
case optKeep:
return "kept " + auth.MaskKey(existing), false
case optLogin:
key, err = macLogin(caps)
case optCreate:
key, err = macCreate(caps)
case optPaste:
key, err = macPaste()
default:
btn, _, _ := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or open Verae Print Stamp from Applications).", "", false, false, []string{"Go back", "Skip"}, "Go back")
if btn == "Skip" {
return "skipped", false
}
continue
}
if errors.Is(err, errCancel) {
continue // back to the menu
}
if err != nil {
vlog.Errorf("mac-key-setup: %v", err)
say("Could not get a key: " + err.Error())
continue
}
if err := auth.SaveXVeraeToken(key); err != nil {
vlog.Errorf("mac-key-setup: save failed: %v", err)
say("The key could not be saved: " + err.Error())
continue
}
vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key))
say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".")
return "saved " + auth.MaskKey(key), true
}
}
func macLogin(caps auth.Capabilities) (string, error) {
email := ""
for {
btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in")
if err != nil || btn == "Back" {
continue
}
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(err, auth.ErrTOTPRequired) {
btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify")
if cerr != nil || btn == "Back" {
continue
}
key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName())
}
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
say("Sign-in failed: " + err.Error())
continue
}
return key, nil
}
}
func macCreate(caps auth.Capabilities) (string, error) {
var name, email string
for {
btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
name = strings.TrimSpace(n)
if name == "" {
continue
}
btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
policy := caps.Policy.Describe()
btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
if perr := caps.Policy.Check(pw, email); perr != nil {
say(perr.Error())
continue
}
btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account")
if err != nil || btn == "Back" {
continue
}
if pw != pw2 {
say("The passwords do not match.")
continue
}
pw2 = ""
vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI)
rr, err := auth.InstallRegister(caps, name, email, pw, tokenName())
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err)
say("Could not create the account: " + err.Error())
continue
}
if rr.PendingID == "" {
return rr.Key, nil // server without email verification (legacy signup)
}
vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email))
msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:"
for {
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify")
if err != nil || btn == "Cancel" {
return "", errCancel
}
if btn == "Send a new code" {
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
say("Could not send a new code: " + rerr.Error())
} else {
say("A new code was sent.")
}
continue
}
key, verr := auth.InstallVerify(rr.PendingID, code, tokenName())
if verr != nil {
say(verr.Error())
continue
}
return key, nil
}
}
}
func macPaste() (string, error) {
for {
btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save")
if err != nil || btn == "Back" {
return "", errCancel
}
if btn == "Open portal" {
_ = exec.Command("open", auth.PortalSignupURL).Start()
continue
}
k = strings.TrimSpace(k)
st, det := auth.ValidateXVeraeKey(k)
vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det)
switch st {
case "valid":
return k, nil
case "invalid":
say("Verae rejected this key (" + det + "). Please check it and paste again.")
case "badformat":
say("That does not look like a Verae key: " + det)
default:
b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway")
if b == "Save anyway" {
return k, nil
}
}
}
}
func askOpenOnComplete() string {
cur, _ := settings.Load()
def := map[string]string{settings.OpenFolder: "Show in Finder", settings.OpenPDF: "Open the PDF", settings.OpenNone: "Do nothing"}[cur.OpenOnComplete]

View file

@ -0,0 +1,171 @@
//go:build linux
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// Linux desktop sign-in dialogs (zenity, GNOME/Cinnamon/XFCE/KDE all ship or
// package it). Launched by the CUPS backend in the job user's session when a
// print needs a key, or by hand: verae-print key-dialog.
const (
dlgTitle = "Verae Print Stamp"
machineWord = "computer"
keyStoreDesc = "~/.verae, readable only by you"
)
func zenityPath() string {
p, err := exec.LookPath("zenity")
if err != nil {
return ""
}
return p
}
func guiAvailable() bool {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return false
}
return zenityPath() != ""
}
func openURL(u string) {
cmd := exec.Command("xdg-open", u)
cmd.Stdout, cmd.Stderr = nil, nil
_ = cmd.Start()
go func() { _ = cmd.Wait() }()
}
// zenity renders --text as Pango markup.
func markupEscape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}
// zen runs zenity; returns stdout (trimmed) and exit code. Exit 1 = cancel or
// an extra button (whose label is printed), 5 = timeout, -1 = could not run.
func zen(args ...string) (string, int) {
base := []string{"--title=" + dlgTitle, "--width=520", "--timeout=900"}
cmd := exec.Command(zenityPath(), append(base, args...)...)
cmd.Stderr = nil
out, err := cmd.Output()
s := strings.TrimRight(string(out), "\n")
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return s, ee.ExitCode()
}
return "", -1
}
return s, 0
}
// ask shows a dialog; returns (button, answer). The default button is OK, the
// first other button is Cancel, the rest are extra buttons.
func ask(msg, def string, hidden, withAnswer bool, buttons []string, defBtn string) (string, string, error) {
if !guiAvailable() {
return "", "", fmt.Errorf("no desktop session for dialogs")
}
var others []string
for _, b := range buttons {
if b != defBtn {
others = append(others, b)
}
}
var args []string
switch {
case withAnswer:
args = []string{"--entry", "--text=" + markupEscape(msg), "--entry-text=" + def}
if hidden {
args = append(args, "--hide-text")
}
case len(others) == 0:
args = []string{"--info", "--text=" + markupEscape(msg)}
default:
args = []string{"--question", "--text=" + markupEscape(msg)}
}
args = append(args, "--ok-label="+defBtn)
if len(others) > 0 {
args = append(args, "--cancel-label="+others[0])
for _, b := range others[1:] {
args = append(args, "--extra-button="+b)
}
}
out, code := zen(args...)
switch code {
case 0:
return defBtn, out, nil
case 1:
for _, b := range others[min(1, len(others)):] {
if out == b {
return b, "", nil
}
}
if len(others) > 0 {
return others[0], "", nil
}
return defBtn, "", nil // info dialog closed
case -1:
return "", "", fmt.Errorf("zenity could not start")
default:
return "", "", errCancel // timeout / window killed
}
}
func say(msg string) { _, _, _ = ask(msg, "", false, false, []string{"OK"}, "OK") }
func choose(msg string, opts []string) (string, error) {
if !guiAvailable() {
return "", fmt.Errorf("no desktop session for dialogs")
}
args := []string{"--list", "--radiolist", "--hide-header", "--height=470",
"--text=" + markupEscape(msg), "--column=", "--column=Option",
"--ok-label=Continue", "--cancel-label=Skip"}
for i, o := range opts {
sel := "FALSE"
if i == 0 {
sel = "TRUE"
}
args = append(args, sel, o)
}
out, code := zen(args...)
if code != 0 || out == "" {
return "", errCancel
}
return out, nil
}
// runKeyDialog: verae-print key-dialog — the desktop "get a signing key" flow.
func runKeyDialog() int {
vlog.Init("installer", false)
if !guiAvailable() {
fmt.Println("key-dialog needs a desktop session with zenity installed (sudo apt install zenity); or run: verae-print setup")
return 1
}
vlog.Printf("key-dialog start user=%s display=%s", os.Getenv("USER"), os.Getenv("DISPLAY"))
release, ok := promptLock()
if !ok {
vlog.Printf("key-dialog: another Verae sign-in window is already open")
return 1
}
reason := os.Getenv("VERAE_KEY_REASON")
result, saved := keyFlow(reason)
release()
vlog.Printf("key-dialog result: %s", result)
fmt.Println("key:", result)
if saved || strings.HasPrefix(result, "kept") {
if n := len(listPending()); n > 0 {
done, remaining, _ := retryPending(stampFile)
vlog.Printf("key-dialog: retried held print jobs: %d stamped, %d remaining", done, remaining)
}
return 0
}
return 1
}

View file

@ -21,6 +21,9 @@ func main() {
if len(os.Args) < 2 && isHelperExe() {
os.Args = append(os.Args, "serve")
}
if len(os.Args) < 2 && (isTrayExe() || isMenuBarApp()) {
os.Args = append(os.Args, "tray")
}
if len(os.Args) < 2 {
usage()
os.Exit(2)
@ -30,6 +33,10 @@ func main() {
os.Exit(runStamp(os.Args[2:]))
case "serve":
os.Exit(runServe(os.Args[2:]))
case "netprint":
os.Exit(runNetprint(os.Args[2:]))
case "tray":
os.Exit(runTrayCmd())
case "ipp-selftest":
os.Exit(runIPPSelftest(os.Args[2:]))
case "installer-account":
@ -76,6 +83,13 @@ Usage:
verae-print install-macos # also install PDF Services menu items (legacy)
verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug]
# Windows print helper: IPP loopback printer + fallback watcher
verae-print netprint [--config FILE] [--name NAME] [--port N] [--data-dir DIR] [--forward TARGET] [--no-mdns]
# LAN IPP Everywhere printer
verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue]
verae-print netprint uninstall --owner driver|standalone
verae-print netprint launch-tray # Windows: start the tray as the logged-in user
verae-print netprint enable-receipts-share # macOS: save shareReceiptsSMB and publish the folder
verae-print tray # menu-bar / system-tray control for the network printer
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

View file

@ -0,0 +1,132 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
func runNetprint(args []string) int {
if len(args) > 0 {
switch args[0] {
case "install", "uninstall", "launch-tray", "enable-receipts-share":
return runNetprintInstall(args)
}
// An unknown word must not fall through into the server. That is how
// `netprint launch-tray` on an older binary started a second listener.
if !strings.HasPrefix(args[0], "-") {
fmt.Fprintf(os.Stderr, "unknown netprint command %q (not starting the server)\n", args[0])
return 2
}
}
fs := flag.NewFlagSet("netprint", flag.ContinueOnError)
config := fs.String("config", "", "config file (default: system netprint.json)")
name := fs.String("name", "", "printer name on the network")
port := fs.Int("port", 0, "IPP port (631, or the fallback when 631 is taken)")
dataDir := fs.String("data-dir", "", "where receipts are stored")
forward := fs.String("forward", "", "queue name or ipp:// URI to forward the PDF to")
noMDNS := fs.Bool("no-mdns", false, "do not advertise with mDNS")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return 2
}
flags := netprint.Config{Name: *name, Port: *port, DataDir: *dataDir, ForwardPrinter: *forward}
if *noMDNS {
off := false
flags.Advertise = &off
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := netprint.RunCommand(ctx, *config, flags, stampNetwork); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
func runNetprintInstall(args []string) int {
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
owner := fs.String("owner", "standalone", "driver or standalone")
token := fs.String("token-file", "", "optional xVerae- token file to copy for the service")
allowPublic := fs.String("allow-public", "", "true or false; empty leaves the saved choice")
localQueue := fs.Bool("local-queue", false, "add a local Windows IPP queue named Verae Timestamp")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args[1:]); err != nil {
return 2
}
var err error
switch args[0] {
case "launch-tray":
err = netprint.LaunchTrayNow()
case "enable-receipts-share":
err = netprint.EnableReceiptsShare()
case "install":
pub, perr := parseAllowPublic(*allowPublic)
if perr != nil {
fmt.Fprintln(os.Stderr, perr)
return 2
}
err = netprint.Install(*owner, *token, netprint.InstallOptions{AllowPublic: pub, LocalQueue: *localQueue})
default:
err = netprint.Uninstall(*owner)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println(args[0], "ok (", *owner, ")")
return 0
}
func parseAllowPublic(s string) (*bool, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return nil, nil
case "true", "1", "yes":
v := true
return &v, nil
case "false", "0", "no":
v := false
return &v, nil
default:
return nil, fmt.Errorf("allow-public must be true or false")
}
}
func runTrayCmd() (code int) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "tray panic: %v\n", r)
code = 1
}
}()
for _, a := range os.Args[2:] {
switch a {
case "--smoke", "-smoke":
_ = os.Setenv("VERAE_TRAY_SMOKE", "1")
default:
fmt.Fprintf(os.Stderr, "unknown tray argument %q\n", a)
return 2
}
}
if err := netprint.RunTray(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
func stampNetwork(pdf, title, user string) (job.Result, error) {
_ = user
// Same client, document name, and job.Run call as a desktop Print-Job.
return stampWithClient(pdf, title, filepath.Dir(pdf), "", vlog.Job(vlog.NewJobID("net")))
}

View file

@ -12,7 +12,16 @@ import (
)
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
return
}
if runtime.GOOS == "linux" {
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
_ = exec.Command("notify-send", "-a", "Verae Timestamp", "Verae: "+title, subtitle).Run()
}
return
}
if runtime.GOOS != "darwin" {
return
}
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
@ -35,8 +44,3 @@ func openFolderPath(p string) {
func runInstallPrinterWindows() int { return 1 }
func debugFromRegistry() bool { return false }
func runKeyDialog() int {
fmt.Println("key-dialog is Windows-only; run: verae-print setup")
return 1
}

View file

@ -28,6 +28,24 @@ func isHelperExe() bool {
return strings.Contains(strings.ToLower(filepath.Base(exe)), "printhelper")
}
func isTrayExe() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(filepath.Base(exe)), "netprint-tray")
}
// isMenuBarApp reports whether this process is the macOS menu-bar bundle.
// Opening Verae NetPrint.app with no arguments starts the tray.
func isMenuBarApp() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.Contains(filepath.Clean(exe), "Verae NetPrint.app"+string(filepath.Separator))
}
// fallbackDropDir is the folder the "Verae TimeStamp PDF (Local Port)" queue writes into.
func fallbackDropDir() string {
if v := os.Getenv("VERAE_FALLBACK_DIR"); v != "" {

View file

@ -36,15 +36,29 @@ func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Chan
return client, ch, nil
}
// stampFile stamps one PDF with the current key (no holding / prompting).
func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) {
// stampWithClient is the real stamp used by the desktop driver, Print-Job, and
// POST /api/stamp. It hashes the file and calls the Verae client. It does not
// invent a receipt.
func stampWithClient(pdf, title, outDir, outPath string, lj vlog.Job) (job.Result, error) {
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
if err != nil {
return job.Result{}, err
}
client.HTTPDo = loggingDo(lj)
return job.Run(job.Options{
SourcePDF: pdf,
OutDir: outDir,
OutPath: outPath,
DocumentName: safeStem(title) + ".pdf",
Channel: ch,
Client: client,
})
}
// stampFile stamps one PDF with the current key (no holding / prompting).
func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) {
start := time.Now()
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: t.OutDir, OutPath: t.OutPath, DocumentName: safeStem(t.Title) + ".pdf", Channel: ch, Client: client})
res, err := stampWithClient(pdf, t.Title, t.OutDir, t.OutPath, lj)
if err != nil {
return res, err
}

View file

@ -0,0 +1,11 @@
//go:build darwin
package main
import "runtime"
func init() {
// -[NSApplication run] returns immediately when it is not on the process
// main thread. Lock before the scheduler can move this goroutine.
runtime.LockOSThread()
}

View file

@ -0,0 +1,36 @@
# Verae Timestamp iPhone shortcut
Build this in the Shortcuts app. It shows up in the Share sheet, sends the PDF or photo to the Verae printer on your Wi-Fi, and saves the stamped certificate. A signed `.shortcut` file is not included: Apple only accepts shortcuts signed on a Mac or iPhone, and this tree is built on Linux.
Open the receipts page on a computer (`http://<printer>:631/receipts`, or 8632 if CUPS is using 631). The QR code opens `http://<printer>:<port>/ios-shortcut` on the phone, which repeats these steps and shows the stamp address for this machine.
## Build it
1. Open **Shortcuts** and tap **+**. Name the shortcut **Verae Timestamp**.
2. Tap the shortcut’s **(i)** or settings and turn on **Show in Share Sheet**.
3. Under share sheet types, leave **PDFs** and **Images** selected. You can clear the other types.
4. Add **Get Contents of URL**.
- URL: `http://<printer>:<port>/api/stamp`
If the receipts page has a PIN, use `http://<printer>:<port>/api/stamp?access=YOURPIN`.
- Method: **POST**.
- Request Body: **File**.
- File: **Shortcut Input**.
- Add a header `X-Filename` if you want the job title to be the file name. The server also accepts `Content-Disposition: attachment; filename="Contract.pdf"`. PDF and JPEG are recognized from the file bytes, so a photo from the share sheet works.
5. Add **Quick Look** and pass the contents of the URL. That is the stamped certificate PDF.
6. Add **Save File**. Leave **Ask Where to Save** on and pick iCloud Drive or On My iPhone when it asks.
7. Show the receipt id:
- Add **Get Dictionary Value**.
- Dictionary: the **Headers** of the Get Contents of URL result.
- Key: `X-Verae-Receipt-Id`.
- Add **Show Result** (or **Show Notification**) with that value.
- `X-Verae-SHA256` is the document hash. `X-Verae-Receipt-JSON` is the URL of the `.verae.json` summary on the printer.
To get a zip instead, change the URL to end in `?format=zip` (use `&access=` if you already have `?access=`). **Save File** then receives `original.pdf`, `certificate.pdf`, and `receipt.json`.
## Use it
In Files or Photos, open a PDF or picture, tap **Share**, and choose **Verae Timestamp**. If it is missing, tap **More** and enable it.
The printer records the request as a job, lists it on the receipts page, and emails the stamped PDF when **Email stamped PDFs** is on. The shortcut does not wait for the email. The PDF in the shortcut result is the certificate.
The stamp address is only for Private and Domain networks, same as the printer. A wrong or missing PIN returns `401` and no PDF.

View file

@ -0,0 +1,47 @@
# Share extension for the Verae Timestamping iOS app
The app (`com.flightlookup.verae-timestamping`) is not in this repository. This note is the design for a **Verae Timestamp** row in the iOS Share sheet that stamps a PDF or photo on the LAN print server and keeps the result in Files and in the app.
The shortcut in `README.md` is the path that works before that extension ships. The server side it needs is already here: `POST /api/stamp` and a `_verae._tcp` Bonjour record.
## Extension
- Extension point: `com.apple.share-services`.
- Display name: **Verae Timestamp**.
- Activation rule: the first attachment conforms to `com.adobe.pdf` or `public.image` (which covers JPEG and HEIC). Ignore other share types.
- Process the attachment in the extension, then call `completeRequestReturningItems` with an item provider for the stamped PDF so the system share sheet can hand it back. Also write a copy into the app group so the main app can list it without waiting for Files.
Suggested app group: `group.com.flightlookup.verae-timestamping`. Put the certificate, the receipt id, the SHA-256, and the receipt JSON in that container. The main app reads the same folder on launch.
## Finding the printer
Browse Bonjour in `local.` with `NWBrowser`:
1. Prefer `_verae._tcp`. The TXT record is `txtvers=1`, `path=/api/stamp`, `ver=1`, and `url=http://<host>:<port>/api/stamp`. The SRV record is the port (631, or 8632 when CUPS owns 631). Resolve the host and POST to `http://<host>:<port>` plus `path`.
2. If that browse is empty, browse `_ipp._tcp` and keep the instance whose TXT `ty` or `product` is **Verae Timestamp**. Use the host and port from that service and POST to `/api/stamp`. The AirPrint `adminurl` ends in `/receipts`; the stamp URL is the same origin with `/api/stamp`.
This repository advertises `_verae._tcp` from the print server’s mDNS socket. It is a separate service name from `<name>._ipp._tcp.local`, so it does not create a second IPP advertisement. On Windows, `DnsServiceRegister` still owns only the IPP name. The shared UDP 5353 socket answers AirPrint subtype PTRs for IPP and, separately, the `_verae._tcp` PTR, SRV, and TXT. It does not add IPP SRV, TXT, or address records while DNS-SD is up.
Stop the browse when the extension finishes. Cache the last host and port in the app group so a later share can try it first and fall back to a browse if the POST fails.
## Upload
`POST /api/stamp` with the file bytes as the body, or as multipart field `file`.
- Header `X-Filename`: the document title.
- Header `X-Verae-User`: optional, shown on the receipts page. Default is `stamp`.
- The response body is `application/pdf` (the certificate).
- Headers: `X-Verae-Receipt-Id`, `X-Verae-SHA256`, `X-Verae-Receipt-JSON`.
- `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`.
The job is stored like a print, appears on `/receipts`, and is emailed when email is enabled. The extension should treat the HTTP response as the copy to save, not wait for the email.
## PIN and network
The route uses the same Private/Domain check and optional receipts PIN as `/receipts`. Send the PIN as `X-Verae-Access`, `Authorization: Bearer <pin>`, or `?access=`. Store the PIN in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), not in the app group. A `401` means the PIN is missing or wrong. A `403` means the computer is on a Public network and Public access is off. Show that text; do not save an empty file.
## Saving
- Write the certificate into the app group and refresh the main app’s receipt list (a Darwin notification on the app group is enough).
- Offer **Save to Files** with a document picker, or by returning the PDF from the extension so the user can pick a folder.
- Keep the receipt id and SHA-256 next to the file. The JSON URL in `X-Verae-Receipt-JSON` is on the LAN and needs the same PIN.

108
docs/network-printer.md Normal file
View file

@ -0,0 +1,108 @@
# Verae network timestamp printer
Optional IPP Everywhere printer. Phones and other computers on the LAN print to it. The default name is **Verae Timestamp** plus this computer's name, for example **Verae Timestamp (Georges-MacBook-Pro)**. Settings can change it. A saved name that is still the old shared **Verae Timestamp** is upgraded on the next start. If that name is already registered on the network, mDNS renames it to **Name (2)** and the new name is saved. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded.
The printer is off until you install the standalone package or turn on the driver installer option. Both paths install one service and one tray / menu-bar control. They do not share an install folder: the standalone package never overwrites the driver binary. If both are installed, the service runs whichever binary is the same version or newer, and removing one leaves the other in place.
| | Windows | macOS | Debian / Ubuntu |
|---|---|---|---|
| Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos-arm64-0.3.4.pkg` | `verae-netprint_*_amd64.deb` |
| Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` |
| Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` |
| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`/Applications/Verae NetPrint.app`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) |
| Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` |
| Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` |
| CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint |
| Config | `%ProgramData%\Verae\PrintStamp\netprint.json` | `/Library/Application Support/Verae/PrintStamp/netprint.json` | `/etc/verae/netprint.json` |
| Receipts | `%ProgramData%\Verae\NetPrint` | `/Users/Shared/Verae Receipts` | `/var/lib/verae-netprint` |
The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network printer does not use that port.
## Try it
1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login.
2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files.
3. On another device, add the printer. Its default name includes the computer, for example **Verae Timestamp (Georges-MacBook-Pro)**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print` and `kDNSServiceFlagsNoAutoRename`. mDNSResponder publishes the subtype PTRs aimed at `<name>._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If the name is already in use, the callback reports the conflict and both services are registered again as **Name (2)**. That name is saved and becomes the IPP printer name. If `DNSServiceRegister` fails for another reason, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `<name>._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `<name>._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG.
4. Print a PDF. Open **Receipts** from the tray, or browse to `http://<lan-address>:<port>/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`.
The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer.
## Email
Settings and the tray can mail each completed job. The message subject is `Verae timestamp: <job title>`. The body contains the receipt ID, the SHA-256, and the `.verae.json` receipt summary. The certificate PDF and the original document are attached. The default recipient is `marchon@gmail.com`, and the default server is `smtp.gmail.com` port 587 with STARTTLS. A Gmail app password works: the username is the Gmail address and the password is the 16-character app password. Mail stays off until the toggle is on and a password is saved. A failure is written to the log and to `emailError` in the status JSON, then retried in the background. It does not fail the print.
The SMTP password and the optional receipts PIN are not stored in `netprint.json`. On Windows they are sealed with DPAPI for the service account. On macOS and Linux they are in `netprint-secrets.bin` next to the config, mode `0600`. Neither value is written to the log.
## Receipts on the phone
`http://<host>:<port>/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://<hostname>.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer Customize pane has **Share receipts folder over the network (SMB)**, off unless you check it. When checked, postinstall shares that folder as **Verae Receipts** (`sharing -a` with `-s 001 -g 000`: SMB only, guest off) and enables `com.apple.smbd` so File Sharing stays on across reboots. The choice is saved as `shareReceiptsSMB` in `netprint.json`. Settings and the menu bar can turn it on or off later. If the choice is on and the share is missing, the daemon recreates it at startup, and the menu-bar app asks it to again at login. Status JSON includes `shareReceiptsSMB` and `smbUrl` (for example `smb://Georges-MacBook-Pro.local/Verae%20Receipts`). The receipts page shows that address. The first connection from iPhone Files needs a username and password from this Mac. Guest access is off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://<host>:<port>/ios-shortcut`.
## Stamp and return
`POST http://<host>:<port>/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as Print-Job, including the same `stampWithClient` call the desktop driver uses (`job.Run` against the Verae client, with the document title as the file name). The job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. A receipt id beginning with `mock-cert` is refused, so a test double is not returned outside test builds. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF.
The server also advertises `_verae._tcp` (PTR, SRV, and TXT `path=/api/stamp`) beside the IPP printer. That record is a different service name, so it does not add a second `_ipp._tcp` advertisement. While Windows DNS-SD owns the printer, the shared UDP socket still answers only the AirPrint subtype PTRs for `_ipp._tcp` and does not add IPP SRV, TXT, or address records. The `_verae._tcp` SRV and TXT go out on that socket because DNS-SD does not register that name. Address records stay with the printer advertisement.
The Share sheet steps are in `docs/ios-shortcut/README.md`. A signed `.shortcut` file is not in this tree: signing needs the Shortcuts app on a Mac or iPhone. The design for a native share extension in the Verae Timestamping iOS app (`com.flightlookup.verae-timestamping`) is `docs/ios-shortcut/share-extension.md`.
Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward does not fail the timestamp.
## Firewall
The Windows installers add inbound rules named **Verae NetPrint IPP** (TCP 631), **Verae NetPrint IPP Fallback** (TCP 8632), and **Verae NetPrint mDNS** (UDP 5353) on the private and domain profiles. Each rule sets `remoteip=any`, which includes IPv4, global IPv6, and link-local `fe80::/10`. A checkbox, off by default, also allows Public networks. A silent install can pass `/PUBLIC=1`. The tray has the same switch. The rules are removed when the last NetPrint product is uninstalled. Port 8631 is not opened; that is the driver helper on loopback.
An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Verae Timestamp** aimed at `http://127.0.0.1:<bound-port>/ipp/print`. It is removed when the last NetPrint product is uninstalled. The driver printer **Verae TimeStamp PDF** is a different queue.
On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw is installed and already active. The rules are removed when the last package is removed.
On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. The first time the service advertises or accepts a LAN connection, macOS asks once for Local Network permission for `verae-print`. Allow it. The menu-bar app may ask as well; allow that too.
## macOS package
The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. GitHub Actions builds it on a `macos-14` runner with `pkgbuild` and `productbuild`, then runs `pkgutil --expand`, `pkgutil --check-signature`, and `installer -pkginfo`. It installs:
- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 daemon binary)
- `/Applications/Verae NetPrint.app` (the menu-bar app: `Contents/Info.plist` with bundle id `ai.verae.netprint`, `LSUIElement`, and `NSLocalNetworkUsageDescription`; `Contents/MacOS/verae-print`, `Contents/MacOS/verae-menubar`, and `Contents/MacOS/verae-airdrop`)
- LaunchDaemon `com.verae.netprint` (root, the print server, the binary under Application Support)
- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `/Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray` as the logged-in user
Receipts and job files go to `/Users/Shared/Verae Receipts`, not next to those binaries. The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal:
```bash
sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target /
```
`postinstall` checks that `verae-print`, `verae-airdrop`, and `verae-menubar` are in the app, then runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there.
`verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, Share receipts folder over SMB, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts folder over the network (SMB)**, unselected unless you check it. That choice is saved as `shareReceiptsSMB`. The first connection from iPhone Files needs a username and password from this Mac.
## AirDrop on the Mac
Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API and opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet.
`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it inside `Verae NetPrint.app`, next to the tray executable. The menu-bar app launches that helper. macOS asks for notification permission the first time the agent posts **AirDrop again**. `UNUserNotificationCenter` is used only when the process is that app bundle (`bundleIdentifier` set and the bundle path ends in `.app`). A bare binary skips the call instead of crashing, because `currentNotificationCenter` throws when `mainBundle` is a directory.
The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer.
A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, `receiptsPinSet`, `shareReceiptsSMB`, and `smbUrl`. It does not include the SMTP password or the receipts PIN.
macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary.
## Troubleshooting
- The icon says stopped. Start it from the menu. On Linux, `systemctl status verae-netprint`. On Windows, `sc query VeraeNetPrint`. On macOS, `sudo launchctl print system/com.verae.netprint`.
- Other devices do not see it. Open `http://127.0.0.1:8630/api/v1/status` and read `mdnsMode` / `mdnsError`, and `%ProgramData%\Verae\logs\netprint.log` on Windows. `mdnsMode` `dns-sd` means Windows DNS-SD accepted the printer. Confirm the host has a LAN address and the network is Private, or turn on Allow Public networks. On a Mac, CUPS keeps localhost port 631. This printer uses 631 on the LAN addresses (`ippBind` `lan`) or 8632 when that bind does not succeed (`ippBind` `all`, `port` 8632). It does not fall back to 8631. Receipt files are in `receiptsDir`.
- Windows setup seemed to hang with `/S`. Every installer dialog has a silent default. Read `%ProgramData%\Verae\logs\netprint-installer.log`.
- Jobs fail with an authentication error. Open Settings and save a current `xVerae-` key. The service reads the machine token (`/etc/verae/xverae-token`, the macOS Application Support file, or `%ProgramData%\Verae\PrintStamp\xverae-token`) and the usual per-user token.
- Raster jobs (AirPrint `image/urf`, Linux `image/pwg-raster`) and `image/jpeg` are converted to PDF on this machine before they are hashed. PDF is accepted as-is.
Commands:
```bash
verae-print netprint # foreground, for debugging
verae-print netprint install --owner driver --allow-public=false
verae-print netprint install --owner standalone --allow-public=true --local-queue
verae-print netprint launch-tray # Windows: tray as the logged-in user
verae-print netprint uninstall --owner driver
verae-print tray
```

Some files were not shown because too many files have changed in this diff Show more