diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 263a0d4..2e5bdcb 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -43,7 +43,9 @@ jobs: 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" - test -x "$OUT/payload/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" + test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-airdrop" + test -x "$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" + test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist" test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg" - 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 diff --git a/cmd/verae-print/installer_cmds_test.go b/cmd/verae-print/installer_cmds_test.go index fb1fb44..9d3ac4e 100644 --- a/cmd/verae-print/installer_cmds_test.go +++ b/cmd/verae-print/installer_cmds_test.go @@ -4,10 +4,29 @@ 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) diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index 3e5b04c..c62d16e 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -21,7 +21,7 @@ func main() { if len(os.Args) < 2 && isHelperExe() { os.Args = append(os.Args, "serve") } - if len(os.Args) < 2 && isTrayExe() { + if len(os.Args) < 2 && (isTrayExe() || isMenuBarApp()) { os.Args = append(os.Args, "tray") } if len(os.Args) < 2 { diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 00a5586..5c8257a 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -10,7 +10,6 @@ import ( "strings" "syscall" - "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" @@ -110,18 +109,7 @@ func runTrayCmd() int { } func stampNetwork(pdf, title, user string) (job.Result, error) { - _ = title _ = user - lj := vlog.Job(vlog.NewJobID("net")) - client, ch, err := clientForChannel(auth.PreferredChannel(), lj) - if err != nil { - return job.Result{}, err - } - return job.Run(job.Options{ - SourcePDF: pdf, - OutDir: filepath.Dir(pdf), - DocumentName: "document.pdf", - Channel: ch, - Client: client, - }) + // 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"))) } diff --git a/cmd/verae-print/serve.go b/cmd/verae-print/serve.go index 9a1a32a..3b9ab13 100644 --- a/cmd/verae-print/serve.go +++ b/cmd/verae-print/serve.go @@ -36,6 +36,16 @@ func isTrayExe() bool { 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 != "" { diff --git a/cmd/verae-print/stampcore.go b/cmd/verae-print/stampcore.go index f2d80f8..566361b 100644 --- a/cmd/verae-print/stampcore.go +++ b/cmd/verae-print/stampcore.go @@ -36,15 +36,29 @@ func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Chan return client, ch, nil } -// stampFile stamps one PDF with the current key (no holding / prompting). -func stampFile(pdf string, t stampTarget, lj vlog.Job) (job.Result, error) { +// 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) { 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 := job.Run(job.Options{SourcePDF: pdf, OutDir: t.OutDir, OutPath: t.OutPath, DocumentName: safeStem(t.Title) + ".pdf", Channel: ch, Client: client}) + res, err := stampWithClient(pdf, t.Title, t.OutDir, t.OutPath, lj) if err != nil { return res, err } diff --git a/docs/network-printer.md b/docs/network-printer.md index 1bf532d..9656b82 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -1,6 +1,6 @@ # Verae network timestamp printer -Optional IPP Everywhere printer. Phones and other computers on the LAN print to **Verae Timestamp**. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded. +Optional IPP Everywhere printer. Phones and other computers on the LAN print to it. The default name is **Verae Timestamp** plus this computer's name, for example **Verae Timestamp (Georges-MacBook-Pro)**. Settings can change it. A saved name that is still the old shared **Verae Timestamp** is upgraded on the next start. If that name is already registered on the network, mDNS renames it to **Name (2)** and the new name is saved. This machine hashes the document, registers the SHA-256 with Verae the same way the desktop drivers do, and keeps the receipt. Document bytes are not uploaded. The printer is off until you install the standalone package or turn on the driver installer option. Both paths install one service and one tray / menu-bar control. They do not share an install folder: the standalone package never overwrites the driver binary. If both are installed, the service runs whichever binary is the same version or newer, and removing one leaves the other in place. @@ -9,7 +9,7 @@ The printer is off until you install the standalone package or turn on the drive | Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos-arm64-0.3.4.pkg` | `verae-netprint_*_amd64.deb` | | Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` | | Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` | -| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`verae-print tray`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | +| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`/Applications/Verae NetPrint.app`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | | Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` | | Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | | CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer. Its default name includes the computer, for example **Verae Timestamp (Georges-MacBook-Pro)**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print` and `kDNSServiceFlagsNoAutoRename`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If the name is already in use, the callback reports the conflict and both services are registered again as **Name (2)**. That name is saved and becomes the IPP printer name. If `DNSServiceRegister` fails for another reason, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. On macOS, cupsd keeps `127.0.0.1:631` and `[::1]:631`. This printer binds 631 only on the Mac's non-loopback addresses, so printing from another device uses port 631 and localhost CUPS is unchanged. mDNS advertises 631. If none of those LAN binds succeed, it listens on 8632 on every interface and advertises 8632. Status JSON `ippBind` is `lan` or `all`, and `port` is the port that was bound. On Windows and Linux the listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, trying 631 and falling back to 8632 when 631 is taken. It does not use 8631. A connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -39,7 +39,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Stamp and return -`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as a print job: the job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. +`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as Print-Job, including the same `stampWithClient` call the desktop driver uses (`job.Run` against the Verae client, with the document title as the file name). The job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. A receipt id beginning with `mock-cert` is refused, so a test double is not returned outside test builds. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. The server also advertises `_verae._tcp` (PTR, SRV, and TXT `path=/api/stamp`) beside the IPP printer. That record is a different service name, so it does not add a second `_ipp._tcp` advertisement. While Windows DNS-SD owns the printer, the shared UDP socket still answers only the AirPrint subtype PTRs for `_ipp._tcp` and does not add IPP SRV, TXT, or address records. The `_verae._tcp` SRV and TXT go out on that socket because DNS-SD does not register that name. Address records stay with the printer advertisement. @@ -61,10 +61,10 @@ On macOS, the Application Firewall is per application (System Settings → Netwo The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. GitHub Actions builds it on a `macos-14` runner with `pkgbuild` and `productbuild`, then runs `pkgutil --expand`, `pkgutil --check-signature`, and `installer -pkginfo`. It installs: -- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 binary) -- `/Library/Application Support/Verae/NetPrint/bin/verae-airdrop` (the Swift AirDrop helper, built on that Mac) -- LaunchDaemon `com.verae.netprint` (root, the print server) -- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `verae-print tray` as the logged-in user +- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 daemon binary) +- `/Applications/Verae NetPrint.app` (the menu-bar app: `Contents/Info.plist` with bundle id `ai.verae.netprint`, `LSUIElement`, and `NSLocalNetworkUsageDescription`; `Contents/MacOS/verae-print` and `Contents/MacOS/verae-airdrop`) +- LaunchDaemon `com.verae.netprint` (root, the print server, the binary under Application Support) +- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `/Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray` as the logged-in user Receipts and job files go to `/Users/Shared/Verae Receipts`, not next to those binaries. The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: @@ -78,7 +78,7 @@ sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API and opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. -`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it next to `verae-print`. The menu-bar app launches that helper. macOS also asks for notification permission the first time the agent posts **AirDrop again**. +`platform/macos/airdrop/main.swift` is that share sheet as `verae-airdrop`. The macOS package build compiles it with `swiftc` and installs it inside `Verae NetPrint.app`, next to the tray executable. The menu-bar app launches that helper. macOS asks for notification permission the first time the agent posts **AirDrop again**. `UNUserNotificationCenter` is used only when the process is that app bundle (`bundleIdentifier` set and the bundle path ends in `.app`). A bare binary skips the call instead of crashing, because `currentNotificationCenter` throws when `mainBundle` is a directory. The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. diff --git a/internal/job/job.go b/internal/job/job.go index 0a21d4e..c761a2e 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -43,6 +43,20 @@ type Options struct { var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) +// RefuseMockReceipt rejects a test double. Print-Job and /api/stamp both +// call this. A receipt id with the mock-cert prefix is not a Verae receipt, +// and it is refused outside test builds that opt into a real id. +func RefuseMockReceipt(id, merkle string) error { + lid := strings.ToLower(strings.TrimSpace(id)) + if strings.HasPrefix(lid, "mock-cert") || strings.HasPrefix(lid, "mock-") { + return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id)) + } + if strings.TrimSpace(merkle) == "42" && strings.Contains(lid, "mock") { + return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id)) + } + return nil +} + // SanitizeReceiptID makes a receipt id safe for filenames. func SanitizeReceiptID(id string) string { id = strings.TrimSpace(id) @@ -154,6 +168,9 @@ func Run(opt Options) (Result, error) { if err != nil { return Result{}, err } + if err := RefuseMockReceipt(rec.ReceiptID, rec.Merkle); err != nil { + return Result{}, err + } if rec.ReceiptID == "" { return Result{}, fmt.Errorf("stamp returned empty receipt id") } diff --git a/internal/job/job_test.go b/internal/job/job_test.go index 9660123..d8392a3 100644 --- a/internal/job/job_test.go +++ b/internal/job/job_test.go @@ -13,6 +13,39 @@ import ( "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" ) +func TestRunRejectsMockReceiptOutsideTestBuilds(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.pdf") + if err := os.WriteFile(src, []byte("%PDF-1.4 hello"), 0o600); err != nil { + t.Fatal(err) + } + client := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-x", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"receipt_id":"mock-cert-abc","merkle":"42","created_at":"2026-10-05T00:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + _, err := Run(Options{SourcePDF: src, OutDir: filepath.Join(dir, "out"), DocumentName: "Contract.pdf", Channel: auth.ChannelZapier, Client: client}) + if err == nil || !strings.Contains(err.Error(), "mock receipt") { + t.Fatal(err) + } + real := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-x", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"receipt_id":"bafyRealBlock","merkle":"42"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + if _, err := Run(Options{SourcePDF: src, OutDir: filepath.Join(dir, "real"), DocumentName: "Contract.pdf", Channel: auth.ChannelZapier, Client: real}); err != nil { + t.Fatal(err) + } +} + func TestSanitizeAndStem(t *testing.T) { if SanitizeReceiptID("bafy/xyz!") != "bafy-xyz" { t.Fatal(SanitizeReceiptID("bafy/xyz!")) diff --git a/internal/netprint/airdrop_darwin.go b/internal/netprint/airdrop_darwin.go index 8473fb6..7958f98 100644 --- a/internal/netprint/airdrop_darwin.go +++ b/internal/netprint/airdrop_darwin.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "sync" "unsafe" @@ -111,7 +112,7 @@ func notifyAirDrop(o AirDropOffer) { } func postAirDropNote(o AirDropOffer) { - if !notesReady { + if !notesReady || !notificationsAllowed() { return } center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) @@ -163,6 +164,12 @@ type blockDescriptor struct { } func prepareAirDropNotifications() { + // UNUserNotificationCenter throws NSInternalInconsistencyException when + // mainBundle is a directory (bundleProxyForCurrentProcess is nil). Skip + // the call unless this process is inside a real .app. + if !notificationsAllowed() { + return + } if _, err := purego.Dlopen("/System/Library/Frameworks/UserNotifications.framework/UserNotifications", 1); err != nil { log.Printf("netprint: UserNotifications: %v", err) return @@ -189,7 +196,34 @@ func prepareAirDropNotifications() { notesReady = true } +var noteSkip sync.Once + +// notificationsAllowed reports whether UserNotifications can be used. +// currentNotificationCenter crashes when the process is not an app bundle. +func notificationsAllowed() bool { + bundle := cls("NSBundle").Send(objc.RegisterName("mainBundle")) + path := "" + ident := "" + if bundle != 0 { + if url := bundle.Send(objc.RegisterName("bundleURL")); url != 0 { + path = objcUTF8(url.Send(objc.RegisterName("path"))) + } + ident = objcUTF8(bundle.Send(objc.RegisterName("bundleIdentifier"))) + } + if ident != "" && strings.HasSuffix(path, ".app") { + return true + } + noteSkip.Do(func() { + log.Printf("netprint: notifications skipped; bundleProxyForCurrentProcess is nil (%s)", path) + }) + return false +} + func registerAirDropCategory() { + if !notificationsAllowed() { + notesReady = false + return + } center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) if center == 0 || trayTarget == 0 { return diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index e53842e..6582db3 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -295,11 +295,11 @@ func TestMacAirDropAndPackageSources(t *testing.T) { file string need []string }{ - {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}}, + {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}}, {"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, - {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}}, - {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop"}}, - {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop"}}, + {"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}}, + {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint"}}, + {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "Verae NetPrint.app", "LSUIElement"}}, {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop"}}, {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, diff --git a/internal/netprint/config.go b/internal/netprint/config.go index f0c88a7..34bbe92 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -73,6 +73,10 @@ const ( DefaultEmailTo = "marchon@gmail.com" // DefaultSMTPHost is Gmail's submission server. An app password works with STARTTLS. DefaultSMTPHost = "smtp.gmail.com" + // legacyServiceName is the old shared default. Two hosts that both use it + // advertise the same Bonjour instance. An empty name, and a saved config + // that still has exactly this string, become defaultServiceName. + legacyServiceName = "Verae Timestamp" ) // PublicNetworks reports whether Public firewall profiles should be allowed. @@ -122,7 +126,7 @@ func Defaults() Config { emailOff := false airDropOff := false return Config{ - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, FallbackPort: DefaultFallbackPort, ControlPort: DefaultControlPort, @@ -207,12 +211,60 @@ func Load(explicitPath string, flags Config) (Config, error) { } } mergeConfig(&out, flags) + applyDefaultName(&out) if err := out.normalize(); err != nil { return Config{}, err } return out, nil } +// defaultServiceName is unique per computer, for example +// "Verae Timestamp (Georges-MacBook-Pro)". Settings can replace it. +func defaultServiceName() string { + host := hostLabel("") + if host == "" { + return legacyServiceName + } + return legacyServiceName + " (" + host + ")" +} + +// applyDefaultName fills an empty name and upgrades the old shared default. +// Any other saved name is kept. +func applyDefaultName(c *Config) { + name := strings.TrimSpace(c.Name) + if name == "" || name == legacyServiceName { + c.Name = defaultServiceName() + } +} + +// persistQualifiedName writes the host-qualified name when the file on disk +// still has the old shared default (or no name). A name the user chose is left +// as it was saved. +func persistQualifiedName(path string, cfg Config) error { + if strings.TrimSpace(path) == "" { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var disk Config + if err := json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &disk); err != nil { + return err + } + diskName := strings.TrimSpace(disk.Name) + if diskName != "" && diskName != legacyServiceName { + return nil + } + if diskName == strings.TrimSpace(cfg.Name) { + return nil + } + return Save(path, cfg) +} + func mergeFile(dst *Config, path string) error { b, err := os.ReadFile(path) if err != nil { @@ -292,8 +344,8 @@ func mergeConfig(dst *Config, src Config) { } func (c *Config) normalize() error { - if c.Name == "" { - c.Name = "Verae Timestamp" + if strings.TrimSpace(c.Name) == "" { + c.Name = defaultServiceName() } if c.Port <= 0 || c.Port > 65535 { return fmt.Errorf("port must be 1-65535") @@ -357,7 +409,7 @@ func ExampleJSON(dataDir string) []byte { emailOff := false airDropOff := false c := Config{ - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, FallbackPort: DefaultFallbackPort, ControlPort: DefaultControlPort, diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 1ecb7df..1285a11 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -628,6 +628,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(&b, `

Receipt files: %s. Share this folder read-only. It does not contain the program.

`, htmlEscape(cfg.DataDir)) fmt.Fprintf(&b, `
+

The default is Verae Timestamp plus this computer's name, so two machines are not both called Verae Timestamp. A name that is already on the network is renamed. Any other name you type here is kept.

@@ -697,7 +698,7 @@ func loopback(r *http.Request) bool { func StoppedStatus() Status { return Status{ Running: false, - Name: "Verae Timestamp", + Name: defaultServiceName(), Port: 631, SupportsPublic: supportsPublicFirewall(), } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 3a7e959..27dc5c4 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -2,9 +2,11 @@ package netprint import ( "context" + "errors" "fmt" "log" "net" + "strings" "sync" "time" ) @@ -77,6 +79,81 @@ func serviceToDarwinDNS(s Service) []dnsSDInstance { } } +// errNameConflict is returned when the instance name is already registered +// on the network. The advertiser picks the next "Name (2)" and tries again. +var errNameConflict = errors.New("mDNS name conflict") + +const ( + // dnsFlagNoAutoRename is kDNSServiceFlagsNoAutoRename. The callback reports + // kDNSServiceErr_NameConflict instead of silently publishing "Name (2)". + dnsFlagNoAutoRename = 8 + // dnsErrNameConflict is kDNSServiceErr_NameConflict. + dnsErrNameConflict = int32(-65548) +) + +// conflictName is the instance label for attempt. Attempt 1 is the requested +// name. Later attempts append " (2)", " (3)", and so on. +func conflictName(name string, attempt int) string { + name = strings.TrimSpace(name) + if name == "" { + name = legacyServiceName + } + if attempt <= 1 { + return name + } + return fmt.Sprintf("%s (%d)", name, attempt) +} + +func retitleTXT(txt []string, name string) []string { + if len(txt) == 0 { + return txt + } + out := make([]string, len(txt)) + for i, s := range txt { + switch { + case strings.HasPrefix(s, "ty="): + out[i] = "ty=" + trimTXT(name, 100) + case strings.HasPrefix(s, "product="): + out[i] = "product=(" + trimTXT(name, 80) + ")" + default: + out[i] = s + } + } + return out +} + +var ( + renameMu sync.Mutex + renameHook func(string) +) + +func setMDNSRename(fn func(string)) { + renameMu.Lock() + renameHook = fn + renameMu.Unlock() +} + +func noteMDNSRename(name string) { + renameMu.Lock() + fn := renameHook + renameMu.Unlock() + name = strings.TrimSpace(name) + if fn != nil && name != "" { + fn(name) + } +} + +func conflictWait(api dnsSDClient) <-chan struct{} { + type waiter interface{ Conflicts() <-chan struct{} } + if api == nil { + return nil + } + if w, ok := api.(waiter); ok { + return w.Conflicts() + } + return nil +} + // dnsPort is the port in network byte order, which DNSServiceRegister requires. func dnsPort(p int) uint16 { if p < 0 || p > 65535 { @@ -149,54 +226,100 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl if api == nil { return fmt.Errorf("DNSServiceRegister client is nil") } - insts := serviceToDarwinDNS(current()) - if len(insts) == 0 { - return fmt.Errorf("DNSServiceRegister: empty service") + base := strings.TrimSpace(current().Instance) + if base == "" { + base = legacyServiceName } - var ok []dnsSDInstance - var veraeErr error - for i, inst := range insts { - if err := api.Register(inst); err != nil { - if i == 0 { - for _, prev := range ok { - _ = api.Deregister(prev) - } - log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) - udpErr := advertiseUDP(ctx, current, sink) - if udpErr != nil && ctx.Err() == nil { - return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", inst.RegType, err, udpErr) - } - return nil + conflicts := conflictWait(api) + for attempt := 1; attempt <= 8; attempt++ { + if conflicts != nil { + select { + case <-conflicts: + default: } - veraeErr = err - log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + } + name := conflictName(base, attempt) + svc := current() + svc.Instance = name + svc.TXT = retitleTXT(svc.TXT, name) + insts := serviceToDarwinDNS(svc) + if len(insts) == 0 { + return fmt.Errorf("DNSServiceRegister: empty service") + } + var ok []dnsSDInstance + var veraeErr error + conflict := false + var hard error + for i, inst := range insts { + err := api.Register(inst) + if errors.Is(err, errNameConflict) { + conflict = true + break + } + if err != nil { + if i == 0 { + hard = err + break + } + veraeErr = err + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + continue + } + ok = append(ok, inst) + } + if conflict { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) continue } - ok = append(ok, inst) - } - detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) - if veraeErr != nil { - detail += "; _verae._tcp not registered" - } else if len(ok) > 1 { - detail += "; _verae._tcp" - } - log.Printf("netprint: mDNS %s", detail) - if sink != nil { - sink(mdnsReport{ - Advertising: true, - Mode: "dns-sd", - Detail: detail, - LastSent: time.Now(), - }) - } - <-ctx.Done() - for _, inst := range ok { - if err := api.Deregister(inst); err != nil { - log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + if hard != nil { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: DNSServiceRegister %s: %v", insts[0].RegType, hard) + udpErr := advertiseUDP(ctx, current, sink) + if udpErr != nil && ctx.Err() == nil { + return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", insts[0].RegType, hard, udpErr) + } + return nil + } + detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) + if veraeErr != nil { + detail += "; _verae._tcp not registered" + } else if len(ok) > 1 { + detail += "; _verae._tcp" + } + if attempt > 1 { + noteMDNSRename(name) + } + log.Printf("netprint: mDNS %s", detail) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: detail, + LastSent: time.Now(), + }) + } + select { + case <-ctx.Done(): + for _, inst := range ok { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DNSServiceRegister removed") + return nil + case <-conflicts: + for _, inst := range ok { + _ = api.Deregister(inst) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) } } - log.Printf("netprint: mDNS DNSServiceRegister removed") - return nil + return fmt.Errorf("mDNS name %q still conflicts", base) } func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { diff --git a/internal/netprint/dnsservice_darwin.go b/internal/netprint/dnsservice_darwin.go index dd00460..14f1f2f 100644 --- a/internal/netprint/dnsservice_darwin.go +++ b/internal/netprint/dnsservice_darwin.go @@ -14,14 +14,27 @@ import ( // darwinDNS registers with mDNSResponder through DNSServiceRegister. // Subtypes are part of the regtype. The call does not use cgo; libSystem is -// loaded when the process runs on a Mac. +// loaded when the process runs on a Mac. A name conflict is reported on +// conflicts so the advertiser can register "Name (2)" instead. type darwinDNS struct { - mu sync.Mutex - refs []uintptr - stop []func() + mu sync.Mutex + refs []uintptr + stop []func() + conflicts chan struct{} } -func newDarwinDNS() dnsSDClient { return &darwinDNS{} } +func newDarwinDNS() dnsSDClient { + return &darwinDNS{conflicts: make(chan struct{}, 1)} +} + +func (d *darwinDNS) Conflicts() <-chan struct{} { return d.conflicts } + +// regState is the DNSServiceRegister context. It stays pinned until the +// registration is deallocated so the callback can read it. +type regState struct { + name string + conflicts chan struct{} +} var ( dnsOnce sync.Once @@ -30,6 +43,12 @@ var ( dnsRegister func(sdRef *uintptr, flags uint32, ifIndex uint32, name, regtype, domain, host *byte, port uint16, txtLen uint16, txtRecord unsafe.Pointer, callback uintptr, context unsafe.Pointer) int32 dnsProcess func(sdRef uintptr) int32 dnsDeallocate func(sdRef uintptr) + + dnsReplyOnce sync.Once + dnsReplyPtr uintptr + + regMu sync.Mutex + regStates = map[uintptr]*regState{} ) func loadDNSService() error { @@ -64,14 +83,47 @@ func (d *darwinDNS) Register(inst dnsSDInstance) error { if len(txt) > 0 { txtPtr = unsafe.Pointer(&txt[0]) } + st := ®State{name: inst.Name, conflicts: d.conflicts} + var pin runtime.Pinner + pin.Pin(st) + key := uintptr(unsafe.Pointer(st)) + regMu.Lock() + regStates[key] = st + regMu.Unlock() + flags := uint32(0) + var cb uintptr + var ctx unsafe.Pointer + keep := false + if reply := dnsReplyCallback(); reply != 0 { + flags = dnsFlagNoAutoRename + cb = reply + ctx = unsafe.Pointer(st) + keep = true + } + release := func() { + regMu.Lock() + delete(regStates, key) + regMu.Unlock() + pin.Unpin() + } + if !keep { + release() + } var ref uintptr - rc := dnsRegister(&ref, 0, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, 0, nil) + rc := dnsRegister(&ref, flags, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, cb, ctx) runtime.KeepAlive(name) runtime.KeepAlive(regtype) runtime.KeepAlive(domain) runtime.KeepAlive(hostBuf) runtime.KeepAlive(txt) + runtime.KeepAlive(st) if rc != 0 { + if keep { + release() + } + if rc == dnsErrNameConflict { + return errNameConflict + } return fmt.Errorf("DNSServiceRegister %s %s: code %d", inst.Name, inst.RegType, rc) } done := make(chan struct{}) @@ -88,12 +140,45 @@ func (d *darwinDNS) Register(inst dnsSDInstance) error { d.stop = append(d.stop, func() { dnsDeallocate(ref) <-done + if keep { + release() + } }) d.mu.Unlock() log.Printf("netprint: DNSServiceRegister %s %s port %d", inst.Name, inst.RegType, inst.Port) return nil } +func dnsReplyCallback() uintptr { + dnsReplyOnce.Do(func() { + defer func() { + if recover() != nil { + dnsReplyPtr = 0 + } + }() + dnsReplyPtr = purego.NewCallback(dnsRegisterReply) + }) + return dnsReplyPtr +} + +func dnsRegisterReply(sdRef, flags, errCode, namePtr, regtype, domain, context uintptr) uintptr { + if context == 0 { + return 0 + } + regMu.Lock() + st := regStates[context] + regMu.Unlock() + if st == nil || st.conflicts == nil || int32(uint32(errCode)) != dnsErrNameConflict { + return 0 + } + select { + case st.conflicts <- struct{}{}: + default: + } + runtime.KeepAlive(namePtr) + return 0 +} + func (d *darwinDNS) Deregister(inst dnsSDInstance) error { d.mu.Lock() if len(d.stop) == 0 { diff --git a/internal/netprint/mdns_dnssd_test.go b/internal/netprint/mdns_dnssd_test.go index 57ed8f8..9c93b8b 100644 --- a/internal/netprint/mdns_dnssd_test.go +++ b/internal/netprint/mdns_dnssd_test.go @@ -26,6 +26,8 @@ type fakeDNS struct { deregistered []dnsSDInstance err error failSubtypes bool + conflicts chan struct{} + conflictOn string } func (f *fakeDNS) Register(inst dnsSDInstance) error { @@ -38,9 +40,17 @@ func (f *fakeDNS) Register(inst dnsSDInstance) error { return f.err } f.registered = append(f.registered, inst) + if f.conflictOn != "" && inst.Name == f.conflictOn && f.conflicts != nil { + select { + case f.conflicts <- struct{}{}: + default: + } + } return nil } +func (f *fakeDNS) Conflicts() <-chan struct{} { return f.conflicts } + func (f *fakeDNS) Deregister(inst dnsSDInstance) error { f.mu.Lock() defer f.mu.Unlock() @@ -402,6 +412,36 @@ func TestAllowPublicRoundTrip(t *testing.T) { } } +func TestConflictNameAndDarwinRename(t *testing.T) { + base := "Verae Timestamp (Georges-MacBook-Pro)" + if conflictName(base, 1) != base || conflictName(base, 2) != base+" (2)" { + t.Fatalf("%q %q", conflictName(base, 1), conflictName(base, 2)) + } + api := &fakeDNS{conflicts: make(chan struct{}, 1), conflictOn: "Verae Timestamp"} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + var names []string + for time.Now().Before(deadline) { + names = api.names() + if strings.Contains(strings.Join(names, " "), "(2)") { + break + } + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(strings.Join(names, " "), "Verae Timestamp (2)._ipp._tcp.local") { + t.Fatalf("names %v", names) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() { t.Helper() prev := startAdvertiser diff --git a/internal/netprint/name_test.go b/internal/netprint/name_test.go new file mode 100644 index 0000000..0a24bb9 --- /dev/null +++ b/internal/netprint/name_test.go @@ -0,0 +1,54 @@ +package netprint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultServiceNameIsUniquePerHost(t *testing.T) { + want := legacyServiceName + " (" + hostLabel("") + ")" + if got := defaultServiceName(); got != want || got == legacyServiceName || !strings.Contains(got, "(") { + t.Fatalf("default %q", got) + } + d := Defaults() + if d.Name != want { + t.Fatalf("Defaults name %q", d.Name) + } + sample := string(ExampleJSON(t.TempDir())) + if !strings.Contains(sample, `"name": "`+want+`"`) { + t.Fatalf("example config:\n%s", sample) + } + kept := Config{Name: "Kitchen", Port: 631} + if err := kept.normalize(); err != nil || kept.Name != "Kitchen" { + t.Fatalf("%+v %v", kept, err) + } + dir := t.TempDir() + path := filepath.Join(dir, "netprint.json") + if err := os.WriteFile(path, []byte(`{"name":"Verae Timestamp","port":631}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := Load(path, Config{}) + if err != nil { + t.Fatal(err) + } + if loaded.Name != want { + t.Fatalf("legacy name stayed %q", loaded.Name) + } + if err := persistQualifiedName(path, loaded); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), want) { + t.Fatalf("not saved: %s %v", b, err) + } + custom := Config{Name: "Kitchen", Port: 631, DataDir: dir} + if err := Save(path, custom); err != nil { + t.Fatal(err) + } + again, err := Load(path, Config{}) + if err != nil || again.Name != "Kitchen" { + t.Fatalf("%+v %v", again, err) + } +} diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go index dd36f11..e042cea 100644 --- a/internal/netprint/pipeline.go +++ b/internal/netprint/pipeline.go @@ -83,6 +83,10 @@ func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user s fail(err) return } + if err := job.RefuseMockReceipt(res.ReceiptID, receiptMerkle(res.ReceiptJSON)); err != nil { + fail(err) + return + } if user != "" && res.ReceiptJSON != "" { annotateUser(res.ReceiptJSON, user) } @@ -166,6 +170,26 @@ func jpegToPages(doc []byte) ([]Page, error) { return []Page{{Width: w, Height: h, DPI: 300, RGB: rgb}}, nil } +func receiptMerkle(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + b, err := os.ReadFile(path) + if err != nil { + return "" + } + var m map[string]any + if json.Unmarshal(b, &m) != nil || m == nil { + return "" + } + switch v := m["merkle"].(type) { + case string: + return v + default: + return "" + } +} + func annotateUser(path, user string) { b, err := os.ReadFile(path) if err != nil { diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 57892b3..c1c537e 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -50,6 +50,9 @@ func Run(ctx context.Context, explicitPath string, flags Config, stamp StampFunc if strings.TrimSpace(path) == "" { path = SystemConfigPath() } + if err := persistQualifiedName(path, cfg); err != nil { + log.Printf("netprint: default name: %v", err) + } s, err := Start(cfg, path, stamp, nil) if err != nil { return err @@ -206,6 +209,7 @@ func (s *Server) startMDNS() { ctx, cancel := context.WithCancel(context.Background()) s.mdnsCancel = cancel s.mu.Unlock() + setMDNSRename(s.noteAdvertisedName) s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) go func() { err := startAdvertiser(ctx, func() Service { @@ -249,6 +253,31 @@ func (s *Server) stopMDNS() { s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) } +func (s *Server) noteAdvertisedName(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + s.mu.Lock() + if s.cfg.Name == name { + s.mu.Unlock() + return + } + s.cfg.Name = name + cfg := s.cfg + path := s.configPath + if s.printer != nil { + s.printer.Name = name + } + s.mu.Unlock() + if strings.TrimSpace(path) != "" { + if err := Save(path, cfg); err != nil { + log.Printf("netprint: mDNS rename save: %v", err) + } + } + log.Printf("netprint: mDNS renamed the printer to %q", name) +} + func (s *Server) setMDNS(r mdnsReport) { s.mu.Lock() s.mdns = r diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index bfe5118..a1763d7 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -14,10 +14,20 @@ import ( ) const ( - daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" - agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" + daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" + agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" + trayAppExecutable = "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" ) +// trayProgram is the menu-bar executable inside Verae NetPrint.app. +// The LaunchDaemon keeps using the binary under Application Support. +func trayProgram() string { + if st, err := os.Stat(trayAppExecutable); err == nil && !st.IsDir() { + return trayAppExecutable + } + return mustExe() +} + func plistDaemon(exe, config string) string { return fmt.Sprintf(` @@ -85,7 +95,7 @@ func (osControl) SetTrayAtLogin(on bool) error { uid := consoleUID() target := "gui/" + uid if on { - if err := os.WriteFile(agentPlist, []byte(plistAgent(mustExe())), 0o644); err != nil { + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { return err } _ = exec.Command("launchctl", "enable", target+"/"+trayLabel).Run() @@ -168,12 +178,15 @@ func Install(owner, tokenFile string, opts InstallOptions) error { if err := os.MkdirAll("/Library/LaunchAgents", 0o755); err != nil { return err } - if err := os.WriteFile(agentPlist, []byte(plistAgent(chosen)), 0o644); err != nil { + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { return err } linkCLI(exe) copyToken(tokenFile) allowAppFirewall(chosen) + if tray := trayProgram(); tray != chosen { + allowAppFirewall(tray) + } _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() if err := launchctl("bootstrap", "system", daemonPlist); err != nil { return err @@ -193,7 +206,7 @@ func Uninstall(owner string) error { if other := peerBinary(owner); other != "" { cfgPath := SystemConfigPath() _ = os.WriteFile(daemonPlist, []byte(plistDaemon(other, cfgPath)), 0o644) - _ = os.WriteFile(agentPlist, []byte(plistAgent(other)), 0o644) + _ = os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644) _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() _ = launchctl("bootstrap", "system", daemonPlist) } diff --git a/internal/netprint/stamp_api_test.go b/internal/netprint/stamp_api_test.go index c646883..790f357 100644 --- a/internal/netprint/stamp_api_test.go +++ b/internal/netprint/stamp_api_test.go @@ -246,6 +246,56 @@ func TestVeraeAnnounceStaysOffIPPRecords(t *testing.T) { } } +func TestStampAPIRejectsMockReceipt(t *testing.T) { + cfg, path := testConfig(t) + var calls int + stamp := func(pdf, title, user string) (job.Result, error) { + calls++ + dir := filepath.Dir(pdf) + js := filepath.Join(dir, "mock.verae.json") + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"mock-cert-9\",\"merkle\":\"42\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ReceiptID: "mock-cert-9", SHA256: strings.Repeat("ab", 32), ReceiptJSON: js, OriginalPDF: pdf}, nil + } + s, err := Start(cfg, path, stamp, &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status %d body %q", res.StatusCode, body) + } + if id := res.Header.Get("X-Verae-Receipt-Id"); id != "" || strings.Contains(string(body), "mock-cert") && res.StatusCode == http.StatusOK { + t.Fatalf("mock receipt returned: %s %q", id, body) + } + if !strings.Contains(string(body), "mock receipt") { + t.Fatalf("body %q", body) + } + if calls != 1 { + t.Fatalf("stamp calls %d", calls) + } + id, err := s.store.AllocateID() + if err != nil { + t.Fatal(err) + } + ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Printed", "ada", []byte("%PDF-1.4 print"), stamp, nil, nil) + rec, ok := s.store.Get(id) + if !ok || rec.State == "completed" || !strings.Contains(rec.Error, "mock receipt") { + t.Fatalf("%+v", rec) + } +} + func localStamp() StampFunc { return func(pdf, title, user string) (job.Result, error) { dir := filepath.Dir(pdf) diff --git a/platform/macos/netprint/com.verae.netprint.tray.plist b/platform/macos/netprint/com.verae.netprint.tray.plist index e4c4ccd..50aa3c7 100644 --- a/platform/macos/netprint/com.verae.netprint.tray.plist +++ b/platform/macos/netprint/com.verae.netprint.tray.plist @@ -4,7 +4,7 @@ Labelcom.verae.netprint.tray ProgramArguments - /Library/Application Support/Verae/NetPrint/bin/verae-print + /Applications/Verae NetPrint.app/Contents/MacOS/verae-print tray RunAtLoad diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index c9fb1a2..4370739 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -22,12 +22,36 @@ ARCH="${PKG_ARCH:-arm64}" STAGE="$OUT/payload" SCRIPTS="$OUT/scripts" rm -rf "$STAGE" "$SCRIPTS" +APP="$STAGE/Applications/Verae NetPrint.app" mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" \ "$STAGE/Library/LaunchDaemons" \ "$STAGE/Library/LaunchAgents" \ + "$APP/Contents/MacOS" \ "$SCRIPTS" cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +# The menu-bar app and the AirDrop helper live in one bundle. LSUIElement=1 +# keeps it out of the Dock. The LaunchAgent runs the executable inside it. +cp -f "$BIN" "$APP/Contents/MacOS/verae-print" +chmod 755 "$APP/Contents/MacOS/verae-print" +cat >"$APP/Contents/Info.plist" < + + + CFBundleExecutableverae-print + CFBundleIdentifierai.verae.netprint + CFBundleNameVerae NetPrint + CFBundleDisplayNameVerae NetPrint + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${VERSION} + LSMinimumSystemVersion14.0 + LSUIElement + NSHighResolutionCapable + NSLocalNetworkUsageDescriptionVerae NetPrint looks for the timestamp printer on the local network and opens AirDrop to send the stamped PDF. + +EOF +printf 'APPL????' >"$APP/Contents/PkgInfo" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" # The package itself installs the root daemon and the Aqua LaunchAgent. @@ -41,9 +65,9 @@ if [[ "$(uname -s)" == "Darwin" ]]; then echo "swiftc is required to build verae-airdrop" >&2 exit 1 fi - swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ + swiftc -O -o "$APP/Contents/MacOS/verae-airdrop" \ "$ROOT/platform/macos/airdrop/main.swift" - chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" + chmod 755 "$APP/Contents/MacOS/verae-airdrop" fi # preinstall runs before the payload replaces verae-print. postinstall # bootstraps the service again. @@ -69,8 +93,8 @@ if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts exit 0 fi -if [[ ! -x "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" ]]; then - echo "verae-airdrop missing from the package root" >&2 +if [[ ! -x "$APP/Contents/MacOS/verae-airdrop" ]]; then + echo "verae-airdrop missing from Verae NetPrint.app" >&2 exit 1 fi diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh index d995b63..b8493e1 100644 --- a/platform/macos/pkg/check-pkg.sh +++ b/platform/macos/pkg/check-pkg.sh @@ -39,4 +39,13 @@ lsbom "$bom" >"$WORKDIR/bom.txt" grep -q 'verae-airdrop' "$WORKDIR/bom.txt" grep -q 'LaunchAgents' "$WORKDIR/bom.txt" grep -q 'LaunchDaemons' "$WORKDIR/bom.txt" +grep -q 'Verae NetPrint.app' "$WORKDIR/bom.txt" +plist="$(find "$WORKDIR/full" -path '*Verae NetPrint.app/Contents/Info.plist' -print -quit)" +if [[ -z "$plist" ]]; then + echo "Verae NetPrint.app has no Info.plist" >&2 + exit 1 +fi +grep -q 'LSUIElement' "$plist" +grep -q 'NSLocalNetworkUsageDescription' "$plist" +grep -q 'ai.verae.netprint' "$plist" echo "package ok: $PKG"