Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8444ec9be |
||
|
|
8f55e17f73 |
150 changed files with 967 additions and 18819 deletions
24
.forgejo/workflows/test-and-deploy.yml
Normal file
24
.forgejo/workflows/test-and-deploy.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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
|
||||
96
.github/workflows/installers.yml
vendored
96
.github/workflows/installers.yml
vendored
|
|
@ -1,96 +0,0 @@
|
|||
name: installers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
netprint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
- name: Toolchain
|
||||
run: sudo apt-get update && sudo apt-get install -y nsis cpio g++ git
|
||||
- name: Build driver and network-printer installers
|
||||
run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: verae-netprint-linux
|
||||
path: |
|
||||
build/netprint/*.deb
|
||||
build/netprint/*.exe
|
||||
build/netprint/macos/**
|
||||
if-no-files-found: error
|
||||
|
||||
macos-pkg:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
- name: Build arm64 binary, AirDrop helper, and package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=0.3.4
|
||||
OUT="$PWD/build/netprint/macos"
|
||||
mkdir -p "$OUT"
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/verae-print" ./cmd/verae-print
|
||||
chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh
|
||||
VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh
|
||||
cp -f "$OUT/verae-print" "$OUT/verae-print-arm64"
|
||||
APP="$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS"
|
||||
test -x "$APP/verae-airdrop"
|
||||
test -x "$APP/verae-menubar"
|
||||
test -x "$APP/verae-print"
|
||||
test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist"
|
||||
test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg"
|
||||
"$APP/verae-menubar" --smoke | tee "$OUT/menubar-smoke.txt"
|
||||
"$APP/verae-print" tray --smoke | tee "$OUT/tray-smoke.txt"
|
||||
grep -E 'smoke ok|no window server' "$OUT/menubar-smoke.txt"
|
||||
grep -E 'smoke ok|no window server' "$OUT/tray-smoke.txt"
|
||||
- name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo
|
||||
run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: verae-netprint-macos
|
||||
path: |
|
||||
build/netprint/macos/*.pkg
|
||||
build/netprint/macos/verae-print
|
||||
build/netprint/macos/verae-print-arm64
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: [netprint, macos-pkg]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: verae-netprint-linux
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: verae-netprint-macos
|
||||
path: macpkg
|
||||
- name: SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f dist/macos/*.pkg dist/SHA256SUMS
|
||||
mkdir -p dist/macos
|
||||
cp -f macpkg/*.pkg macpkg/verae-print macpkg/verae-print-arm64 dist/macos/
|
||||
cd dist
|
||||
sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh | tee SHA256SUMS
|
||||
test -f macos/VeraeNetPrint-macos-arm64-0.3.4.pkg
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: verae-netprint-installers
|
||||
path: |
|
||||
dist/*.deb
|
||||
dist/*.exe
|
||||
dist/SHA256SUMS
|
||||
dist/macos/**
|
||||
if-no-files-found: error
|
||||
13
Makefile
13
Makefile
|
|
@ -1,13 +0,0 @@
|
|||
.PHONY: test netprint-debs netprint-windows netprint-macos installers
|
||||
|
||||
VERSION ?= 0.3.4
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
netprint-debs:
|
||||
VERSION=$(VERSION) ./platform/linux/deb/build-deb.sh amd64
|
||||
VERSION=$(VERSION) ./platform/linux/deb/build-netprint-deb.sh amd64
|
||||
|
||||
netprint-windows netprint-macos installers:
|
||||
VERSION=$(VERSION) ./scripts/build-netprint-installers.sh
|
||||
15
README.md
15
README.md
|
|
@ -26,14 +26,6 @@ verae-print setup
|
|||
verae-print stamp document.pdf
|
||||
```
|
||||
|
||||
**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. On Windows the service advertises through DNS-SD (`DnsServiceRegister`) because the DNS Client owns UDP 5353, writes `%ProgramData%\Verae\logs\netprint.log`, and the setup starts the tray for the logged-in user. Public networks and a local Windows queue stay off unless you opt in. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md).
|
||||
|
||||
| OS | Standalone | With the driver (off unless you opt in) |
|
||||
|----|------------|------------------------------------------|
|
||||
| Windows | `VeraeNetPrint-windows-amd64-setup.exe` | Checkbox on the driver setup’s options page |
|
||||
| macOS | `VeraeNetPrint-macos.pkg` | Customize → Network timestamp printer |
|
||||
| Debian | `sudo apt install ./verae-netprint_amd64.deb` | `sudo apt install ./verae-print_amd64.deb` and answer the prompt, or install `verae-netprint` later |
|
||||
|
||||
**Linux (tarball, any distro):**
|
||||
|
||||
```bash
|
||||
|
|
@ -72,13 +64,6 @@ printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once
|
|||
chmod 600 ~/.verae/xverae-token
|
||||
```
|
||||
|
||||
Build the network-printer installers (Debian packages, Windows NSIS setups, macOS binaries and a `.pkg` when `pkgbuild` is available):
|
||||
|
||||
```bash
|
||||
make installers
|
||||
# artifacts: build/netprint/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
In any app: **File → Print → Destination → Verae Timestamping → Print**.
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
// Command verae-netprint-tray is the Windows notification-area build of the
|
||||
// network-printer menu. It is the same control code as `verae-print tray`,
|
||||
// linked as a GUI-subsystem binary so it does not open a console.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := netprint.RunTray(context.Background()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build !windows && !darwin && !linux
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -3,52 +3,10 @@ package main
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func TestNetworkStampUsesDesktopPath(t *testing.T) {
|
||||
netprintSrc, err := os.ReadFile("netprint_cmd.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := os.ReadFile("stampcore.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(netprintSrc)
|
||||
if !strings.Contains(text, "stampWithClient(") || strings.Contains(text, "mock-cert") || strings.Contains(text, `DocumentName: "document.pdf"`) {
|
||||
t.Fatal("netprint stamp is not the desktop stamp path")
|
||||
}
|
||||
if !strings.Contains(string(core), "func stampWithClient(") || !strings.Contains(string(core), "stampWithClient(pdf, t.Title") {
|
||||
t.Fatal("stampFile does not use stampWithClient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) {
|
||||
if code := runNetprint([]string{"launch-server"}); code != 2 {
|
||||
t.Fatalf("unknown command exit %d, want 2", code)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
// Linux has no tray launcher. The command must fail before any listen.
|
||||
if code := runNetprint([]string{"launch-tray"}); code != 1 {
|
||||
t.Fatalf("launch-tray exit %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrayUnknownArgument(t *testing.T) {
|
||||
old := os.Args
|
||||
t.Cleanup(func() { os.Args = old })
|
||||
os.Args = []string{"verae-print", "tray", "--nope"}
|
||||
if code := runTrayCmd(); code != 2 {
|
||||
t.Fatalf("tray exit %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTextFileUTF16(t *testing.T) {
|
||||
s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n"
|
||||
u := utf16.Encode([]rune(s))
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
//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,8 +45,6 @@ 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
|
||||
|
|
@ -55,35 +53,7 @@ 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)" }
|
||||
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'
|
||||
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
|
||||
}
|
||||
'create' {
|
||||
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
|
||||
|
|
@ -173,21 +143,9 @@ 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() + "\r\nForgot your password? Click \"Forgot password?\" below."
|
||||
msg = "Sign-in failed: " + err.Error()
|
||||
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 {
|
||||
|
|
@ -273,95 +231,6 @@ 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."
|
||||
|
|
|
|||
|
|
@ -1,365 +0,0 @@
|
|||
//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,12 +3,14 @@
|
|||
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"
|
||||
)
|
||||
|
|
@ -20,15 +22,6 @@ 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
|
||||
|
|
@ -67,6 +60,8 @@ 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()
|
||||
|
|
@ -108,6 +103,14 @@ 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")
|
||||
|
|
@ -140,6 +143,217 @@ 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]
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
//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
|
||||
}
|
||||
|
|
@ -21,9 +21,6 @@ func main() {
|
|||
if len(os.Args) < 2 && isHelperExe() {
|
||||
os.Args = append(os.Args, "serve")
|
||||
}
|
||||
if len(os.Args) < 2 && (isTrayExe() || isMenuBarApp()) {
|
||||
os.Args = append(os.Args, "tray")
|
||||
}
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
|
|
@ -33,10 +30,6 @@ func main() {
|
|||
os.Exit(runStamp(os.Args[2:]))
|
||||
case "serve":
|
||||
os.Exit(runServe(os.Args[2:]))
|
||||
case "netprint":
|
||||
os.Exit(runNetprint(os.Args[2:]))
|
||||
case "tray":
|
||||
os.Exit(runTrayCmd())
|
||||
case "ipp-selftest":
|
||||
os.Exit(runIPPSelftest(os.Args[2:]))
|
||||
case "installer-account":
|
||||
|
|
@ -83,13 +76,6 @@ Usage:
|
|||
verae-print install-macos # also install PDF Services menu items (legacy)
|
||||
verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug]
|
||||
# Windows print helper: IPP loopback printer + fallback watcher
|
||||
verae-print netprint [--config FILE] [--name NAME] [--port N] [--data-dir DIR] [--forward TARGET] [--no-mdns]
|
||||
# LAN IPP Everywhere printer
|
||||
verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue]
|
||||
verae-print netprint uninstall --owner driver|standalone
|
||||
verae-print netprint launch-tray # Windows: start the tray as the logged-in user
|
||||
verae-print netprint enable-receipts-share # macOS: save shareReceiptsSMB and publish the folder
|
||||
verae-print tray # menu-bar / system-tray control for the network printer
|
||||
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
|
||||
verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH]
|
||||
# after each print: reveal the certificate (folder, default), open it (pdf), or nothing
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
func runNetprint(args []string) int {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "install", "uninstall", "launch-tray", "enable-receipts-share":
|
||||
return runNetprintInstall(args)
|
||||
}
|
||||
// An unknown word must not fall through into the server. That is how
|
||||
// `netprint launch-tray` on an older binary started a second listener.
|
||||
if !strings.HasPrefix(args[0], "-") {
|
||||
fmt.Fprintf(os.Stderr, "unknown netprint command %q (not starting the server)\n", args[0])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("netprint", flag.ContinueOnError)
|
||||
config := fs.String("config", "", "config file (default: system netprint.json)")
|
||||
name := fs.String("name", "", "printer name on the network")
|
||||
port := fs.Int("port", 0, "IPP port (631, or the fallback when 631 is taken)")
|
||||
dataDir := fs.String("data-dir", "", "where receipts are stored")
|
||||
forward := fs.String("forward", "", "queue name or ipp:// URI to forward the PDF to")
|
||||
noMDNS := fs.Bool("no-mdns", false, "do not advertise with mDNS")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
flags := netprint.Config{Name: *name, Port: *port, DataDir: *dataDir, ForwardPrinter: *forward}
|
||||
if *noMDNS {
|
||||
off := false
|
||||
flags.Advertise = &off
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := netprint.RunCommand(ctx, *config, flags, stampNetwork); err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runNetprintInstall(args []string) int {
|
||||
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
|
||||
owner := fs.String("owner", "standalone", "driver or standalone")
|
||||
token := fs.String("token-file", "", "optional xVerae- token file to copy for the service")
|
||||
allowPublic := fs.String("allow-public", "", "true or false; empty leaves the saved choice")
|
||||
localQueue := fs.Bool("local-queue", false, "add a local Windows IPP queue named Verae Timestamp")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
var err error
|
||||
switch args[0] {
|
||||
case "launch-tray":
|
||||
err = netprint.LaunchTrayNow()
|
||||
case "enable-receipts-share":
|
||||
err = netprint.EnableReceiptsShare()
|
||||
case "install":
|
||||
pub, perr := parseAllowPublic(*allowPublic)
|
||||
if perr != nil {
|
||||
fmt.Fprintln(os.Stderr, perr)
|
||||
return 2
|
||||
}
|
||||
err = netprint.Install(*owner, *token, netprint.InstallOptions{AllowPublic: pub, LocalQueue: *localQueue})
|
||||
default:
|
||||
err = netprint.Uninstall(*owner)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(args[0], "ok (", *owner, ")")
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseAllowPublic(s string) (*bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return nil, nil
|
||||
case "true", "1", "yes":
|
||||
v := true
|
||||
return &v, nil
|
||||
case "false", "0", "no":
|
||||
v := false
|
||||
return &v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("allow-public must be true or false")
|
||||
}
|
||||
}
|
||||
|
||||
func runTrayCmd() (code int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "tray panic: %v\n", r)
|
||||
code = 1
|
||||
}
|
||||
}()
|
||||
for _, a := range os.Args[2:] {
|
||||
switch a {
|
||||
case "--smoke", "-smoke":
|
||||
_ = os.Setenv("VERAE_TRAY_SMOKE", "1")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown tray argument %q\n", a)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if err := netprint.RunTray(context.Background()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func stampNetwork(pdf, title, user string) (job.Result, error) {
|
||||
_ = user
|
||||
// Same client, document name, and job.Run call as a desktop Print-Job.
|
||||
return stampWithClient(pdf, title, filepath.Dir(pdf), "", vlog.Job(vlog.NewJobID("net")))
|
||||
}
|
||||
|
|
@ -12,16 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func notify(title, subtitle string) {
|
||||
if os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
_ = exec.Command("notify-send", "-a", "Verae Timestamp", "Verae: "+title, subtitle).Run()
|
||||
}
|
||||
return
|
||||
}
|
||||
if runtime.GOOS != "darwin" {
|
||||
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
|
||||
|
|
@ -44,3 +35,8 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,24 +28,6 @@ func isHelperExe() bool {
|
|||
return strings.Contains(strings.ToLower(filepath.Base(exe)), "printhelper")
|
||||
}
|
||||
|
||||
func isTrayExe() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(filepath.Base(exe)), "netprint-tray")
|
||||
}
|
||||
|
||||
// isMenuBarApp reports whether this process is the macOS menu-bar bundle.
|
||||
// Opening Verae NetPrint.app with no arguments starts the tray.
|
||||
func isMenuBarApp() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(filepath.Clean(exe), "Verae NetPrint.app"+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// fallbackDropDir is the folder the "Verae TimeStamp PDF (Local Port)" queue writes into.
|
||||
func fallbackDropDir() string {
|
||||
if v := os.Getenv("VERAE_FALLBACK_DIR"); v != "" {
|
||||
|
|
|
|||
|
|
@ -36,29 +36,15 @@ func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Chan
|
|||
return client, ch, nil
|
||||
}
|
||||
|
||||
// stampWithClient is the real stamp used by the desktop driver, Print-Job, and
|
||||
// POST /api/stamp. It hashes the file and calls the Verae client. It does not
|
||||
// invent a receipt.
|
||||
func stampWithClient(pdf, title, outDir, outPath string, lj vlog.Job) (job.Result, error) {
|
||||
// stampFile stamps one PDF with the current key (no holding / prompting).
|
||||
func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) {
|
||||
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
|
||||
if err != nil {
|
||||
return job.Result{}, err
|
||||
}
|
||||
client.HTTPDo = loggingDo(lj)
|
||||
return job.Run(job.Options{
|
||||
SourcePDF: pdf,
|
||||
OutDir: outDir,
|
||||
OutPath: outPath,
|
||||
DocumentName: safeStem(title) + ".pdf",
|
||||
Channel: ch,
|
||||
Client: client,
|
||||
})
|
||||
}
|
||||
|
||||
// stampFile stamps one PDF with the current key (no holding / prompting).
|
||||
func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) {
|
||||
start := time.Now()
|
||||
res, err := stampWithClient(pdf, t.Title, t.OutDir, t.OutPath, lj)
|
||||
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: t.OutDir, OutPath: t.OutPath, DocumentName: safeStem(t.Title) + ".pdf", Channel: ch, Client: client})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "runtime"
|
||||
|
||||
func init() {
|
||||
// -[NSApplication run] returns immediately when it is not on the process
|
||||
// main thread. Lock before the scheduler can move this goroutine.
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Verae Timestamp iPhone shortcut
|
||||
|
||||
Build this in the Shortcuts app. It shows up in the Share sheet, sends the PDF or photo to the Verae printer on your Wi-Fi, and saves the stamped certificate. A signed `.shortcut` file is not included: Apple only accepts shortcuts signed on a Mac or iPhone, and this tree is built on Linux.
|
||||
|
||||
Open the receipts page on a computer (`http://<printer>:631/receipts`, or 8632 if CUPS is using 631). The QR code opens `http://<printer>:<port>/ios-shortcut` on the phone, which repeats these steps and shows the stamp address for this machine.
|
||||
|
||||
## Build it
|
||||
|
||||
1. Open **Shortcuts** and tap **+**. Name the shortcut **Verae Timestamp**.
|
||||
2. Tap the shortcut’s **(i)** or settings and turn on **Show in Share Sheet**.
|
||||
3. Under share sheet types, leave **PDFs** and **Images** selected. You can clear the other types.
|
||||
4. Add **Get Contents of URL**.
|
||||
- URL: `http://<printer>:<port>/api/stamp`
|
||||
If the receipts page has a PIN, use `http://<printer>:<port>/api/stamp?access=YOURPIN`.
|
||||
- Method: **POST**.
|
||||
- Request Body: **File**.
|
||||
- File: **Shortcut Input**.
|
||||
- Add a header `X-Filename` if you want the job title to be the file name. The server also accepts `Content-Disposition: attachment; filename="Contract.pdf"`. PDF and JPEG are recognized from the file bytes, so a photo from the share sheet works.
|
||||
5. Add **Quick Look** and pass the contents of the URL. That is the stamped certificate PDF.
|
||||
6. Add **Save File**. Leave **Ask Where to Save** on and pick iCloud Drive or On My iPhone when it asks.
|
||||
7. Show the receipt id:
|
||||
- Add **Get Dictionary Value**.
|
||||
- Dictionary: the **Headers** of the Get Contents of URL result.
|
||||
- Key: `X-Verae-Receipt-Id`.
|
||||
- Add **Show Result** (or **Show Notification**) with that value.
|
||||
- `X-Verae-SHA256` is the document hash. `X-Verae-Receipt-JSON` is the URL of the `.verae.json` summary on the printer.
|
||||
|
||||
To get a zip instead, change the URL to end in `?format=zip` (use `&access=` if you already have `?access=`). **Save File** then receives `original.pdf`, `certificate.pdf`, and `receipt.json`.
|
||||
|
||||
## Use it
|
||||
|
||||
In Files or Photos, open a PDF or picture, tap **Share**, and choose **Verae Timestamp**. If it is missing, tap **More** and enable it.
|
||||
|
||||
The printer records the request as a job, lists it on the receipts page, and emails the stamped PDF when **Email stamped PDFs** is on. The shortcut does not wait for the email. The PDF in the shortcut result is the certificate.
|
||||
|
||||
The stamp address is only for Private and Domain networks, same as the printer. A wrong or missing PIN returns `401` and no PDF.
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# Share extension for the Verae Timestamping iOS app
|
||||
|
||||
The app (`com.flightlookup.verae-timestamping`) is not in this repository. This note is the design for a **Verae Timestamp** row in the iOS Share sheet that stamps a PDF or photo on the LAN print server and keeps the result in Files and in the app.
|
||||
|
||||
The shortcut in `README.md` is the path that works before that extension ships. The server side it needs is already here: `POST /api/stamp` and a `_verae._tcp` Bonjour record.
|
||||
|
||||
## Extension
|
||||
|
||||
- Extension point: `com.apple.share-services`.
|
||||
- Display name: **Verae Timestamp**.
|
||||
- Activation rule: the first attachment conforms to `com.adobe.pdf` or `public.image` (which covers JPEG and HEIC). Ignore other share types.
|
||||
- Process the attachment in the extension, then call `completeRequestReturningItems` with an item provider for the stamped PDF so the system share sheet can hand it back. Also write a copy into the app group so the main app can list it without waiting for Files.
|
||||
|
||||
Suggested app group: `group.com.flightlookup.verae-timestamping`. Put the certificate, the receipt id, the SHA-256, and the receipt JSON in that container. The main app reads the same folder on launch.
|
||||
|
||||
## Finding the printer
|
||||
|
||||
Browse Bonjour in `local.` with `NWBrowser`:
|
||||
|
||||
1. Prefer `_verae._tcp`. The TXT record is `txtvers=1`, `path=/api/stamp`, `ver=1`, and `url=http://<host>:<port>/api/stamp`. The SRV record is the port (631, or 8632 when CUPS owns 631). Resolve the host and POST to `http://<host>:<port>` plus `path`.
|
||||
2. If that browse is empty, browse `_ipp._tcp` and keep the instance whose TXT `ty` or `product` is **Verae Timestamp**. Use the host and port from that service and POST to `/api/stamp`. The AirPrint `adminurl` ends in `/receipts`; the stamp URL is the same origin with `/api/stamp`.
|
||||
|
||||
This repository advertises `_verae._tcp` from the print server’s mDNS socket. It is a separate service name from `<name>._ipp._tcp.local`, so it does not create a second IPP advertisement. On Windows, `DnsServiceRegister` still owns only the IPP name. The shared UDP 5353 socket answers AirPrint subtype PTRs for IPP and, separately, the `_verae._tcp` PTR, SRV, and TXT. It does not add IPP SRV, TXT, or address records while DNS-SD is up.
|
||||
|
||||
Stop the browse when the extension finishes. Cache the last host and port in the app group so a later share can try it first and fall back to a browse if the POST fails.
|
||||
|
||||
## Upload
|
||||
|
||||
`POST /api/stamp` with the file bytes as the body, or as multipart field `file`.
|
||||
|
||||
- Header `X-Filename`: the document title.
|
||||
- Header `X-Verae-User`: optional, shown on the receipts page. Default is `stamp`.
|
||||
- The response body is `application/pdf` (the certificate).
|
||||
- Headers: `X-Verae-Receipt-Id`, `X-Verae-SHA256`, `X-Verae-Receipt-JSON`.
|
||||
- `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`.
|
||||
|
||||
The job is stored like a print, appears on `/receipts`, and is emailed when email is enabled. The extension should treat the HTTP response as the copy to save, not wait for the email.
|
||||
|
||||
## PIN and network
|
||||
|
||||
The route uses the same Private/Domain check and optional receipts PIN as `/receipts`. Send the PIN as `X-Verae-Access`, `Authorization: Bearer <pin>`, or `?access=`. Store the PIN in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), not in the app group. A `401` means the PIN is missing or wrong. A `403` means the computer is on a Public network and Public access is off. Show that text; do not save an empty file.
|
||||
|
||||
## Saving
|
||||
|
||||
- Write the certificate into the app group and refresh the main app’s receipt list (a Darwin notification on the app group is enough).
|
||||
- Offer **Save to Files** with a document picker, or by returning the PDF from the extension so the user can pick a folder.
|
||||
- Keep the receipt id and SHA-256 next to the file. The JSON URL in `X-Verae-Receipt-JSON` is on the LAN and needs the same PIN.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue