Compare commits

..
Author SHA1 Message Date
6f8796c25e Open Zap Billing when PrintIt stamps fail for credits
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
On HTTP 402/429 (and credit/billing error bodies), hold the print job and open
https://zap.verae.ai/portal/?tab=billing so the user can reload prepaid balance.
Throttle browser opens to once per 10 minutes. Auth failures still use the
sign-in flow unchanged.
2026-10-20 01:07:39 -04:00
7af670c4ef Publish PrintIt 0.3.6 downloads (macOS/Windows/Debian) + tray + Verify/hasher
Site index and DOWNLOADS-MANIFEST now list HelperVersion 0.3.6 artifacts on
printit.verae.ai/downloads: signed .pkg, NSIS setup.exe, .deb, linux tarballs,
macOS menubar zip, Windows tray scripts, Verae Timestamping.app 0.2.1, and
verae-hasher binaries for win/linux/mac.
2026-10-20 00:56:02 -04:00
d82d0a59cf fix(macos-pkg): dark-mode contrast, RTF Agree, sidebar brand clip
Screenshots (Desktop 5.25–5.27 AM) showed:
- Light purple/white chips and table headers unreadable on dark Installer WebKit
- License page only Continue (HTML license); no Agree/Disagree
- Left background text truncated ("Verae Print Stam", cut tagline)

Fixes:
- welcome/readme/conclusion/license HTML: color-scheme + prefers-color-scheme dark,
  high-contrast chips/tables/boxes (no white-on-white)
- license.rtf primary in Distribution (Installer Agree/Disagree)
- background.png / background-dark.png: left plum column + logo, short labels only
- smoke-installer-ui.sh checks RTF + dark-mode CSS (19 PASS)

Rebuild: SKIP_SIGN=1 ./platform/macos/pkg/build-pkg.sh
2026-10-14 05:31:07 -04:00
78cc3a40ec fix(macos-pkg): product ID, Verae branding, license Agree in installer
The product .pkg was a bare component wrapper with no Installer.app UI.

- Distribution.xml.in: title Verae Print Stamp, org ai.verae, product id
- resources: welcome/license/readme/conclusion HTML + plum brand backgrounds
- license.html requires Agree/Disagree before install continues
- readme.html product identification table (bundle/pkg IDs, version, publisher)
- App bundle: AppIcon.icns, VeraeProductID/VeraeBrand keys, PRODUCT.txt sidecar
- build-pkg.sh uses productbuild --distribution --resources
- SKIP_SIGN=1 for local UI smoke; smoke-installer-ui.sh (15 checks)

Verify: SKIP_SIGN=1 ./platform/macos/pkg/build-pkg.sh
        ./platform/macos/pkg/smoke-installer-ui.sh
2026-10-14 05:25:28 -04:00
41e079a0ad docs: PR body for windows-debug (MSI/CERT/statuslang)
Summarize MSI dual-ship, CERT-01, and statuslang work for the
windows-debug PR. Links OPERATOR-READY and DUAL-SHIP as canonical
operator paths; external sign/notary/VM legs stay org-blocked.
2026-10-14 04:33:41 -04:00
d822137bbb fix: align deb/site to HelperVersion 0.3.6 (version audit)
todo-list-3 version-audit: fix stale 0.3.4 defaults in build-deb.sh and
printit index deb blurb; document audit in PACKAGING; smoke asserts deb
fallback. CDN last-published 0.3.2/0.1.2 checksums left intentional.
2026-10-14 04:33:32 -04:00
0efe762a23 docs(cert): OFL fonts READY package + CERT-01 pixel checklist
- Add fonts/READY-PACKAGE.md: exact woff2/ttf names, OFL license steps,
  pixel-match checklist vs verae-2026-design CERT-01.html/png
- Cross-link README, LICENSE-FONTS, fetch-fonts.example.sh status
- certificate.html residual points at READY-PACKAGE (ops drop-in)

Software path unchanged; go test pdfcert + statuslang PASS.
Binaries remain operator deploy-host only (not committed).
2026-10-14 04:17:44 -04:00
778763737e docs: OPERATOR-READY package for MSI/notary external legs
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Consolidate Authenticode, Apple notary, VM C, and digest-paste into
platform/windows/wix/OPERATOR-READY.md (runbook + commands + secret
names only). Cross-link from CI-SIGN-NOTARY, VM-C-ONE-PAGER, PACKAGING,
and COMMERCIAL-INSTALLER-BACKLOG. Live enable remains blocked only on
org secret store / clean VM — no secret values invented.
2026-10-14 04:17:07 -04:00
9d17dff73d docs: commercial MSI/notary dry-run smoke in PACKAGING verification
Issue 1 software path is complete in-repo (full MSI StageFull, dual-ship-
smoke, sign/notary dry-runs, fill-manifest, CI unsigned + sign example,
PUBLISH-DIGESTS 0.3.6 TBD). Document the offline dry-run commands under
PACKAGING.md Verification so unit/script smoke is discoverable next to the
day-to-day packaging checks. Live Authenticode/notary/VM C remain external.
2026-10-14 01:57:51 -04:00
a15db6ffc7 docs: VM-C-ONE-PAGER single-page dual-ship printer CA unlock path
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
enable-printer-cas → build-full-msi → dual-ship-smoke → sign-dry-run → fill-manifest.
2026-10-14 01:30:49 -04:00
3140e6c76f feat: PUBLISH-DIGESTS.md + commented Authenticode CI paths + EmbedBrandFonts TTF
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
windows-msi-sign.yml.example; gofpdf register when EmbedBrandFonts and fonts present.
2026-10-14 01:24:11 -04:00
9f0881ccfc feat: notary-dry-run.sh for macOS menubar (no submit without NOTARY_SUBMIT=1)
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Prints notarytool recipe with secret env names; links CI and backlog.
2026-10-14 01:08:18 -04:00
6090b41f8f feat: sign-dry-run.ps1 + CERT LICENSE-FONTS + EmbedBrandFonts default off
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Signtool verify/print sign recipe without secrets; PDF brand embed opt-in only.
2026-10-14 00:55:24 -04:00
86809b27eb feat: enable-printer-cas.ps1 unlocks deferred printer CAs for VM C
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Toggles Product-Full markers; dual-ship-smoke reports C blocked until enabled.
2026-10-14 00:47:09 -04:00
376bbe188a feat: dual-ship-smoke.ps1 offline VM A/B/C runner with optional artifact hashes
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Prints checklist steps; -VerifyArtifacts uses fill-manifest-sha256.
2026-10-14 00:39:21 -04:00
da4a0dc4e2 feat: fill-manifest-sha256.ps1 + CERT local font-face first
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Hash helper for DOWNLOADS-MANIFEST; certificate.html prefers fonts/*.woff2 when present.
2026-10-14 00:27:11 -04:00
042342bd69 docs: DOWNLOADS-MANIFEST 0.3.6 TBD + VM-DUAL-SHIP-CHECKLIST + CERT spacing
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Manifest placeholders until signed upload; printable VM A/B/C sheet.
2026-10-14 00:20:58 -04:00
dcee1918dc docs: thin-NSIS exit checklist + menubar ci-notarize.yml.example
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
DUAL-SHIP exit criteria A–E; notary CI sketch with secret names only.
2026-10-14 00:13:54 -04:00
1d6ce81a54 ci: unsigned windows-msi workflow + DUAL-SHIP CI artifact section
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
GitHub Actions builds full MSI when WiX present; sign step commented (secret names only).
2026-10-14 00:06:41 -04:00
1e0152c7af docs: DUAL-SHIP runbook + deferred CA stubs in Product-Full.wxs
NSIS+MSI validation matrix; site version 0.3.6 string; no Authenticode claim.
2026-10-13 23:56:39 -04:00
7768562517 feat: P0 build-full-msi.ps1 StageFull candle/light path
Product-Full File components; HelperVersion 0.3.6 defaults; no Authenticode claim.
2026-10-13 23:48:59 -04:00
8a8db16da1 feat: TODO-7 A1/A7 — Product-Full.wxs stub, CI-SIGN-NOTARY, CERT spacing + font fetch example
Commercial installer and CERT-01 layout next slice; no elevated MSI claim.
2026-10-13 23:38:34 -04:00
46fa1aa0d4 feat: MSI full outline, silent props, HelperVersion NSIS sync, CERT fonts plan
TODO-6 L1/I1/L7/I7 — commercial + CERT next-level scaffolds.
2026-10-13 23:23:45 -04:00
f766e537ba feat: WiX ProductVersion from HelperVersion + build-tray-msi + CERT live QR
- build-tray-msi.ps1 candle/light or tip; version from common.ps1
- certificate.html final QR via qrcode CDN when digest present
2026-10-13 23:08:55 -04:00
b72324edc4 feat: commercial installer scaffold — WiX tray MSI skeleton + notarize checklist
Non-elevated tray shortcuts wxs; NOTARIZE-CHECKLIST; backlog scaffold section.
2026-10-13 22:25:50 -04:00
14132e1782 feat: CERT Fraunces title fonts and statuslang Label tests
- certificate.html Fraunces CDN + print fallbacks
- TestLabel covers all brand chip states
2026-10-13 22:03:30 -04:00
1b357f6e87 docs: COMMERCIAL-INSTALLER-BACKLOG for MSI and notarized macOS 2026-10-13 21:52:19 -04:00
5cfb92c82a docs+site: CERT-01 web certificate viewer and packaging links
- certificate.html brand kit preview with multihash when present
- PACKAGING.md CERT section residual
2026-10-13 21:50:36 -04:00
c2c87274be docs: PACKAGING.md for tray, menubar, CERT, and statuslang
Document HelperVersion packaging paths and evidence rules.
2026-10-13 21:46:33 -04:00
66900f3951 chore: stop tracking verae-print binary 2026-10-13 21:18:46 -04:00
b342b9235e fix: prefer go.mod tree for packaging paths; CERT residual tests
- Avoid research checkout shadowing logo/script lookup
- Strengthen packaging/CERT tests for multihash rules
2026-10-13 21:18:23 -04:00
48da24a661 feat: 2026 brand UI — statuslang, tray/menubar, CERT pdf, site kit
- Brand receipt status strings (statuslang) for notify/queue
- Windows tray + Start Menu/Startup install; macOS menubar helper
- CERT-01 plum/gold PDF + multihash/blake3 when present
- printit.verae.ai brand kit, queue page, customer-auth helper
- verae-print pending/tray-status/menubar commands
2026-10-13 21:11:32 -04:00
10814f0bfb docs: Standard Readme update for production board pack 2026-10-12 19:24:33 -04:00
dd46ad151f feat: bill PrintIt only through zap.verae.ai by default
Default channel is zapier (xVerae-). Direct api.veraetime.net requires
VERAE_ALLOW_DIRECT_VERAE=1. Send x-billing-channel=printit on stamps.
2026-10-12 14:59:36 -04: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
128 changed files with 14361 additions and 1273 deletions

View file

@ -1,24 +0,0 @@
name: test-and-deploy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: 138
steps:
- uses: actions/checkout@v4
- name: Unit tests
run: bash scripts/test-138.sh
deploy-138:
needs: test
if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: 138
steps:
- uses: actions/checkout@v4
- name: Deploy the host checkout
run: bash scripts/deploy-138.sh

View file

@ -0,0 +1,164 @@
# EXAMPLE ONLY — not a live workflow.
# Copy into .github/workflows/windows-msi-sign.yml (or .forgejo/workflows/) and enable
# only when org secret store holds the names below. Default product CI remains
# windows-msi.yml (unsigned full MSI).
#
# Secret / var NAMES only — never commit PFX bytes, passwords, thumbprints as values,
# or Azure client secrets. See platform/windows/wix/CI-SIGN-NOTARY.md.
#
# GitHub Actions: secrets.* / vars.* as below.
# Forgejo Actions: same secret names; map runs-on to your Windows label; keep names identical.
#
# After sign + upload: docs/PUBLISH-DIGESTS.md + fill-manifest-sha256.ps1 (hash only; no upload).
#
# name: windows-msi-sign
# on:
# workflow_dispatch:
# # push:
# # tags: ["printstamp-v*"]
name: windows-msi-sign-example
on:
workflow_dispatch:
permissions:
contents: read
# id-token: write # uncomment if using Azure OIDC → Trusted Signing (no client secret)
jobs:
# Live default stays unsigned — see windows-msi.yml.
# This example job is gated off until secrets exist.
full-msi-sign:
if: ${{ false }} # flip only after secrets exist; do not claim SmartScreen until clean-VM proof
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install WiX Toolset v3
shell: pwsh
run: |
choco install wixtoolset --version 3.14.1 -y --no-progress
$wixBin = "${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin"
if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) {
$found = Get-ChildItem -Path "${env:ProgramFiles(x86)}" -Filter "candle.exe" -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match 'WiX Toolset' } |
Select-Object -First 1
if ($found) { $wixBin = $found.Directory.FullName }
}
if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) {
Write-Error "WiX candle.exe not found after install."
}
"WIX_BIN=$wixBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PATH=$wixBin;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build full MSI (pre-sign)
shell: pwsh
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File platform\windows\scripts\build-full-msi.ps1 -Arch amd64
$msi = Get-ChildItem -Path platform\windows\wix\out -Filter "VeraePrintStamp-windows-*.msi" |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $msi) { Write-Error "No MSI under platform/windows/wix/out" }
"MSI_PATH=$($msi.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSI_NAME=$($msi.Name)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$stage = "platform\windows\wix\StageFull\bin"
"STAGE_BIN=$stage" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# --- Path A: PFX via secrets (signtool) ---
# Secret / var names (placeholders — values never in git):
# secrets.WINDOWS_CERT_PFX_BASE64
# secrets.WINDOWS_CERT_PFX_PASSWORD
# secrets.WINDOWS_CERT_THUMBPRINT # optional if /sha1 preferred after import
# vars.WINDOWS_SIGN_TIMESTAMP_URL # RFC 3161 TSA
#
# - name: Import code-signing PFX
# shell: pwsh
# env:
# WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
# WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }}
# run: |
# $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
# [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64))
# $pwd = ConvertTo-SecureString $env:WINDOWS_CERT_PFX_PASSWORD -AsPlainText -Force
# Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null
# Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue
# # Never Upload-Artifact the PFX; never Write-Host password or base64.
#
# - name: signtool Authenticode (EXEs + MSI)
# shell: pwsh
# env:
# WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }}
# WINDOWS_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CERT_THUMBPRINT }}
# run: |
# $tsa = $env:WINDOWS_SIGN_TIMESTAMP_URL
# if (-not $tsa) { $tsa = "https://timestamp.digicert.com" }
# $signtool = (Get-Command signtool.exe -ErrorAction Stop).Source
# $targets = @(
# (Join-Path $env:STAGE_BIN "verae-print.exe"),
# (Join-Path $env:STAGE_BIN "verae-printhelper.exe"),
# $env:MSI_PATH
# ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
# foreach ($t in $targets) {
# if ($env:WINDOWS_CERT_THUMBPRINT) {
# & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /sha1 $env:WINDOWS_CERT_THUMBPRINT $t
# } else {
# & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /a $t
# }
# if ($LASTEXITCODE -ne 0) { Write-Error "signtool sign failed: $t" }
# & $signtool verify /pa $t
# if ($LASTEXITCODE -ne 0) { Write-Error "signtool verify failed: $t" }
# }
# --- Path B: Azure Trusted Signing (prefer over PFX when available) ---
# Secret / var names (placeholders — values never in git):
# secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID # or OIDC; skip client secret
# secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID
# secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET # omit when id-token: write + federated creds
# vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT # e.g. https://cus.codesigning.azure.net
# vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT
# vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE
# vars.WINDOWS_SIGN_TIMESTAMP_URL # optional; action may default TSA
#
# - name: Azure Trusted Signing (EXEs + MSI)
# # uses: azure/trusted-signing-action@v0 # pin a reviewed tag when enabling
# # with:
# # azure-tenant-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID }}
# # azure-client-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID }}
# # azure-client-secret: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET }}
# # endpoint: ${{ vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT }}
# # trusted-signing-account-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT }}
# # certificate-profile-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
# # files-folder: ${{ env.STAGE_BIN }}
# # files-folder-filter: exe
# # files-folder-recurse: true
# # file-digest: SHA256
# # timestamp-rfc3161: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }}
# # timestamp-digest: SHA256
# shell: pwsh
# run: |
# Write-Error "Trusted Signing path not enabled — wire action + secret names above."
# - name: Upload signed MSI artifact
# uses: actions/upload-artifact@v4
# with:
# name: VeraePrintStamp-windows-msi-signed
# path: platform/windows/wix/out/VeraePrintStamp-windows-*.msi
# if-no-files-found: error
# retention-days: 14
# Operator after download: upload CDN blob → fill-manifest-sha256.ps1 → paste digests.
# See docs/PUBLISH-DIGESTS.md (does not upload; secret values never land in manifests).
- name: Placeholder (example not enabled)
shell: pwsh
run: |
Write-Host "windows-msi-sign.yml.example — sign job gated if: false."
Write-Host "Live unsigned build: .github/workflows/windows-msi.yml"
Write-Host "Local dry-run: platform/windows/scripts/sign-dry-run.ps1"
Write-Host "Digests after sign+upload: docs/PUBLISH-DIGESTS.md"

164
.github/workflows/windows-msi.yml vendored Normal file
View file

@ -0,0 +1,164 @@
# Unsigned full elevated MSI (Product-Full.wxs + build-full-msi.ps1).
# Artifact is NOT Authenticode-signed. Sign step is commented; secret names only.
# See platform/windows/wix/CI-SIGN-NOTARY.md and platform/windows/wix/DUAL-SHIP.md § CI artifact.
name: windows-msi
on:
workflow_dispatch:
push:
paths:
- "platform/windows/**"
- "cmd/**"
- "internal/**"
- "go.mod"
- "go.sum"
- ".github/workflows/windows-msi.yml"
pull_request:
paths:
- "platform/windows/**"
- "cmd/**"
- "internal/**"
- "go.mod"
- "go.sum"
- ".github/workflows/windows-msi.yml"
permissions:
contents: read
jobs:
full-msi-unsigned:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install WiX Toolset v3
shell: pwsh
run: |
choco install wixtoolset --version 3.14.1 -y --no-progress
$wixBin = "${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin"
if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) {
# Fallback: any WiX v3.* bin on Program Files
$found = Get-ChildItem -Path "${env:ProgramFiles(x86)}" -Filter "candle.exe" -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match 'WiX Toolset' } |
Select-Object -First 1
if ($found) { $wixBin = $found.Directory.FullName }
}
if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) {
Write-Error "WiX candle.exe not found after install. Full MSI job requires WiX v3."
}
"WIX_BIN=$wixBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PATH=$wixBin;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "WiX bin: $wixBin"
& (Join-Path $wixBin "candle.exe") -?
- name: Build full MSI (unsigned)
shell: pwsh
run: |
$candle = Get-Command candle -ErrorAction SilentlyContinue
$light = Get-Command light -ErrorAction SilentlyContinue
if (-not $candle -or -not $light) {
Write-Error "WiX candle/light not on PATH — refusing unsigned MSI build (fail closed)."
}
powershell -NoProfile -ExecutionPolicy Bypass -File platform\windows\scripts\build-full-msi.ps1 -Arch amd64
$msi = Get-ChildItem -Path platform\windows\wix\out -Filter "VeraePrintStamp-windows-*.msi" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $msi) {
Write-Error "build-full-msi.ps1 did not produce VeraePrintStamp-windows-*.msi under platform/windows/wix/out"
}
Write-Host "MSI: $($msi.FullName) ($([math]::Round($msi.Length/1MB, 2)) MB)"
Get-FileHash -Algorithm SHA256 -Path $msi.FullName | Format-List
"MSI_PATH=$($msi.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSI_NAME=$($msi.Name)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# --- Future Authenticode (NOT enabled) ---
# Wire only after certs exist in the org secret store. Never commit PFX or passwords.
# Full copy-paste example (gated if: false): .github/workflows/windows-msi-sign.yml.example
# Local dry-run (no secrets): platform/windows/scripts/sign-dry-run.ps1
# After sign + CDN upload: docs/PUBLISH-DIGESTS.md + fill-manifest-sha256.ps1
# Secret / var names (placeholders only — see CI-SIGN-NOTARY.md):
# secrets.WINDOWS_CERT_PFX_BASE64
# secrets.WINDOWS_CERT_PFX_PASSWORD
# secrets.WINDOWS_CERT_THUMBPRINT # optional /sha1 after import
# vars.WINDOWS_SIGN_TIMESTAMP_URL # RFC 3161 TSA
# secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID # Path B: Azure Trusted Signing
# secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID
# secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET # omit when OIDC id-token: write
# vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT
# vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT
# vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE
#
# --- Path A: PFX → signtool (commented; secret NAMES only) ---
# - name: Import code-signing PFX
# if: ${{ false }}
# shell: pwsh
# env:
# WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
# WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }}
# run: |
# $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
# [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64))
# $pwd = ConvertTo-SecureString $env:WINDOWS_CERT_PFX_PASSWORD -AsPlainText -Force
# Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null
# Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue
# # Never Upload-Artifact the PFX; never echo password or base64.
#
# - name: signtool Authenticode (EXEs + MSI)
# if: ${{ false }}
# shell: pwsh
# env:
# WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }}
# WINDOWS_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CERT_THUMBPRINT }}
# run: |
# $tsa = $env:WINDOWS_SIGN_TIMESTAMP_URL
# if (-not $tsa) { $tsa = "https://timestamp.digicert.com" }
# $signtool = (Get-Command signtool.exe -ErrorAction Stop).Source
# $stage = "platform\windows\wix\StageFull\bin"
# $targets = @(
# (Join-Path $stage "verae-print.exe"),
# (Join-Path $stage "verae-printhelper.exe"),
# $env:MSI_PATH
# ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
# foreach ($t in $targets) {
# if ($env:WINDOWS_CERT_THUMBPRINT) {
# & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /sha1 $env:WINDOWS_CERT_THUMBPRINT $t
# } else {
# & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /a $t
# }
# if ($LASTEXITCODE -ne 0) { Write-Error "signtool sign failed: $t" }
# & $signtool verify /pa $t
# if ($LASTEXITCODE -ne 0) { Write-Error "signtool verify failed: $t" }
# }
#
# --- Path B: Azure Trusted Signing (prefer over PFX when available) ---
# - name: Azure Trusted Signing (EXEs + MSI)
# if: ${{ false }}
# # uses: azure/trusted-signing-action@v0 # pin a reviewed tag when enabling
# # with:
# # azure-tenant-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID }}
# # azure-client-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID }}
# # azure-client-secret: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET }}
# # endpoint: ${{ vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT }}
# # trusted-signing-account-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT }}
# # certificate-profile-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
# # files: ${{ env.MSI_PATH }}
# # file-digest: SHA256
# # timestamp-rfc3161: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }}
# # timestamp-digest: SHA256
# shell: pwsh
# run: |
# Write-Error "Trusted Signing not enabled — unsigned artifact only. See windows-msi-sign.yml.example."
- name: Upload unsigned MSI artifact
uses: actions/upload-artifact@v4
with:
name: VeraePrintStamp-windows-msi-unsigned
path: platform/windows/wix/out/VeraePrintStamp-windows-*.msi
if-no-files-found: error
retention-days: 14

6
.gitignore vendored
View file

@ -7,3 +7,9 @@
.DS_Store
node_modules/
/build/
Verae-Secret-Credentials.MD
# Compiled PT-06 menu bar (built by run-menubar.sh / swiftc)
platform/macos/menubar/verae-menubar
verae-print
platform/macos/pkg/resources/AppIcon.iconset/
build/macos-pkg/

162
README.md
View file

@ -1,136 +1,66 @@
# PrintToVeraeTimeStamp
Print / CLI helper that saves `{name}.pdf`, stamps the digest with Verae, and writes `{name}-verae-timestamp-{RECEIPTID}.pdf`.
[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
Only the SHA-256 digest is sent to Verae. Document bytes never leave the machine.
PrintIt desktop/CLI path: stamp documents via Verae edge billing (xVerae keys).
## Table of Contents
- [Background](#background)
- [Install](#install)
- [Usage](#usage)
- [Packaging](#packaging)
- [Contributing](#contributing)
- [License](#license)
## Background
Default channel is zapier (zap.verae.ai) with x-billing-channel=printit.
Desktop and print workflows share the same commercial meter as portal API customers.
**Co-contributors:** Grok (xAI), George Lambert, Scott "R00tPiG Security" Lindsey — Verae Inc. ([verae.com](https://verae.com)).
## Install
Downloads: [printit.verae.ai](https://printit.verae.ai/) (macOS `.pkg`, Windows `.zip`, Linux `.tar.gz`).
**macOS (recommended):** open `VeraePrintStamp-macos.pkg`. First launch opens **customer.verae.ai** for an **xVerae-** signing key.
**Windows (GUI installer):**
```text
Download VeraePrintStamp-windows-amd64-setup.exe from printit.verae.ai
Run the installer → Finish with “Run setup” → paste your xVerae- key
verae-print stamp .\document.pdf
```
**Debian / Ubuntu:**
```bash
sudo apt install ./verae-print_amd64.deb
verae-print setup
verae-print stamp document.pdf
```
**Linux (tarball, any distro):**
```bash
tar xzf VeraePrintStamp-linux-amd64.tar.gz
cd VeraePrintStamp-linux-amd64-*
sudo ./install.sh # CLI + optional CUPS queue
verae-print setup
verae-print stamp document.pdf
```
From source (macOS):
```bash
cd /Users/marchon/research/PrintToVeraeTimeStamp
go build -o "$HOME/bin/verae-print" ./cmd/verae-print
verae-print setup # create account + save xVerae- signing key
verae-print install-printer # Print → Destination → Verae Timestamping
```
Optional (buried PDF submenu): `verae-print install-macos`
Ensure `$HOME/bin` is on your `PATH`. For the legacy Verae Time JWT channel:
```bash
uvx verae-mcp-timestamping-server setup
# or session check:
uvx verae-mcp-timestamping-server session
```
For the Zapier channel, store an `xVerae-` token from [customer.verae.ai](https://customer.verae.ai/portal/) / [zap.verae.ai](https://zap.verae.ai/portal/) Security tab:
```bash
mkdir -p ~/.verae
chmod 700 ~/.verae
printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once
chmod 600 ~/.verae/xverae-token
```sh
go build ./...
```
## Usage
In any app: **File → Print → Destination → Verae Timestamping → Print**.
Also: drop a PDF on **Verae Print Stamp** in `/Applications`, or **Open With**.
CLI:
```bash
verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] file.pdf
```sh
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
```
Default output directory: `~/Documents/Verae Timestamps/`
## Packaging
| File | Content |
|------|---------|
| `{name}.pdf` | Exact printed bytes (proven artifact) |
| `{name}-verae-timestamp-{RECEIPTID}.pdf` | Registration / certificate PDF |
| `{name}.verae.json` | Receipt sidecar |
Tray, menu bar, CERT-01 digests, version stamps (`HelperVersion` **0.3.6**), and IPP GET vs POST split:
Channels:
- `zapier` (default after setup) — `zap.verae.ai` + customer `xVerae-` signing key + `attachPdf:true`
- `verae` — `api.veraetime.net` + JWT in `~/.verae/auth.properties` (opt-in: `-channel=verae`)
## Signed macOS installer (.pkg)
Same FlightLookup Developer ID + notarization path as Verae Timestamping.app (`~/fie/installers/macos/signing.env`).
```bash
cd /Users/marchon/research/PrintToVeraeTimeStamp
chmod +x platform/macos/pkg/build-pkg.sh
./platform/macos/pkg/build-pkg.sh
# → build/macos-pkg/dist/VeraePrintStamp-0.1.2.pkg
# → build/macos-pkg/dist/VeraePrintStamp-macos.pkg
```
The pkg installs:
| Path | Role |
|------|------|
| `/Applications/Verae Print Stamp.app` | Drop / Open With |
| `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | Stamp CLI (universal) |
| `/usr/local/bin/verae-print` | Symlink |
| LaunchAgent + CUPS queue | **Print → Destination → Verae Timestamping** |
Requires: `Developer ID Application` + `Developer ID Installer` (FlightLookup), App Store Connect API key in `signing.env`, Node for bundling the registration PDF builder.
## Windows / Linux release archives
```bash
./scripts/build-win-linux.sh
./platform/windows/build-installer.sh # NSIS GUI setup.exe (needs makensis)
# on Debian/Ubuntu host:
VERSION=0.1.2 BIN_AMD64=... BIN_ARM64=... ./platform/linux/deb/build-deb.sh all
```
Windows ships an NSIS GUI installer; stamp via CLI after setup (virtual printer still on the [spec](docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md) roadmap). Debian/Ubuntu use `verae-print_*.deb`; other distros use the tarball + `install.sh`.
## Status
macOS Destination printer (notarized `.pkg`), Windows/Linux CLI packages, and branded registration PDF are published at [printit.verae.ai](https://printit.verae.ai/).
- [docs/PACKAGING.md](docs/PACKAGING.md) — Windows tray (no elevated MSI), macOS LaunchAgent / unsigned `.app` / notarization (docs only), `pending` / `tray-status` / `menubar`, statuslang notify titles, hold ≠ evidence, CERT-01 web viewer.
- [site/printit.verae.ai/certificate.html](site/printit.verae.ai/certificate.html) — CERT-01 branded certificate HTML (plum/gold, multihash when present).
## Contributing
Open questions and design changes in this repository. Pull requests are accepted for review against the spec.
Questions: open an issue on the project Forgejo/GitHub remote, or contact Verae engineering.
PRs are accepted when they include tests and do not leak secrets.
## License
UNLICENSED © Verae / George Lambert
UNLICENSED © Verae Inc. (verae.com)

View file

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

View file

@ -0,0 +1,7 @@
//go:build !windows
package main
import "os/exec"
func hideWindow(c *exec.Cmd) {}

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

@ -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,37 @@ 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 && isBillingFailure(err) {
title := *name
if title == "" {
title = filepath.Base(pdfPath)
}
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
if herr != nil {
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Add funds: %s\n", auth.ZapBillingURL)
_ = openBrowser(auth.ZapBillingURL)
return 1
}
lj.Printf("stamp: out of credits (%v); job HELD as pending %s — opening Billing", err, m.ID)
fmt.Printf("Out of credits; the job is saved (pending %s). Opening Billing so you can add funds:\n %s\n", m.ID, auth.ZapBillingURL)
notify(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", title, m.ID)))
handleBillingFailure(billingReason(err))
return 0
}
if err != nil {
lj.Errorf("stamp failed: %v", err)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
failTitle := *name
if failTitle == "" {
failTitle = filepath.Base(pdfPath)
}
notify(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", failTitle, err.Error()))
return 1
}
lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF)
@ -235,8 +272,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
}
@ -266,14 +306,40 @@ func runInstallPrinter() int {
}
func repoRootHint() string {
// Prefer known research path; else cwd
candidates := []string{
"/Users/marchon/research/PrintToVeraeTimeStamp",
".",
// Prefer the go.mod tree that owns this binary (A4 packaging source of truth).
// No machine-specific absolute paths — walk cwd and exe parents.
var starts []string
if wd, err := os.Getwd(); err == nil {
starts = append(starts, wd)
}
for _, c := range candidates {
if st, err := os.Stat(filepath.Join(c, "platform", "macos", "ipp", "install-ipp-printer.sh")); err == nil && !st.IsDir() {
return c
starts = append(starts, exeDirHint())
if exe := exeDirHint(); exe != "." {
starts = append(starts, filepath.Join(exe, ".."), filepath.Join(exe, "..", ".."))
}
starts = append(starts, ".")
seen := map[string]bool{}
for _, start := range starts {
dir, err := filepath.Abs(start)
if err != nil {
continue
}
for i := 0; i < 8; i++ {
if seen[dir] {
break
}
seen[dir] = true
if fileExists(filepath.Join(dir, "go.mod")) {
// Any go.mod with platform/ is good enough for install scripts.
if fileExists(filepath.Join(dir, "platform")) ||
fileExists(filepath.Join(dir, "internal", "job", "job.go")) {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
return "."

584
cmd/verae-print/menubar.go Normal file
View file

@ -0,0 +1,584 @@
package main
// PT-W3 / PT-06 tray / menu-bar launcher.
// Ships a working presence without a multi-month native C++ tray:
// Windows → platform/windows/scripts/verae-tray.ps1 (NotifyIcon + /status poll)
// → Startup shortcut via menubar --install (or install-tray-startup.ps1)
// → Start Menu folder via build-tray-startmenu.ps1 (WiX/NSIS-free stub)
// macOS → platform/macos/menubar/run-menubar.sh (NSStatusItem via Swift)
// → durable copy under ~/Library/Application Support/Verae/menubar
// → LaunchAgent via menubar --install; optional .app via build-menubar-app.sh
// IPP POST stays pure; this only opens scripts that GET /status and /queue.
import (
"flag"
"fmt"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const (
menubarLaunchLabel = "com.verae.timestamping.menubar"
winStartupLnkName = "Verae Tray.lnk"
)
// runMenubar starts the platform tray/menu-bar helper, or prints install hints.
func runMenubar(args []string) int {
fs := flag.NewFlagSet("menubar", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
addr := fs.String("addr", defaultHelperAddr(), "helper loopback address for /status and /queue")
install := fs.Bool("install", false, "install at login (Windows Startup shortcut; macOS LaunchAgent)")
uninstall := fs.Bool("uninstall", false, "remove login install (Windows Startup shortcut; macOS LaunchAgent)")
foreground := fs.Bool("foreground", false, "run attached (default: detach on Windows/macOS)")
if err := fs.Parse(args); err != nil {
return 2
}
_ = os.Setenv("VERAE_IPP_ADDR", *addr)
if *uninstall {
return menubarUninstall()
}
if *install {
return menubarInstall(*addr)
}
switch runtime.GOOS {
case "windows":
return startWindowsTray(*addr, *foreground)
case "darwin":
return startDarwinMenubar(*addr, *foreground)
default:
fmt.Fprintln(os.Stderr, "menubar: no tray script on this OS — use: verae-print tray-status --open")
fmt.Fprintln(os.Stderr, " loopback: GET http://"+*addr+"/queue and GET http://"+*addr+"/status")
return runTrayStatus([]string{"--open", "--addr", *addr})
}
}
func startWindowsTray(addr string, foreground bool) int {
script, err := locateWindowsTrayScript()
if err != nil {
fmt.Fprintf(os.Stderr, "menubar: verae-tray.ps1 not found (%v)\n", err)
fmt.Fprintln(os.Stderr, "fallback: verae-print tray-status --open")
return runTrayStatus([]string{"--open", "--addr", addr})
}
ps := windowsPowerShell()
args := []string{
"-NoProfile", "-ExecutionPolicy", "Bypass", "-STA",
"-File", script,
"-Addr", addr,
}
cmd := exec.Command(ps, args...)
if foreground {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "menubar: tray exited: %v\n", err)
return 1
}
return 0
}
hideWindow(cmd)
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "menubar: start tray: %v\n", err)
return 1
}
fmt.Printf("Verae tray started (pid %d) · polls http://%s/status · queue http://%s/queue\n", cmd.Process.Pid, addr, addr)
fmt.Println("Install at login: verae-print menubar --install")
fmt.Println(statuslang.EvidenceNote)
return 0
}
func startDarwinMenubar(addr string, foreground bool) int {
script, err := locateDarwinMenubarScript()
if err != nil {
fmt.Fprintf(os.Stderr, "menubar: run-menubar.sh not found (%v)\n", err)
fmt.Fprintln(os.Stderr, "fallback: verae-print tray-status --open")
return runTrayStatus([]string{"--open", "--addr", addr})
}
_ = os.Chmod(script, 0o755)
cmd := exec.Command("/bin/bash", script)
cmd.Env = append(os.Environ(), "VERAE_IPP_ADDR="+addr)
if foreground {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "menubar: %v\n", err)
return 1
}
return 0
}
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "menubar: start: %v\n", err)
return 1
}
fmt.Printf("Verae menu bar started (pid %d) · polls http://%s/status · queue http://%s/queue\n", cmd.Process.Pid, addr, addr)
fmt.Println("Install at login: verae-print menubar --install")
fmt.Println(statuslang.EvidenceNote)
return 0
}
func menubarInstall(addr string) int {
switch runtime.GOOS {
case "windows":
return menubarInstallWindows(addr)
case "darwin":
return menubarInstallDarwin(addr)
default:
fmt.Fprintln(os.Stderr, "menubar --install: no tray packaging on this OS")
fmt.Fprintln(os.Stderr, " use: verae-print tray-status --open")
return 2
}
}
func menubarUninstall() int {
switch runtime.GOOS {
case "windows":
return menubarUninstallWindows()
case "darwin":
return menubarUninstallDarwin()
default:
fmt.Fprintln(os.Stderr, "menubar --uninstall: no tray packaging on this OS")
return 2
}
}
// --- Windows Startup shortcut ------------------------------------------------
func menubarInstallWindows(addr string) int {
tray, err := locateWindowsTrayScript()
if err != nil {
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
return 1
}
installer, err := locateWindowsStartupInstaller()
if err == nil {
ps := windowsPowerShell()
cmd := exec.Command(ps,
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", installer,
"-Addr", addr,
"-TrayScript", tray,
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
return 1
}
return 0
}
// Fallback without install-tray-startup.ps1: create .lnk via PowerShell COM.
if err := writeWindowsStartupShortcut(tray, addr); err != nil {
fmt.Fprintf(os.Stderr, "menubar --install: %v\n", err)
return 1
}
fmt.Println("Installed Startup shortcut:", windowsStartupShortcutPath())
fmt.Println("Tray starts at next login. Run now: verae-print menubar")
fmt.Println(statuslang.EvidenceNote)
return 0
}
func menubarUninstallWindows() int {
installer, err := locateWindowsStartupInstaller()
if err == nil {
ps := windowsPowerShell()
cmd := exec.Command(ps,
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", installer,
"-Uninstall",
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "menubar --uninstall: %v\n", err)
return 1
}
return 0
}
path := windowsStartupShortcutPath()
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println("Removed Startup shortcut:", path)
return 0
}
func windowsStartupShortcutPath() string {
appData := os.Getenv("APPDATA")
if appData == "" {
home, _ := os.UserHomeDir()
appData = filepath.Join(home, "AppData", "Roaming")
}
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", winStartupLnkName)
}
func writeWindowsStartupShortcut(trayScript, addr string) error {
ps := windowsPowerShell()
lnk := windowsStartupShortcutPath()
if err := os.MkdirAll(filepath.Dir(lnk), 0o755); err != nil {
return err
}
// Escape single quotes for PowerShell single-quoted strings.
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
psExe := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
if _, err := os.Stat(psExe); err != nil {
psExe = "powershell.exe"
}
args := fmt.Sprintf(`-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "%s" -Addr %s`, trayScript, addr)
script := fmt.Sprintf(`
$ErrorActionPreference = 'Stop'
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut('%s')
$sc.TargetPath = '%s'
$sc.Arguments = '%s'
$sc.WorkingDirectory = '%s'
$sc.WindowStyle = 7
$sc.Description = 'Verae Timestamping tray — polls GET /status; hold id ≠ evidence.'
$sc.Save()
`, esc(lnk), esc(psExe), esc(args), esc(filepath.Dir(trayScript)))
cmd := exec.Command(ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func windowsPowerShell() string {
ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
if _, err := os.Stat(ps); err != nil {
return "powershell.exe"
}
return ps
}
func locateWindowsTrayScript() (string, error) {
script, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1")
if err == nil {
return script, nil
}
// Installer layout next to the exe.
for _, alt := range []string{
filepath.Join(exeDirHint(), "scripts", "verae-tray.ps1"),
filepath.Join(exeDirHint(), "..", "scripts", "verae-tray.ps1"),
filepath.Join(exeDirHint(), "verae-tray.ps1"),
} {
if fileExists(alt) {
abs, _ := filepath.Abs(alt)
return abs, nil
}
}
return "", err
}
func locateWindowsStartupInstaller() (string, error) {
script, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1")
if err == nil {
return script, nil
}
for _, alt := range []string{
filepath.Join(exeDirHint(), "scripts", "install-tray-startup.ps1"),
filepath.Join(exeDirHint(), "..", "scripts", "install-tray-startup.ps1"),
} {
if fileExists(alt) {
abs, _ := filepath.Abs(alt)
return abs, nil
}
}
return "", err
}
// --- macOS durable LaunchAgent -----------------------------------------------
func menubarSupportDir(home string) string {
return filepath.Join(home, "Library", "Application Support", "Verae", "menubar")
}
func menubarInstallDarwin(addr string) int {
srcScript, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
srcDir := filepath.Dir(srcScript)
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
support := menubarSupportDir(home)
if err := os.MkdirAll(support, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
// Copy durable assets so LaunchAgent does not depend on the checkout path.
for _, name := range []string{"run-menubar.sh", "verae-menubar.swift", "com.verae.timestamping.menubar.plist"} {
src := filepath.Join(srcDir, name)
if !fileExists(src) {
continue
}
dst := filepath.Join(support, name)
if err := copyFile(src, dst); err != nil {
fmt.Fprintf(os.Stderr, "menubar --install: copy %s: %v\n", name, err)
return 1
}
}
script := filepath.Join(support, "run-menubar.sh")
_ = os.Chmod(script, 0o755)
// Best-effort compile so LaunchAgent does not need swift at login.
bin := filepath.Join(support, "verae-menubar")
swiftSrc := filepath.Join(support, "verae-menubar.swift")
if fileExists(swiftSrc) {
if path, lookErr := exec.LookPath("swiftc"); lookErr == nil {
c := exec.Command(path, "-O", "-o", bin, swiftSrc)
if out, err := c.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "menubar: swiftc optional compile failed (will use swift at run): %v\n%s\n", err, strings.TrimSpace(string(out)))
} else {
_ = os.Chmod(bin, 0o755)
}
}
}
logDir := filepath.Join(home, "Library", "Logs")
_ = os.MkdirAll(logDir, 0o755)
agents := filepath.Join(home, "Library", "LaunchAgents")
_ = os.MkdirAll(agents, 0o755)
plistPath := filepath.Join(agents, menubarLaunchLabel+".plist")
body := menubarPlistBody(script, logDir, addr)
// Prefer rewriting the template if present.
if tpl := filepath.Join(support, "com.verae.timestamping.menubar.plist"); fileExists(tpl) {
raw, err := os.ReadFile(tpl)
if err == nil {
s := string(raw)
s = strings.ReplaceAll(s, "MENUBAR_SCRIPT_PLACEHOLDER", script)
s = strings.ReplaceAll(s, "LOG_DIR_PLACEHOLDER", logDir)
// Ensure VERAE_IPP_ADDR matches --addr.
if strings.Contains(s, "<key>VERAE_IPP_ADDR</key>") {
// Replace the next <string>…</string> after VERAE_IPP_ADDR.
s = replaceEnvString(s, "VERAE_IPP_ADDR", addr)
}
body = s
}
}
if err := os.WriteFile(plistPath, []byte(body), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := launchctlLoadMenubar(plistPath); err != nil {
fmt.Fprintf(os.Stderr, "menubar: wrote %s but launchctl load failed: %v\n", plistPath, err)
fmt.Fprintln(os.Stderr, " load manually after login, or: verae-print menubar")
fmt.Fprintln(os.Stderr, " assets:", support)
return 1
}
fmt.Println("Installed LaunchAgent:", plistPath)
fmt.Println("Durable assets:", support)
fmt.Println("Menu bar polls http://" + addr + "/status · Open queue → /queue")
fmt.Println("Optional .app: platform/macos/menubar/build-menubar-app.sh")
fmt.Println(statuslang.EvidenceNote)
return 0
}
func menubarUninstallDarwin() int {
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
plistPath := filepath.Join(home, "Library", "LaunchAgents", menubarLaunchLabel+".plist")
_ = launchctlUnloadMenubar(plistPath)
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, err)
return 1
}
// Leave Application Support assets; user may still run menubar manually.
fmt.Println("Removed LaunchAgent:", plistPath)
fmt.Println("Assets kept at:", menubarSupportDir(home), "(delete manually if desired)")
return 0
}
func menubarPlistBody(script, logDir, addr string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>%s/verae-menubar.launchd.out.log</string>
<key>StandardErrorPath</key>
<string>%s/verae-menubar.launchd.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>VERAE_IPP_ADDR</key>
<string>%s</string>
</dict>
</dict>
</plist>
`, menubarLaunchLabel, script, logDir, logDir, addr)
}
func launchctlLoadMenubar(plistPath string) error {
uid := os.Getuid()
domain := "gui/" + strconv.Itoa(uid)
label := menubarLaunchLabel
// Modern path first (macOS 10.13+); fall back to load -w.
_ = exec.Command("launchctl", "bootout", domain+"/"+label).Run()
_ = exec.Command("launchctl", "unload", plistPath).Run()
if err := exec.Command("launchctl", "bootstrap", domain, plistPath).Run(); err == nil {
_ = exec.Command("launchctl", "enable", domain+"/"+label).Run()
_ = exec.Command("launchctl", "kickstart", "-k", domain+"/"+label).Run()
return nil
}
if err := exec.Command("launchctl", "load", "-w", plistPath).Run(); err != nil {
return err
}
return nil
}
func launchctlUnloadMenubar(plistPath string) error {
uid := os.Getuid()
domain := "gui/" + strconv.Itoa(uid)
label := menubarLaunchLabel
_ = exec.Command("launchctl", "bootout", domain+"/"+label).Run()
_ = exec.Command("launchctl", "unload", "-w", plistPath).Run()
return nil
}
func locateDarwinMenubarScript() (string, error) {
// Prefer durable install copy if present.
if home, err := os.UserHomeDir(); err == nil {
p := filepath.Join(menubarSupportDir(home), "run-menubar.sh")
if fileExists(p) {
return p, nil
}
}
script, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
if err == nil {
return script, nil
}
for _, alt := range []string{
filepath.Join(exeDirHint(), "menubar", "run-menubar.sh"),
filepath.Join(exeDirHint(), "..", "menubar", "run-menubar.sh"),
filepath.Join(exeDirHint(), "run-menubar.sh"),
} {
if fileExists(alt) {
abs, _ := filepath.Abs(alt)
return abs, nil
}
}
return "", err
}
// replaceEnvString replaces the <string> value immediately following <key>NAME</key>.
func replaceEnvString(plist, key, value string) string {
needle := "<key>" + key + "</key>"
i := strings.Index(plist, needle)
if i < 0 {
return plist
}
rest := plist[i+len(needle):]
start := strings.Index(rest, "<string>")
end := strings.Index(rest, "</string>")
if start < 0 || end < 0 || end < start {
return plist
}
return plist[:i+len(needle)] + rest[:start] + "<string>" + value + rest[end:]
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
if st, err := os.Stat(src); err == nil {
_ = os.Chmod(tmp, st.Mode())
}
return os.Rename(tmp, dst)
}
func findRepoFile(parts ...string) (string, error) {
rel := filepath.Join(parts...)
// Prefer go.mod tree (repoRootHint) over bare relative/exe guesses so a
// sibling research checkout cannot shadow the Verae-Projects package tree.
candidates := []string{
filepath.Join(repoRootHint(), rel),
filepath.Join(".", rel),
filepath.Join(exeDirHint(), rel),
filepath.Join(exeDirHint(), "..", rel),
filepath.Join(exeDirHint(), "..", "..", rel),
}
// Walk up from cwd for go.mod (tests + nested cwd).
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
seen := map[string]bool{}
for _, c := range candidates {
abs, err := filepath.Abs(c)
if err != nil {
continue
}
if seen[abs] {
continue
}
seen[abs] = true
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)
}

View file

@ -0,0 +1,313 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
)
func TestFindRepoFileMenubarAssets(t *testing.T) {
switch runtime.GOOS {
case "darwin":
p, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh")
if err != nil {
t.Fatal(err)
}
if filepath.Base(p) != "run-menubar.sh" {
t.Fatal(p)
}
if _, err := findRepoFile("platform", "macos", "menubar", "verae-menubar.swift"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "macos", "menubar", "com.verae.timestamping.menubar.plist"); err != nil {
t.Fatal(err)
}
case "windows":
if _, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
t.Fatal(err)
}
default:
// Scripts still exist in the tree for packaging even on Linux CI.
if _, err := findRepoFile("platform", "windows", "scripts", "verae-tray.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "macos", "menubar", "verae-menubar.swift"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh"); err != nil {
t.Fatal(err)
}
}
}
func TestMenubarPlistBody(t *testing.T) {
body := menubarPlistBody("/tmp/run-menubar.sh", "/tmp/logs", "127.0.0.1:8631")
for _, want := range []string{
menubarLaunchLabel,
"/tmp/run-menubar.sh",
"/tmp/logs/verae-menubar.launchd.out.log",
"<string>127.0.0.1:8631</string>",
"<key>KeepAlive</key>",
"<key>RunAtLoad</key>",
} {
if !strings.Contains(body, want) {
t.Fatalf("plist missing %q\n%s", want, body)
}
}
}
func TestReplaceEnvString(t *testing.T) {
in := `<key>VERAE_IPP_ADDR</key>
<string>127.0.0.1:8631</string>`
out := replaceEnvString(in, "VERAE_IPP_ADDR", "127.0.0.1:9999")
if !strings.Contains(out, "<string>127.0.0.1:9999</string>") {
t.Fatal(out)
}
if strings.Contains(out, "8631") {
t.Fatal(out)
}
}
func TestMenubarSupportDir(t *testing.T) {
p := menubarSupportDir("/Users/test")
want := filepath.Join("/Users/test", "Library", "Application Support", "Verae", "menubar")
if p != want {
t.Fatalf("got %q want %q", p, want)
}
}
func TestCopyFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a.txt")
dst := filepath.Join(dir, "b.txt")
if err := os.WriteFile(src, []byte("hello-menubar"), 0o644); err != nil {
t.Fatal(err)
}
if err := copyFile(src, dst); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(b) != "hello-menubar" {
t.Fatal(string(b))
}
}
func TestWindowsStartupShortcutPathShape(t *testing.T) {
p := windowsStartupShortcutPath()
if !strings.HasSuffix(p, winStartupLnkName) {
t.Fatal(p)
}
if !strings.Contains(strings.ToLower(p), "startup") {
t.Fatal(p)
}
}
func TestLocateScriptsPreferRepo(t *testing.T) {
// On every OS the packaging scripts should resolve from the go.mod tree.
if _, err := findRepoFile("platform", "windows", "scripts", "install-tray-startup.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1"); err != nil {
t.Fatal(err)
}
if _, err := findRepoFile("platform", "macos", "menubar", "run-menubar.sh"); err != nil {
t.Fatal(err)
}
}
func TestBuildTrayStartmenuScriptDocumentsWiXFree(t *testing.T) {
p, err := findRepoFile("platform", "windows", "scripts", "build-tray-startmenu.ps1")
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
body := string(b)
for _, want := range []string{
"Start Menu",
"Verae Tray",
"install-tray-startup.ps1",
"WiX",
"WScript.Shell",
"-Uninstall",
} {
if !strings.Contains(body, want) {
t.Fatalf("build-tray-startmenu.ps1 missing %q", want)
}
}
}
func TestMenubarAppBuilderHasNotarizationPointer(t *testing.T) {
p, err := findRepoFile("platform", "macos", "menubar", "build-menubar-app.sh")
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
body := string(b)
for _, want := range []string{
"LSUIElement",
"ai.verae.timestamping.menubar",
"entitlements.plist",
"notarize",
"PkgInfo",
"HelperVersion",
"packaging-metadata.txt",
} {
if !strings.Contains(body, want) {
t.Fatalf("build-menubar-app.sh missing %q", want)
}
}
readme, err := findRepoFile("platform", "macos", "menubar", "README.txt")
if err != nil {
t.Fatal(err)
}
rb, err := os.ReadFile(readme)
if err != nil {
t.Fatal(err)
}
rbody := string(rb)
for _, want := range []string{
"notarytool",
"stapler",
"Developer ID Application",
"codesign",
} {
if !strings.Contains(rbody, want) {
t.Fatalf("menubar README.txt missing notarization step %q", want)
}
}
}
func TestRepoRootHintPrefersGoModTree(t *testing.T) {
// Residual packaging: no machine-specific research absolute path; go.mod tree wins.
root := repoRootHint()
if root == "" || root == "." {
// cwd may already be the module; still require go.mod via findRepoFile.
} else {
if !fileExists(filepath.Join(root, "go.mod")) {
t.Fatalf("repoRootHint %q missing go.mod", root)
}
if !fileExists(filepath.Join(root, "internal", "job", "job.go")) {
t.Fatalf("repoRootHint %q missing internal/job/job.go", root)
}
}
jobPath, err := findRepoFile("internal", "job", "job.go")
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(jobPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `const HelperVersion = "`+job.HelperVersion+`"`) {
t.Fatalf("resolved job.go must declare HelperVersion=%s at %s", job.HelperVersion, jobPath)
}
// Source must not hardcode a sibling research checkout path.
mainPath, err := findRepoFile("cmd", "verae-print", "main.go")
if err != nil {
t.Fatal(err)
}
mb, err := os.ReadFile(mainPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(mb), "/Users/marchon/research/PrintToVeraeTimeStamp") {
t.Fatal("main.go must not hardcode research absolute path (use go.mod walk)")
}
}
func TestPackagingMetadataVersionStamp(t *testing.T) {
// A4: tray/menubar install scripts + READMEs stamp HelperVersion.
// job.HelperVersion is the single source of truth.
helper := job.HelperVersion
if helper == "" {
t.Fatal("job.HelperVersion empty")
}
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)
}
}
}
}

View file

@ -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"
)
@ -35,6 +37,9 @@ var errNoKey = errors.New("no Verae signing key is set up")
// isAuthFailure: the job can succeed after the user signs in again.
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
// isBillingFailure: the job can succeed after the user adds funds on Zap Billing.
func isBillingFailure(err error) bool { return veraeapi.IsBillingError(err) }
type pendingMeta struct {
ID string `json:"id"`
Title string `json:"title"`
@ -160,13 +165,18 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
return done, len(listPending()), true
}
if isBillingFailure(err) {
lj.Errorf("pending: %s still needs funds: %v", m.ID, err)
offerBillingCheckout(billingReason(err))
return done, len(listPending()), false
}
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
continue
}
removePending(m)
done++
lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF)
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", m.Title, res.ReceiptID))
openOnComplete(lj, res.CertificatePDF)
}
return done, len(listPending()), false
@ -194,6 +204,35 @@ func promptLock() (func(), bool) {
return func() {}, false
}
// billingOpenThrottle is how long we suppress re-opening the Billing tab
// after a credit failure (avoids a browser storm while jobs stay held).
const billingOpenThrottle = 10 * time.Minute
// offerBillingCheckout opens the Zap Billing reload page at most once per
// billingOpenThrottle. Safe across processes via a lock file in pendingDir.
func offerBillingCheckout(reason string) {
_ = os.MkdirAll(pendingDir(), 0o700)
marker := filepath.Join(pendingDir(), ".billing.open")
if st, err := os.Stat(marker); err == nil && time.Since(st.ModTime()) < billingOpenThrottle {
vlog.Printf("billing: checkout already offered %s ago; not re-opening (%d pending)",
time.Since(st.ModTime()).Round(time.Second), len(listPending()))
return
}
_ = os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)+"\n"+reason+"\n"), 0o600)
vlog.Printf("billing: opening %s (%d pending)", auth.ZapBillingURL, len(listPending()))
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(reason))
if err := openBrowser(auth.ZapBillingURL); err != nil {
vlog.Errorf("billing: could not open browser: %v — open %s", err, auth.ZapBillingURL)
fmt.Fprintf(os.Stderr, "Out of credits. Open Billing to add funds:\n %s\n", auth.ZapBillingURL)
}
}
// handleBillingFailure holds the print job narrative for the user and opens
// Zap Billing. Does not prompt for a new signing key.
func handleBillingFailure(reason string) {
offerBillingCheckout(reason)
}
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
// pending jobs. Returns true when everything pending was stamped.
func handleAuthFailure(reason string, stamp stampFn) bool {
@ -216,7 +255,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 +298,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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;").Replace(s)
}
// renderPendingHTML is the PT-04/W4 queue page (also served at helper GET /queue).
func renderPendingHTML(list []pendingMeta, snap traySnap) string {
var rows strings.Builder
if len(list) == 0 {
rows.WriteString(`<tr><td colspan="4" class="empty">No held jobs — print with Destination → Verae Timestamping</td></tr>`)
} else {
for _, m := range list {
title := m.Title
if title == "" {
title = "(untitled)"
}
rows.WriteString(fmt.Sprintf(
`<tr><td class="mono">%s</td><td>%s</td><td><span class="badge">%s</span></td><td class="muted">%s</td></tr>`,
htmlEsc(m.ID), htmlEsc(title), htmlEsc(statuslang.Pending), htmlEsc(m.Created),
))
}
}
meta := fmt.Sprintf("Folder: %s · %d held", htmlEsc(pendingDir()), len(list))
if snap.QueueURL != "" {
meta += fmt.Sprintf(` · live: <a class="mono" href="%s">%s</a>`, htmlEsc(snap.QueueURL), htmlEsc(snap.QueueURL))
} else {
meta += ` · refresh: <span class="mono">verae-print pending --html</span>`
}
meta += fmt.Sprintf(` · <span class="mono">verae-print tray-status</span>`)
return fmt.Sprintf(`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta http-equiv="refresh" content="15"/>
<title>Print job queue · Verae Timestamping</title>
<style>
:root{--plum:#531B59;--gold:#C4A85A;--ink:#1D1534;--muted:#5E5470;--bg:#FBFAFD;--card:#fff;--line:#E3DEF0;--surface:#F4F1F8;--pend-bg:#EEF0F3;--pend:#475467;--ok:#0E6B3A;--ok-bg:#E3F5EA}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--ink)}
main{max-width:820px;margin:0 auto;padding:28px 16px}
h1{font-size:1.35rem;margin:0 0 6px} .lede{color:var(--muted);font-size:.95rem;margin:0 0 18px;max-width:40rem}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;box-shadow:0 1px 2px rgba(29,21,52,.04)}
table{width:100%%;border-collapse:collapse;font-size:.9rem} th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--line)}
th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
.mono{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.8rem}
.muted{color:var(--muted)}.empty{color:var(--muted);text-align:center;padding:28px 8px}
.badge{display:inline-block;background:var(--pend-bg);color:var(--pend);border-radius:999px;padding:2px 10px;font-size:.78rem;font-weight:600}
.pill{display:inline-block;border-radius:999px;padding:3px 12px;font-size:.8rem;font-weight:600;background:var(--ok-bg);color:var(--ok)}
.pill.warn{background:var(--pend-bg);color:var(--pend)}
.note{margin-top:14px;padding:12px 14px;background:var(--surface);border-left:3px solid var(--gold);border-radius:0 8px 8px 0;font-size:.85rem;color:var(--muted)}
.note b{color:var(--plum)} .meta{margin-top:12px;font-size:.8rem;color:var(--muted)}
a{color:var(--plum)} .status{margin:0 0 14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
</style></head><body><main>
<h1>Print job queue</h1>
<p class="lede">PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. <b style="color:var(--plum)">%s</b></p>
<div class="status"><span class="pill%s">%s</span><span class="muted">%s</span></div>
<div class="card">
<table><thead><tr><th>Hold id</th><th>Document</th><th>State</th><th>Created</th></tr></thead>
<tbody>%s</tbody></table>
<div class="note"><b>Not proof:</b> %s Wait for <b>%s</b>.</div>
<p class="meta">%s</p>
</div></main></body></html>
`, htmlEsc(statuslang.EvidenceNote), pillClass(snap), htmlEsc(snap.Headline), htmlEsc(snap.Detail), rows.String(), htmlEsc(statuslang.EvidenceNote), htmlEsc(statuslang.FinalReceiptReady), meta)
}
func pillClass(snap traySnap) string {
if snap.Held > 0 || !snap.KeyOK {
return " warn"
}
return ""
}

View file

@ -91,3 +91,17 @@ func TestRetryKeepsNonAuthFailures(t *testing.T) {
t.Fatalf("done=%d remaining=%d authFail=%v", done, remaining, authFail)
}
}
func TestIsBillingFailure(t *testing.T) {
pendingEnv(t)
bill := &veraeapi.HTTPError{Service: "zapier stamp", Status: 402, Body: `{"error":"payment_required"}`}
if !isBillingFailure(bill) {
t.Fatal("expected billing failure")
}
if isAuthFailure(bill) {
t.Fatal("402 must not be auth failure")
}
if isBillingFailure(&veraeapi.HTTPError{Status: 401, Body: "no"}) {
t.Fatal("401 is not billing")
}
}

View file

@ -11,11 +11,25 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
// 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
}
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
if title == "" {
title = "Verae Timestamp"
}
if runtime.GOOS == "linux" {
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
_ = exec.Command("notify-send", "-a", "Verae Timestamp", title, body).Run()
}
return
}
if runtime.GOOS != "darwin" {
return
}
// 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()
}
@ -35,8 +49,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

@ -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()
}

View file

@ -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,27 @@ 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())
if isBillingFailure(err) {
m, herr := holdJob(j.Path, target, j.Source, err, true)
if herr != nil {
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
go offerBillingCheckout(billingReason(err))
return err
}
lj.Printf("process: HELD as pending %s (%v); opening Zap Billing", m.ID, err)
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", m.Title, m.ID)))
go handleBillingFailure(billingReason(err))
return nil // job held; user adds funds then reprint / retry pending
}
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 {

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