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 |
128 changed files with 14361 additions and 1273 deletions
|
|
@ -1,24 +0,0 @@
|
|||
name: test-and-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: 138
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Unit tests
|
||||
run: bash scripts/test-138.sh
|
||||
|
||||
deploy-138:
|
||||
needs: test
|
||||
if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: 138
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy the host checkout
|
||||
run: bash scripts/deploy-138.sh
|
||||
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
|
||||
node_modules/
|
||||
/build/
|
||||
Verae-Secret-Credentials.MD
|
||||
# Compiled PT-06 menu bar (built by run-menubar.sh / swiftc)
|
||||
platform/macos/menubar/verae-menubar
|
||||
verae-print
|
||||
platform/macos/pkg/resources/AppIcon.iconset/
|
||||
build/macos-pkg/
|
||||
|
|
|
|||
162
README.md
162
README.md
|
|
@ -1,136 +1,66 @@
|
|||
# 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
|
||||
|
||||
Downloads: [printit.verae.ai](https://printit.verae.ai/) (macOS `.pkg`, Windows `.zip`, Linux `.tar.gz`).
|
||||
|
||||
**macOS (recommended):** open `VeraePrintStamp-macos.pkg`. First launch opens **customer.verae.ai** for an **xVerae-** signing key.
|
||||
|
||||
**Windows (GUI installer):**
|
||||
|
||||
```text
|
||||
Download VeraePrintStamp-windows-amd64-setup.exe from printit.verae.ai
|
||||
Run the installer → Finish with “Run setup” → paste your xVerae- key
|
||||
verae-print stamp .\document.pdf
|
||||
```
|
||||
|
||||
**Debian / Ubuntu:**
|
||||
|
||||
```bash
|
||||
sudo apt install ./verae-print_amd64.deb
|
||||
verae-print setup
|
||||
verae-print stamp document.pdf
|
||||
```
|
||||
|
||||
**Linux (tarball, any distro):**
|
||||
|
||||
```bash
|
||||
tar xzf VeraePrintStamp-linux-amd64.tar.gz
|
||||
cd VeraePrintStamp-linux-amd64-*
|
||||
sudo ./install.sh # CLI + optional CUPS queue
|
||||
verae-print setup
|
||||
verae-print stamp document.pdf
|
||||
```
|
||||
|
||||
From source (macOS):
|
||||
|
||||
```bash
|
||||
cd /Users/marchon/research/PrintToVeraeTimeStamp
|
||||
go build -o "$HOME/bin/verae-print" ./cmd/verae-print
|
||||
verae-print setup # create account + save xVerae- signing key
|
||||
verae-print install-printer # Print → Destination → Verae Timestamping
|
||||
```
|
||||
|
||||
Optional (buried PDF submenu): `verae-print install-macos`
|
||||
|
||||
Ensure `$HOME/bin` is on your `PATH`. For the legacy Verae Time JWT channel:
|
||||
|
||||
```bash
|
||||
uvx verae-mcp-timestamping-server setup
|
||||
# or session check:
|
||||
uvx verae-mcp-timestamping-server session
|
||||
```
|
||||
|
||||
For the Zapier channel, store an `xVerae-` token from [customer.verae.ai](https://customer.verae.ai/portal/) / [zap.verae.ai](https://zap.verae.ai/portal/) Security tab:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.verae
|
||||
chmod 700 ~/.verae
|
||||
printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once
|
||||
chmod 600 ~/.verae/xverae-token
|
||||
```sh
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
In any app: **File → Print → Destination → Verae Timestamping → Print**.
|
||||
|
||||
Also: drop a PDF on **Verae Print Stamp** in `/Applications`, or **Open With**.
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] file.pdf
|
||||
```sh
|
||||
verae-print stamp --channel=zapier file.pdf # default billed path
|
||||
verae-print serve # IPP helper + loopback status
|
||||
verae-print tray-status # held/key/helper snapshot
|
||||
verae-print tray-status --open # open http://127.0.0.1:8631/queue
|
||||
verae-print menubar # PT-W3/PT-06: tray (Win) or menu bar (macOS)
|
||||
verae-print menubar --install # login presence (Win Startup / macOS LaunchAgent)
|
||||
verae-print menubar --uninstall # remove login presence
|
||||
verae-print pending --html # offline queue page
|
||||
# Live while helper runs: GET /queue (HTML), GET /status (JSON), GET / (health)
|
||||
# Windows tray: platform/windows/scripts/verae-tray.ps1
|
||||
# Windows Startup: platform/windows/scripts/install-tray-startup.ps1
|
||||
# Windows Start Menu (WiX-free stub): platform/windows/scripts/build-tray-startmenu.ps1
|
||||
# macOS menu bar: platform/macos/menubar/run-menubar.sh
|
||||
# macOS durable LaunchAgent assets: ~/Library/Application Support/Verae/menubar/
|
||||
# macOS optional .app (unsigned): platform/macos/menubar/build-menubar-app.sh
|
||||
# macOS notarization steps (docs only): platform/macos/menubar/README.txt
|
||||
# Direct chain only with VERAE_ALLOW_DIRECT_VERAE=1
|
||||
```
|
||||
|
||||
Default output directory: `~/Documents/Verae Timestamps/`
|
||||
## Packaging
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `{name}.pdf` | Exact printed bytes (proven artifact) |
|
||||
| `{name}-verae-timestamp-{RECEIPTID}.pdf` | Registration / certificate PDF |
|
||||
| `{name}.verae.json` | Receipt sidecar |
|
||||
Tray, menu bar, CERT-01 digests, version stamps (`HelperVersion` **0.3.6**), and IPP GET vs POST split:
|
||||
|
||||
Channels:
|
||||
|
||||
- `zapier` (default after setup) — `zap.verae.ai` + customer `xVerae-` signing key + `attachPdf:true`
|
||||
- `verae` — `api.veraetime.net` + JWT in `~/.verae/auth.properties` (opt-in: `-channel=verae`)
|
||||
|
||||
## Signed macOS installer (.pkg)
|
||||
|
||||
Same FlightLookup Developer ID + notarization path as Verae Timestamping.app (`~/fie/installers/macos/signing.env`).
|
||||
|
||||
```bash
|
||||
cd /Users/marchon/research/PrintToVeraeTimeStamp
|
||||
chmod +x platform/macos/pkg/build-pkg.sh
|
||||
./platform/macos/pkg/build-pkg.sh
|
||||
# → build/macos-pkg/dist/VeraePrintStamp-0.1.2.pkg
|
||||
# → build/macos-pkg/dist/VeraePrintStamp-macos.pkg
|
||||
```
|
||||
|
||||
The pkg installs:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `/Applications/Verae Print Stamp.app` | Drop / Open With |
|
||||
| `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | Stamp CLI (universal) |
|
||||
| `/usr/local/bin/verae-print` | Symlink |
|
||||
| LaunchAgent + CUPS queue | **Print → Destination → Verae Timestamping** |
|
||||
|
||||
Requires: `Developer ID Application` + `Developer ID Installer` (FlightLookup), App Store Connect API key in `signing.env`, Node for bundling the registration PDF builder.
|
||||
|
||||
## Windows / Linux release archives
|
||||
|
||||
```bash
|
||||
./scripts/build-win-linux.sh
|
||||
./platform/windows/build-installer.sh # NSIS GUI setup.exe (needs makensis)
|
||||
# on Debian/Ubuntu host:
|
||||
VERSION=0.1.2 BIN_AMD64=... BIN_ARM64=... ./platform/linux/deb/build-deb.sh all
|
||||
```
|
||||
|
||||
Windows ships an NSIS GUI installer; stamp via CLI after setup (virtual printer still on the [spec](docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md) roadmap). Debian/Ubuntu use `verae-print_*.deb`; other distros use the tarball + `install.sh`.
|
||||
|
||||
## Status
|
||||
|
||||
macOS Destination printer (notarized `.pkg`), Windows/Linux CLI packages, and branded registration PDF are published at [printit.verae.ai](https://printit.verae.ai/).
|
||||
- [docs/PACKAGING.md](docs/PACKAGING.md) — Windows tray (no elevated MSI), macOS LaunchAgent / unsigned `.app` / notarization (docs only), `pending` / `tray-status` / `menubar`, statuslang notify titles, hold ≠ evidence, CERT-01 web viewer.
|
||||
- [site/printit.verae.ai/certificate.html](site/printit.verae.ai/certificate.html) — CERT-01 branded certificate HTML (plum/gold, multihash when present).
|
||||
|
||||
## Contributing
|
||||
|
||||
Open questions and design changes in this repository. Pull requests are accepted for review against the spec.
|
||||
Questions: open an issue on the project Forgejo/GitHub remote, or contact Verae engineering.
|
||||
PRs are accepted when they include tests and do not leak secrets.
|
||||
|
||||
## License
|
||||
|
||||
UNLICENSED © Verae / George Lambert
|
||||
UNLICENSED © Verae Inc. (verae.com)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build !windows && !darwin
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
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) {
|
||||
'login' {
|
||||
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false ''
|
||||
$fl = New-Object Windows.Forms.LinkLabel; $fl.Text = 'Forgot password?'; $fl.Left = 170; $fl.Top = $script:y; $fl.Width = 200
|
||||
$fl.Add_LinkClicked({ $script:result = 'forgot'; $f.Close() }); $f.Controls.Add($fl); $script:y += 24
|
||||
$script:y += 8
|
||||
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
|
||||
Btn 'Create account' 125 120 'create' | Out-Null
|
||||
|
|
@ -53,7 +55,35 @@ switch ($kind) {
|
|||
$f.Height = $script:y + 75
|
||||
if ($pre) { $f.Add_Shown({ $p.Focus() }) }
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
|
||||
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" }
|
||||
elseif ($script:result -eq 'forgot') { "forgot|$(B $e.Text)" } else { $script:result }
|
||||
}
|
||||
'forgot' {
|
||||
$e = Field 'Email' $false $pre
|
||||
$script:y += 8
|
||||
$ok = Btn 'Send code' 15 100 'send'; $f.AcceptButton = $ok
|
||||
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'send') { "send|$(B $e.Text)" } else { 'cancel' }
|
||||
}
|
||||
'reset' {
|
||||
$k = Field 'Code from the email' $false ''; $p = Field 'New password' $true ''; $p2 = Field 'Confirm new password' $true ''
|
||||
$script:y += 8
|
||||
$ok = Btn 'Reset password' 15 130 'reset'; $f.AcceptButton = $ok
|
||||
Btn 'Send a new code' 155 130 'resend' | Out-Null
|
||||
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'reset') { "reset|$(B $k.Text)|$(B $p.Text)|$(B $p2.Text)" } else { $script:result }
|
||||
}
|
||||
'unavail' {
|
||||
$ok = Btn 'Open customer.verae.ai' 15 200 'open'; $f.AcceptButton = $ok
|
||||
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'open') { Start-Process 'https://customer.verae.ai/portal/' }
|
||||
'cancel'
|
||||
}
|
||||
'create' {
|
||||
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
|
||||
|
|
@ -143,9 +173,21 @@ func acquireKeyInteractive(reason string) bool {
|
|||
}
|
||||
if err != nil {
|
||||
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err)
|
||||
msg = "Sign-in failed: " + err.Error()
|
||||
msg = "Sign-in failed: " + err.Error() + "\r\nForgot your password? Click \"Forgot password?\" below."
|
||||
continue
|
||||
}
|
||||
case "forgot":
|
||||
if e := strings.TrimSpace(f[0]); e != "" {
|
||||
email = e
|
||||
}
|
||||
k, ok, m := winForgotFlow(caps, email)
|
||||
if m != "" {
|
||||
msg = m
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = k
|
||||
case "create":
|
||||
k, ok := winCreateFlow(caps, email)
|
||||
if !ok {
|
||||
|
|
@ -231,6 +273,95 @@ func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
// winResetUnavailable: in-app reset not supported; offer the web portal.
|
||||
func winResetUnavailable() {
|
||||
_, _, _ = winForm("unavail", "Password reset isn't available in the app yet.\r\n\r\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "")
|
||||
}
|
||||
|
||||
// winForgotFlow: emailed-code password reset. Returns (key, ok, message for the sign-in form).
|
||||
func winForgotFlow(caps auth.Capabilities, email string) (string, bool, string) {
|
||||
msg := "Reset your Verae password.\r\nWe'll email a 6-digit code to this address."
|
||||
for i := 0; i < 10; i++ {
|
||||
act, f, err := winForm("forgot", msg, email)
|
||||
if err != nil || act != "send" {
|
||||
return "", false, ""
|
||||
}
|
||||
email = strings.TrimSpace(f[0])
|
||||
if !auth.ValidEmail(email) {
|
||||
msg = "Please enter a valid email address."
|
||||
continue
|
||||
}
|
||||
vlog.Printf("sign-in: password reset requested for %s", auth.MaskEmail(email))
|
||||
if ferr := auth.PasswordForgot(email); ferr != nil {
|
||||
vlog.Errorf("sign-in: password forgot %s: %v", auth.MaskEmail(email), ferr)
|
||||
if errors.Is(ferr, auth.ErrResetUnavailable) {
|
||||
winResetUnavailable()
|
||||
return "", false, ""
|
||||
}
|
||||
msg = "Could not send a reset code: " + ferr.Error()
|
||||
continue
|
||||
}
|
||||
cmsg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes). Check your spam folder.\r\n" + caps.Policy.Describe()
|
||||
for j := 0; j < 15; j++ {
|
||||
act, cf, err := winForm("reset", cmsg, "")
|
||||
if err != nil || act == "cancel" || act == "" {
|
||||
return "", false, ""
|
||||
}
|
||||
if act == "resend" {
|
||||
if rerr := auth.PasswordForgot(email); rerr != nil {
|
||||
if errors.Is(rerr, auth.ErrResetUnavailable) {
|
||||
winResetUnavailable()
|
||||
return "", false, ""
|
||||
}
|
||||
cmsg = "Could not send a new code: " + rerr.Error()
|
||||
} else {
|
||||
cmsg = "A new code was sent to " + email + ".\r\n" + caps.Policy.Describe()
|
||||
}
|
||||
continue
|
||||
}
|
||||
code, pw := strings.TrimSpace(cf[0]), cf[1]
|
||||
switch {
|
||||
case len(code) != 6 || strings.Trim(code, "0123456789") != "":
|
||||
cmsg = "Enter the 6-digit code from the email.\r\n" + caps.Policy.Describe()
|
||||
continue
|
||||
case pw != cf[2]:
|
||||
cmsg = "The passwords do not match.\r\n" + caps.Policy.Describe()
|
||||
continue
|
||||
}
|
||||
if perr := caps.Policy.Check(pw, email); perr != nil {
|
||||
cmsg = perr.Error() + "\r\n" + caps.Policy.Describe()
|
||||
continue
|
||||
}
|
||||
key, rerr := auth.PasswordReset(email, code, pw, tokenName())
|
||||
cf[1], cf[2] = "", ""
|
||||
if rerr != nil {
|
||||
vlog.Errorf("sign-in: password reset %s: %v", auth.MaskEmail(email), rerr)
|
||||
if errors.Is(rerr, auth.ErrResetUnavailable) {
|
||||
winResetUnavailable()
|
||||
return "", false, ""
|
||||
}
|
||||
cmsg = "Could not reset the password: " + rerr.Error()
|
||||
continue
|
||||
}
|
||||
vlog.Printf("sign-in: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "")
|
||||
if key != "" {
|
||||
return key, true, ""
|
||||
}
|
||||
key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName())
|
||||
pw = ""
|
||||
if lerr != nil {
|
||||
if errors.Is(lerr, auth.ErrTOTPRequired) {
|
||||
return "", false, "Your password was changed. Sign in with your new password and your 6-digit authenticator code."
|
||||
}
|
||||
return "", false, "Your password was changed, but signing in failed: " + lerr.Error() + "\r\nSign in with your new password."
|
||||
}
|
||||
return key, true, ""
|
||||
}
|
||||
return "", false, ""
|
||||
}
|
||||
return "", false, ""
|
||||
}
|
||||
|
||||
// pasteFlow: masked key field + portal button; validated before saving.
|
||||
func pasteFlow(reason string) bool {
|
||||
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below."
|
||||
|
|
|
|||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
|
@ -22,6 +20,15 @@ import (
|
|||
|
||||
const dlgTitle = "Verae Print Stamp"
|
||||
|
||||
const (
|
||||
machineWord = "Mac"
|
||||
keyStoreDesc = "Keychain + ~/.verae"
|
||||
)
|
||||
|
||||
func openURL(u string) { _ = exec.Command("open", u).Start() }
|
||||
|
||||
func guiAvailable() bool { return true }
|
||||
|
||||
// AppleScript gets all user-visible text via argv (no string interpolation →
|
||||
// no injection). Output: "<button>\t<text returned>" or "CANCEL".
|
||||
const dialogScript = `on run argv
|
||||
|
|
@ -60,8 +67,6 @@ const chooseScript = `on run argv
|
|||
return item 1 of r
|
||||
end run`
|
||||
|
||||
var errCancel = errors.New("cancelled")
|
||||
|
||||
func osa(script string, args ...string) (string, error) {
|
||||
cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...)
|
||||
out, err := cmd.Output()
|
||||
|
|
@ -103,14 +108,6 @@ func choose(msg string, opts []string) (string, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
optKeep = "Keep the existing key"
|
||||
optLogin = "Sign in to my Verae account (email + password)"
|
||||
optCreate = "Create a new Verae account"
|
||||
optPaste = "Paste an xVerae- key"
|
||||
optSkip = "Skip for now"
|
||||
)
|
||||
|
||||
func runMacKeySetup(args []string) int {
|
||||
fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError)
|
||||
askOpen := fs.Bool("ask-open", true, "also ask what to open after each print")
|
||||
|
|
@ -143,217 +140,6 @@ func runMacKeySetup(args []string) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key.
|
||||
func acquireKeyInteractive(reason string) bool {
|
||||
_, saved := keyFlow(reason)
|
||||
return saved
|
||||
}
|
||||
|
||||
// keyFlow returns a masked result string and whether a NEW key was saved.
|
||||
func keyFlow(reason string) (string, bool) {
|
||||
existing, _ := auth.LoadXVeraeToken()
|
||||
caps := auth.GetCapabilities()
|
||||
vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "")
|
||||
var opts []string
|
||||
intro := "No Verae signing key was found for this Mac user. You need one to timestamp documents."
|
||||
if existing != "" {
|
||||
st, det := auth.ValidateXVeraeKey(existing)
|
||||
vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det)
|
||||
switch st {
|
||||
case "valid":
|
||||
intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)."
|
||||
opts = append(opts, optKeep)
|
||||
case "invalid":
|
||||
intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key."
|
||||
default:
|
||||
intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)."
|
||||
opts = append(opts, optKeep)
|
||||
}
|
||||
}
|
||||
opts = append(opts, optLogin, optCreate, optPaste, optSkip)
|
||||
if reason != "" {
|
||||
intro = reason + "\n\n" + intro
|
||||
}
|
||||
prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (Keychain + ~/.verae)."
|
||||
for {
|
||||
c, err := choose(prompt, opts)
|
||||
if err != nil {
|
||||
c = optSkip
|
||||
}
|
||||
var key string
|
||||
switch c {
|
||||
case optKeep:
|
||||
return "kept " + auth.MaskKey(existing), false
|
||||
case optLogin:
|
||||
key, err = macLogin(caps)
|
||||
case optCreate:
|
||||
key, err = macCreate(caps)
|
||||
case optPaste:
|
||||
key, err = macPaste()
|
||||
default:
|
||||
btn, _, _ := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or open Verae Print Stamp from Applications).", "", false, false, []string{"Go back", "Skip"}, "Go back")
|
||||
if btn == "Skip" {
|
||||
return "skipped", false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errCancel) {
|
||||
continue // back to the menu
|
||||
}
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: %v", err)
|
||||
say("Could not get a key: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if err := auth.SaveXVeraeToken(key); err != nil {
|
||||
vlog.Errorf("mac-key-setup: save failed: %v", err)
|
||||
say("The key could not be saved: " + err.Error())
|
||||
continue
|
||||
}
|
||||
vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key))
|
||||
say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".")
|
||||
return "saved " + auth.MaskKey(key), true
|
||||
}
|
||||
}
|
||||
|
||||
func macLogin(caps auth.Capabilities) (string, error) {
|
||||
email := ""
|
||||
for {
|
||||
btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
email = strings.TrimSpace(e)
|
||||
if !auth.ValidEmail(email) {
|
||||
say("Please enter a valid email address.")
|
||||
continue
|
||||
}
|
||||
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
|
||||
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
|
||||
if errors.Is(err, auth.ErrTOTPRequired) {
|
||||
btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify")
|
||||
if cerr != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName())
|
||||
}
|
||||
pw = ""
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
|
||||
say("Sign-in failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
func macCreate(caps auth.Capabilities) (string, error) {
|
||||
var name, email string
|
||||
for {
|
||||
btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
name = strings.TrimSpace(n)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
email = strings.TrimSpace(e)
|
||||
if !auth.ValidEmail(email) {
|
||||
say("Please enter a valid email address.")
|
||||
continue
|
||||
}
|
||||
policy := caps.Policy.Describe()
|
||||
btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
if perr := caps.Policy.Check(pw, email); perr != nil {
|
||||
say(perr.Error())
|
||||
continue
|
||||
}
|
||||
btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
if pw != pw2 {
|
||||
say("The passwords do not match.")
|
||||
continue
|
||||
}
|
||||
pw2 = ""
|
||||
vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI)
|
||||
rr, err := auth.InstallRegister(caps, name, email, pw, tokenName())
|
||||
pw = ""
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err)
|
||||
say("Could not create the account: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if rr.PendingID == "" {
|
||||
return rr.Key, nil // server without email verification (legacy signup)
|
||||
}
|
||||
vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email))
|
||||
msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:"
|
||||
for {
|
||||
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify")
|
||||
if err != nil || btn == "Cancel" {
|
||||
return "", errCancel
|
||||
}
|
||||
if btn == "Send a new code" {
|
||||
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
|
||||
say("Could not send a new code: " + rerr.Error())
|
||||
} else {
|
||||
say("A new code was sent.")
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, verr := auth.InstallVerify(rr.PendingID, code, tokenName())
|
||||
if verr != nil {
|
||||
say(verr.Error())
|
||||
continue
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func macPaste() (string, error) {
|
||||
for {
|
||||
btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
if btn == "Open portal" {
|
||||
_ = exec.Command("open", auth.PortalSignupURL).Start()
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
st, det := auth.ValidateXVeraeKey(k)
|
||||
vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det)
|
||||
switch st {
|
||||
case "valid":
|
||||
return k, nil
|
||||
case "invalid":
|
||||
say("Verae rejected this key (" + det + "). Please check it and paste again.")
|
||||
case "badformat":
|
||||
say("That does not look like a Verae key: " + det)
|
||||
default:
|
||||
b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway")
|
||||
if b == "Save anyway" {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askOpenOnComplete() string {
|
||||
cur, _ := settings.Load()
|
||||
def := map[string]string{settings.OpenFolder: "Show in Finder", settings.OpenPDF: "Open the PDF", settings.OpenNone: "Do nothing"}[cur.OpenOnComplete]
|
||||
|
|
|
|||
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/veraeapi"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -38,6 +39,12 @@ func main() {
|
|||
os.Exit(runValidateKey(os.Args[2:]))
|
||||
case "config":
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "pending", "queue":
|
||||
os.Exit(runPendingList(os.Args[2:]))
|
||||
case "tray-status", "status":
|
||||
os.Exit(runTrayStatus(os.Args[2:]))
|
||||
case "menubar", "tray":
|
||||
os.Exit(runMenubar(os.Args[2:]))
|
||||
case "mac-key-setup":
|
||||
os.Exit(runMacKeySetup(os.Args[2:]))
|
||||
case "key-dialog":
|
||||
|
|
@ -79,6 +86,11 @@ Usage:
|
|||
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
|
||||
verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH]
|
||||
# after each print: reveal the certificate (folder, default), open it (pdf), or nothing
|
||||
verae-print pending [--json|--html] # PT-04/W4 job queue (brand labels; hold id ≠ evidence)
|
||||
verae-print tray-status [--json|--open] # PT-W3 tray substitute: held count, key, helper, queue URL
|
||||
verae-print menubar [--addr host:port] [--install|--uninstall|--foreground]
|
||||
# PT-W3/PT-06: Windows NotifyIcon / macOS menu bar (GET /status)
|
||||
# --install: Win Startup shortcut or macOS LaunchAgent at login
|
||||
verae-print mac-key-setup # macOS: native dialogs to keep/sign in/create account/paste a key (run as the user)
|
||||
verae-print version
|
||||
|
||||
|
|
@ -214,12 +226,37 @@ func runStamp(args []string) int {
|
|||
}
|
||||
lj.Printf("stamp: key rejected (%v); job HELD as pending %s", err, m.ID)
|
||||
fmt.Printf("Verae did not accept the signing key; the job is saved (pending %s) and will be timestamped after you sign in.\n", m.ID)
|
||||
notify(statuslang.NotifyTitle("held"), notifyQueueHint(statuslang.NotifyBody("held", title, m.ID)))
|
||||
handleAuthFailure(authReason(err), stampFile)
|
||||
return 0
|
||||
}
|
||||
if err != nil && isBillingFailure(err) {
|
||||
title := *name
|
||||
if title == "" {
|
||||
title = filepath.Base(pdfPath)
|
||||
}
|
||||
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
|
||||
if herr != nil {
|
||||
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Add funds: %s\n", auth.ZapBillingURL)
|
||||
_ = openBrowser(auth.ZapBillingURL)
|
||||
return 1
|
||||
}
|
||||
lj.Printf("stamp: out of credits (%v); job HELD as pending %s — opening Billing", err, m.ID)
|
||||
fmt.Printf("Out of credits; the job is saved (pending %s). Opening Billing so you can add funds:\n %s\n", m.ID, auth.ZapBillingURL)
|
||||
notify(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", title, m.ID)))
|
||||
handleBillingFailure(billingReason(err))
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
lj.Errorf("stamp failed: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||
failTitle := *name
|
||||
if failTitle == "" {
|
||||
failTitle = filepath.Base(pdfPath)
|
||||
}
|
||||
notify(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", failTitle, err.Error()))
|
||||
return 1
|
||||
}
|
||||
lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF)
|
||||
|
|
@ -235,8 +272,11 @@ func runStamp(args []string) int {
|
|||
done, remaining, _ := retryPending(stampFile)
|
||||
lj.Printf("pending: %d stamped, %d remaining", done, remaining)
|
||||
}
|
||||
// macOS notification (best-effort)
|
||||
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
docTitle := *name
|
||||
if docTitle == "" {
|
||||
docTitle = filepath.Base(pdfPath)
|
||||
}
|
||||
notify(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", docTitle, res.ReceiptID))
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
@ -266,14 +306,40 @@ func runInstallPrinter() int {
|
|||
}
|
||||
|
||||
func repoRootHint() string {
|
||||
// Prefer known research path; else cwd
|
||||
candidates := []string{
|
||||
"/Users/marchon/research/PrintToVeraeTimeStamp",
|
||||
".",
|
||||
// Prefer the go.mod tree that owns this binary (A4 packaging source of truth).
|
||||
// No machine-specific absolute paths — walk cwd and exe parents.
|
||||
var starts []string
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
starts = append(starts, wd)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(filepath.Join(c, "platform", "macos", "ipp", "install-ipp-printer.sh")); err == nil && !st.IsDir() {
|
||||
return c
|
||||
starts = append(starts, exeDirHint())
|
||||
if exe := exeDirHint(); exe != "." {
|
||||
starts = append(starts, filepath.Join(exe, ".."), filepath.Join(exe, "..", ".."))
|
||||
}
|
||||
starts = append(starts, ".")
|
||||
seen := map[string]bool{}
|
||||
for _, start := range starts {
|
||||
dir, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if seen[dir] {
|
||||
break
|
||||
}
|
||||
seen[dir] = true
|
||||
if fileExists(filepath.Join(dir, "go.mod")) {
|
||||
// Any go.mod with platform/ is good enough for install scripts.
|
||||
if fileExists(filepath.Join(dir, "platform")) ||
|
||||
fileExists(filepath.Join(dir, "internal", "job", "job.go")) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
return "."
|
||||
|
|
|
|||
584
cmd/verae-print/menubar.go
Normal file
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/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -25,6 +26,7 @@ import (
|
|||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
|
@ -35,6 +37,9 @@ var errNoKey = errors.New("no Verae signing key is set up")
|
|||
// isAuthFailure: the job can succeed after the user signs in again.
|
||||
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
|
||||
|
||||
// isBillingFailure: the job can succeed after the user adds funds on Zap Billing.
|
||||
func isBillingFailure(err error) bool { return veraeapi.IsBillingError(err) }
|
||||
|
||||
type pendingMeta struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -160,13 +165,18 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
|
|||
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
|
||||
return done, len(listPending()), true
|
||||
}
|
||||
if isBillingFailure(err) {
|
||||
lj.Errorf("pending: %s still needs funds: %v", m.ID, err)
|
||||
offerBillingCheckout(billingReason(err))
|
||||
return done, len(listPending()), false
|
||||
}
|
||||
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
|
||||
continue
|
||||
}
|
||||
removePending(m)
|
||||
done++
|
||||
lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF)
|
||||
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", m.Title, res.ReceiptID))
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
}
|
||||
return done, len(listPending()), false
|
||||
|
|
@ -194,6 +204,35 @@ func promptLock() (func(), bool) {
|
|||
return func() {}, false
|
||||
}
|
||||
|
||||
// billingOpenThrottle is how long we suppress re-opening the Billing tab
|
||||
// after a credit failure (avoids a browser storm while jobs stay held).
|
||||
const billingOpenThrottle = 10 * time.Minute
|
||||
|
||||
// offerBillingCheckout opens the Zap Billing reload page at most once per
|
||||
// billingOpenThrottle. Safe across processes via a lock file in pendingDir.
|
||||
func offerBillingCheckout(reason string) {
|
||||
_ = os.MkdirAll(pendingDir(), 0o700)
|
||||
marker := filepath.Join(pendingDir(), ".billing.open")
|
||||
if st, err := os.Stat(marker); err == nil && time.Since(st.ModTime()) < billingOpenThrottle {
|
||||
vlog.Printf("billing: checkout already offered %s ago; not re-opening (%d pending)",
|
||||
time.Since(st.ModTime()).Round(time.Second), len(listPending()))
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)+"\n"+reason+"\n"), 0o600)
|
||||
vlog.Printf("billing: opening %s (%d pending)", auth.ZapBillingURL, len(listPending()))
|
||||
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(reason))
|
||||
if err := openBrowser(auth.ZapBillingURL); err != nil {
|
||||
vlog.Errorf("billing: could not open browser: %v — open %s", err, auth.ZapBillingURL)
|
||||
fmt.Fprintf(os.Stderr, "Out of credits. Open Billing to add funds:\n %s\n", auth.ZapBillingURL)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBillingFailure holds the print job narrative for the user and opens
|
||||
// Zap Billing. Does not prompt for a new signing key.
|
||||
func handleBillingFailure(reason string) {
|
||||
offerBillingCheckout(reason)
|
||||
}
|
||||
|
||||
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
|
||||
// pending jobs. Returns true when everything pending was stamped.
|
||||
func handleAuthFailure(reason string, stamp stampFn) bool {
|
||||
|
|
@ -216,7 +255,8 @@ func handleAuthFailure(reason string, stamp stampFn) bool {
|
|||
}
|
||||
if !acquire(msg) {
|
||||
vlog.Printf("pending: sign-in cancelled; %d job(s) kept in %s", n, pendingDir())
|
||||
notifyUser("Verae: print jobs waiting", fmt.Sprintf("%d job(s) will be timestamped after you sign in.", n))
|
||||
ht, hb := statuslang.NotifyHeldJobs(n)
|
||||
notifyUser(ht, notifyQueueHint(hb))
|
||||
return false
|
||||
}
|
||||
markKeySeen()
|
||||
|
|
@ -258,3 +298,174 @@ func keyChangedSinceSeen() bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runPendingList prints the held job queue (PT-04 / PT-W4 style) using brand status strings.
|
||||
// Flags: -json (machine-readable), -html (self-contained local queue page).
|
||||
func runPendingList(args []string) int {
|
||||
fs := flag.NewFlagSet("pending", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
asJSON := fs.Bool("json", false, "machine-readable JSON (held jobs only; hold id is not evidence)")
|
||||
asHTML := fs.Bool("html", false, "self-contained HTML job queue (PT-04/W4 labels)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
list := listPending()
|
||||
if *asJSON {
|
||||
b, err := encodePendingJSON(list, traySnapshot(""))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
_, _ = os.Stdout.Write(b)
|
||||
return 0
|
||||
}
|
||||
if *asHTML {
|
||||
_, err := io.WriteString(os.Stdout, renderPendingHTML(list, traySnapshot("")))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Printf("Pending print jobs · %s\n", statuslang.Pending)
|
||||
fmt.Printf("Folder: %s\n", pendingDir())
|
||||
if len(list) == 0 {
|
||||
fmt.Println("(empty) — nothing waiting for a signing key")
|
||||
fmt.Println("Note:", statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
fmt.Printf("%-22s %-28s %-16s %s\n", "HOLD_ID", "TITLE", "STATE", "CREATED")
|
||||
for _, m := range list {
|
||||
title := m.Title
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
if len(title) > 28 {
|
||||
title = title[:25] + "…"
|
||||
}
|
||||
fmt.Printf("%-22s %-28s %-16s %s\n", m.ID, title, statuslang.Pending, m.Created)
|
||||
}
|
||||
fmt.Printf("\n%d held · sign in / setup key to stamp\n", len(list))
|
||||
fmt.Println(statuslang.EvidenceNote)
|
||||
return 0
|
||||
}
|
||||
|
||||
type pendingJSONRow struct {
|
||||
HoldID string `json:"holdId"`
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source,omitempty"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
type pendingJSONOut struct {
|
||||
Headline string `json:"headline"`
|
||||
Detail string `json:"detail"`
|
||||
State string `json:"state"`
|
||||
Folder string `json:"folder"`
|
||||
Count int `json:"count"`
|
||||
KeyOK bool `json:"keyOk"`
|
||||
HelperUp bool `json:"helperUp"`
|
||||
QueueURL string `json:"queueUrl,omitempty"`
|
||||
Note string `json:"note"`
|
||||
Jobs []pendingJSONRow `json:"jobs"`
|
||||
}
|
||||
|
||||
func encodePendingJSON(list []pendingMeta, snap traySnap) ([]byte, error) {
|
||||
out := pendingJSONOut{
|
||||
Headline: snap.Headline,
|
||||
Detail: snap.Detail,
|
||||
State: statuslang.Pending,
|
||||
Folder: pendingDir(),
|
||||
Count: len(list),
|
||||
KeyOK: snap.KeyOK,
|
||||
HelperUp: snap.HelperUp,
|
||||
QueueURL: snap.QueueURL,
|
||||
Note: statuslang.EvidenceNote,
|
||||
Jobs: make([]pendingJSONRow, 0, len(list)),
|
||||
}
|
||||
if len(list) == 0 && snap.KeyOK {
|
||||
out.State = statuslang.PrinterReady
|
||||
}
|
||||
for _, m := range list {
|
||||
title := m.Title
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
out.Jobs = append(out.Jobs, pendingJSONRow{
|
||||
HoldID: m.ID, Title: title, Source: m.Source,
|
||||
State: statuslang.Pending, Reason: m.Reason, Created: m.Created,
|
||||
})
|
||||
}
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
func htmlEsc(s string) string {
|
||||
return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).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)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func notify(title, subtitle string) {
|
||||
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
// notify shows an OS banner. title should be statuslang.NotifyTitle; body NotifyBody.
|
||||
func notify(title, body string) {
|
||||
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
|
||||
if title == "" {
|
||||
title = "Verae Timestamp"
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
_ = exec.Command("notify-send", "-a", "Verae Timestamp", title, body).Run()
|
||||
}
|
||||
return
|
||||
}
|
||||
if runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
// Brand status string is the notification title (PT-06 / PT-W3).
|
||||
script := fmt.Sprintf(`display notification %q with title %q`, body, title)
|
||||
_ = exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +49,3 @@ func openFolderPath(p string) {
|
|||
func runInstallPrinterWindows() int { return 1 }
|
||||
|
||||
func debugFromRegistry() bool { return false }
|
||||
|
||||
func runKeyDialog() int {
|
||||
fmt.Println("key-dialog is Windows-only; run: verae-print setup")
|
||||
return 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,42 @@ func exeDir() string {
|
|||
}
|
||||
|
||||
// notifyUser: best-effort balloon via PowerShell (no extra deps). Always logs.
|
||||
// BalloonTipClicked / DoubleClick open the live loopback /queue (PT-W3).
|
||||
func notifyUser(title, body string) {
|
||||
vlog.Printf("notify: %s — %s", title, body)
|
||||
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info);Start-Sleep 9;$n.Dispose()`, esc(title), esc(body))
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
esc := func(s string) string {
|
||||
s = strings.ReplaceAll(s, "'", "''")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return s
|
||||
}
|
||||
queue := "http://127.0.0.1:8631/queue"
|
||||
if serveListenAddr != "" && isLoopbackAddr(serveListenAddr) {
|
||||
queue = queueURLFor(serveListenAddr)
|
||||
} else if a := os.Getenv("VERAE_IPP_ADDR"); a != "" && isLoopbackAddr(a) {
|
||||
queue = queueURLFor(a)
|
||||
}
|
||||
// STA + DoEvents so BalloonTipClicked can open /queue before dispose.
|
||||
ps := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$q='%s'
|
||||
$n=New-Object System.Windows.Forms.NotifyIcon
|
||||
$n.Icon=[System.Drawing.SystemIcons]::Information
|
||||
$n.Visible=$true
|
||||
$n.Text='Verae Timestamping'
|
||||
$open={ try { Start-Process $q } catch {} }
|
||||
$n.add_BalloonTipClicked($open)
|
||||
$n.add_DoubleClick($open)
|
||||
$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info)
|
||||
$sw=[Diagnostics.Stopwatch]::StartNew()
|
||||
while($sw.Elapsed.TotalSeconds -lt 9){ [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 120 }
|
||||
$n.Visible=$false; $n.Dispose()
|
||||
`, esc(queue), esc(title), esc(body))
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-STA", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
hideWindow(cmd)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
|
||||
)
|
||||
|
|
@ -74,8 +75,22 @@ func runServe(args []string) int {
|
|||
go pendingOnStart()
|
||||
errc := make(chan error, 2)
|
||||
if !*noIPP {
|
||||
srv := &winprint.IPPServer{Addr: *addr, Name: "Verae TimeStamp PDF", SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc}
|
||||
serveListenAddr = *addr
|
||||
srv := &winprint.IPPServer{
|
||||
Addr: *addr, Name: "Verae TimeStamp PDF",
|
||||
SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc,
|
||||
// PT-W3 tray substitute: GET-only status on the same loopback port as IPP.
|
||||
// POST remains pure IPP — Windows print clients are unaffected.
|
||||
ExtraHealth: helperExtraHealth,
|
||||
ServeQueue: handleQueueHTTP,
|
||||
ServeStatus: handleStatusHTTP,
|
||||
}
|
||||
go func() { errc <- srv.ListenAndServe() }()
|
||||
vlog.Printf("status: queue http://%s/queue · status http://%s/status (GET only; hold id ≠ evidence)", *addr, *addr)
|
||||
if n := len(listPending()); n > 0 {
|
||||
ht, hb := statuslang.NotifyHeldJobs(n)
|
||||
notifyUser(ht, notifyQueueHint(hb))
|
||||
}
|
||||
}
|
||||
if !*noWatch {
|
||||
w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc}
|
||||
|
|
@ -134,14 +149,27 @@ func makeProcessor(outDir string) winprint.Processor {
|
|||
return err
|
||||
}
|
||||
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, err)
|
||||
notifyUser(statuslang.NotifyTitle("held"), notifyQueueHint(statuslang.NotifyBody("held", m.Title, m.ID)))
|
||||
go handleAuthFailure(authReason(err), stampFile)
|
||||
return nil // the job is safe; IPP reports success to Windows
|
||||
}
|
||||
notifyUser("Verae timestamp FAILED", err.Error())
|
||||
if isBillingFailure(err) {
|
||||
m, herr := holdJob(j.Path, target, j.Source, err, true)
|
||||
if herr != nil {
|
||||
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
|
||||
go offerBillingCheckout(billingReason(err))
|
||||
return err
|
||||
}
|
||||
lj.Printf("process: HELD as pending %s (%v); opening Zap Billing", m.ID, err)
|
||||
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", m.Title, m.ID)))
|
||||
go handleBillingFailure(billingReason(err))
|
||||
return nil // job held; user adds funds then reprint / retry pending
|
||||
}
|
||||
notifyUser(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", j.Title, err.Error()))
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(j.Path)
|
||||
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", j.Title, res.ReceiptID))
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
// a successful stamp proves the key works: flush anything still pending
|
||||
if len(listPending()) > 0 {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue