Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f8796c25e | |||
| 7af670c4ef | |||
| d82d0a59cf | |||
| 78cc3a40ec | |||
| 41e079a0ad | |||
| d822137bbb | |||
| 0efe762a23 | |||
| 778763737e | |||
| 9d17dff73d | |||
| a15db6ffc7 | |||
| 3140e6c76f | |||
| 9f0881ccfc | |||
| 6090b41f8f | |||
| 86809b27eb | |||
| 376bbe188a | |||
| da4a0dc4e2 | |||
| 042342bd69 | |||
| dcee1918dc | |||
| 1d6ce81a54 | |||
| 1e0152c7af | |||
| 7768562517 | |||
| 8a8db16da1 | |||
| 46fa1aa0d4 | |||
| f766e537ba | |||
| b72324edc4 | |||
| 14132e1782 | |||
| 1b357f6e87 | |||
| 5cfb92c82a | |||
| c2c87274be | |||
| 66900f3951 | |||
| b342b9235e | |||
| 48da24a661 | |||
| 10814f0bfb | |||
| dd46ad151f | |||
| 2fef6575ba | |||
| 179c974b60 | |||
| f8e74b56db | |||
| 333ce3ef74 | |||
|
|
133315ab53 | ||
| 095e1e42ba | |||
| 7a938892d1 | |||
| 386f9d1a2f |
123 changed files with 14355 additions and 634 deletions
164
.github/workflows/windows-msi-sign.yml.example
vendored
Normal file
164
.github/workflows/windows-msi-sign.yml.example
vendored
Normal 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
164
.github/workflows/windows-msi.yml
vendored
Normal 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
6
.gitignore
vendored
|
|
@ -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
162
README.md
|
|
@ -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`.
|
[](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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//go:build !windows && !darwin
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|
|
||||||
7
cmd/verae-print/hide_other.go
Normal file
7
cmd/verae-print/hide_other.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func hideWindow(c *exec.Cmd) {}
|
||||||
10
cmd/verae-print/keydialog_stub.go
Normal file
10
cmd/verae-print/keydialog_stub.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
365
cmd/verae-print/keyflow_gui.go
Normal file
365
cmd/verae-print/keyflow_gui.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
171
cmd/verae-print/keysetup_linux.go
Normal file
171
cmd/verae-print/keysetup_linux.go
Normal 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("&", "&", "<", "<", ">", ">").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
|
||||||
|
}
|
||||||
|
|
@ -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
584
cmd/verae-print/menubar.go
Normal 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)
|
||||||
|
}
|
||||||
313
cmd/verae-print/menubar_test.go
Normal file
313
cmd/verae-print/menubar_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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("&", "&", "<", "<", ">", ">", `"`, """).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 ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue