Compare commits

...
Sign in to create a new pull request.
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
123 changed files with 14355 additions and 634 deletions

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 .DS_Store
node_modules/ node_modules/
/build/ /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 # 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 ## Install
Downloads: [printit.verae.ai](https://printit.verae.ai/) (macOS `.pkg`, Windows `.zip`, Linux `.tar.gz`). ```sh
go build ./...
**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
``` ```
## Usage ## Usage
In any app: **File → Print → Destination → Verae Timestamping → Print**. ```sh
verae-print stamp --channel=zapier file.pdf # default billed path
Also: drop a PDF on **Verae Print Stamp** in `/Applications`, or **Open With**. verae-print serve # IPP helper + loopback status
verae-print tray-status # held/key/helper snapshot
CLI: 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)
```bash verae-print menubar --install # login presence (Win Startup / macOS LaunchAgent)
verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] file.pdf 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 | Tray, menu bar, CERT-01 digests, version stamps (`HelperVersion` **0.3.6**), and IPP GET vs POST split:
|------|---------|
| `{name}.pdf` | Exact printed bytes (proven artifact) |
| `{name}-verae-timestamp-{RECEIPTID}.pdf` | Registration / certificate PDF |
| `{name}.verae.json` | Receipt sidecar |
Channels: - [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).
- `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/).
## Contributing ## 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 ## 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 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) { switch ($kind) {
'login' { 'login' {
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false '' $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 $script:y += 8
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok $ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
Btn 'Create account' 125 120 'create' | Out-Null Btn 'Create account' 125 120 'create' | Out-Null
@ -53,7 +55,35 @@ switch ($kind) {
$f.Height = $script:y + 75 $f.Height = $script:y + 75
if ($pre) { $f.Add_Shown({ $p.Focus() }) } if ($pre) { $f.Add_Shown({ $p.Focus() }) }
[void]$f.ShowDialog() [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' { 'create' {
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true '' $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 { if err != nil {
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err) 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 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": case "create":
k, ok := winCreateFlow(caps, email) k, ok := winCreateFlow(caps, email)
if !ok { if !ok {
@ -231,6 +273,95 @@ func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
return "", false 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. // pasteFlow: masked key field + portal button; validated before saving.
func pasteFlow(reason string) bool { func pasteFlow(reason string) bool {
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below." 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 package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
) )
@ -22,6 +20,15 @@ import (
const dlgTitle = "Verae Print Stamp" 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 → // AppleScript gets all user-visible text via argv (no string interpolation →
// no injection). Output: "<button>\t<text returned>" or "CANCEL". // no injection). Output: "<button>\t<text returned>" or "CANCEL".
const dialogScript = `on run argv const dialogScript = `on run argv
@ -60,8 +67,6 @@ const chooseScript = `on run argv
return item 1 of r return item 1 of r
end run` end run`
var errCancel = errors.New("cancelled")
func osa(script string, args ...string) (string, error) { func osa(script string, args ...string) (string, error) {
cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...) cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...)
out, err := cmd.Output() out, err := cmd.Output()
@ -103,14 +108,6 @@ func choose(msg string, opts []string) (string, error) {
return out, nil 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 { func runMacKeySetup(args []string) int {
fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError) fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError)
askOpen := fs.Bool("ask-open", true, "also ask what to open after each print") 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 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 { func askOpenOnComplete() string {
cur, _ := settings.Load() cur, _ := settings.Load()
def := map[string]string{settings.OpenFolder: "Show in Finder", settings.OpenPDF: "Open the PDF", settings.OpenNone: "Do nothing"}[cur.OpenOnComplete] 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/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
) )
func main() { func main() {
@ -38,6 +39,12 @@ func main() {
os.Exit(runValidateKey(os.Args[2:])) os.Exit(runValidateKey(os.Args[2:]))
case "config": case "config":
os.Exit(runConfig(os.Args[2:])) 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": case "mac-key-setup":
os.Exit(runMacKeySetup(os.Args[2:])) os.Exit(runMacKeySetup(os.Args[2:]))
case "key-dialog": 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 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] 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 # 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 mac-key-setup # macOS: native dialogs to keep/sign in/create account/paste a key (run as the user)
verae-print version 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) 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) 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) handleAuthFailure(authReason(err), stampFile)
return 0 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 { if err != nil {
lj.Errorf("stamp failed: %v", err) lj.Errorf("stamp failed: %v", err)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", 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 return 1
} }
lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF) 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) done, remaining, _ := retryPending(stampFile)
lj.Printf("pending: %d stamped, %d remaining", done, remaining) lj.Printf("pending: %d stamped, %d remaining", done, remaining)
} }
// macOS notification (best-effort) docTitle := *name
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) if docTitle == "" {
docTitle = filepath.Base(pdfPath)
}
notify(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", docTitle, res.ReceiptID))
return 0 return 0
} }
@ -266,14 +306,40 @@ func runInstallPrinter() int {
} }
func repoRootHint() string { func repoRootHint() string {
// Prefer known research path; else cwd // Prefer the go.mod tree that owns this binary (A4 packaging source of truth).
candidates := []string{ // No machine-specific absolute paths — walk cwd and exe parents.
"/Users/marchon/research/PrintToVeraeTimeStamp", var starts []string
".", if wd, err := os.Getwd(); err == nil {
starts = append(starts, wd)
} }
for _, c := range candidates { starts = append(starts, exeDirHint())
if st, err := os.Stat(filepath.Join(c, "platform", "macos", "ipp", "install-ipp-printer.sh")); err == nil && !st.IsDir() { if exe := exeDirHint(); exe != "." {
return c 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 "." 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/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"flag"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -25,6 +26,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" "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. // isAuthFailure: the job can succeed after the user signs in again.
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) } 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 { type pendingMeta struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` 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) lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
return done, len(listPending()), true 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) lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
continue continue
} }
removePending(m) removePending(m)
done++ done++
lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF) 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) openOnComplete(lj, res.CertificatePDF)
} }
return done, len(listPending()), false return done, len(listPending()), false
@ -194,6 +204,35 @@ func promptLock() (func(), bool) {
return func() {}, false 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 // handleAuthFailure shows ONE login prompt (if none is open), then retries all
// pending jobs. Returns true when everything pending was stamped. // pending jobs. Returns true when everything pending was stamped.
func handleAuthFailure(reason string, stamp stampFn) bool { func handleAuthFailure(reason string, stamp stampFn) bool {
@ -216,7 +255,8 @@ func handleAuthFailure(reason string, stamp stampFn) bool {
} }
if !acquire(msg) { if !acquire(msg) {
vlog.Printf("pending: sign-in cancelled; %d job(s) kept in %s", n, pendingDir()) 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 return false
} }
markKeySeen() markKeySeen()
@ -258,3 +298,174 @@ func keyChangedSinceSeen() bool {
} }
return false 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) 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" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
) )
func notify(title, subtitle string) { // notify shows an OS banner. title should be statuslang.NotifyTitle; body NotifyBody.
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" { func notify(title, body string) {
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
return 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() _ = exec.Command("osascript", "-e", script).Run()
} }
@ -35,8 +49,3 @@ func openFolderPath(p string) {
func runInstallPrinterWindows() int { return 1 } func runInstallPrinterWindows() int { return 1 }
func debugFromRegistry() bool { return false } 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. // 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) { func notifyUser(title, body string) {
vlog.Printf("notify: %s — %s", title, body) vlog.Printf("notify: %s — %s", title, body)
if os.Getenv("VERAE_NO_NOTIFY") == "1" { if os.Getenv("VERAE_NO_NOTIFY") == "1" {
return return
} }
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } esc := func(s string) string {
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)) s = strings.ReplaceAll(s, "'", "''")
cmd := exec.Command("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) 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) hideWindow(cmd)
_ = cmd.Start() _ = cmd.Start()
} }

View file

@ -16,6 +16,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
) )
@ -74,8 +75,22 @@ func runServe(args []string) int {
go pendingOnStart() go pendingOnStart()
errc := make(chan error, 2) errc := make(chan error, 2)
if !*noIPP { 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() }() 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 { if !*noWatch {
w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc} w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc}
@ -134,14 +149,27 @@ func makeProcessor(outDir string) winprint.Processor {
return err return err
} }
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, 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) go handleAuthFailure(authReason(err), stampFile)
return nil // the job is safe; IPP reports success to Windows 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 return err
} }
_ = os.Remove(j.Path) _ = 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) openOnComplete(lj, res.CertificatePDF)
// a successful stamp proves the key works: flush anything still pending // a successful stamp proves the key works: flush anything still pending
if len(listPending()) > 0 { if len(listPending()) > 0 {

View file

@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
@ -59,3 +60,18 @@ func authReason(err error) string {
} }
return "No Verae signing key is set up for this user yet. Please sign in or create an account." return "No Verae signing key is set up for this user yet. Please sign in or create an account."
} }
func billingReason(err error) string {
if err != nil && strings.TrimSpace(err.Error()) != "" {
return "Your Verae account needs funds before PrintIt can stamp. Opening Billing so you can add prepaid balance.\n\n(" + shortErr(err) + ")"
}
return "Your Verae account needs funds before PrintIt can stamp. Opening Billing so you can add prepaid balance."
}
func shortErr(err error) string {
s := err.Error()
if len(s) > 160 {
return s[:157] + "…"
}
return s
}

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