Ship the Mac menu bar as an app bundle and use one real stamp path.

The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken.

Co-authored-by: George Lambert <marchon@gmail.com>
This commit is contained in:
Cursor Agent 2026-10-05 02:09:48 +00:00
commit f96ade41bd
No known key found for this signature in database
24 changed files with 715 additions and 94 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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")))
}

View file

@ -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 != "" {

View file

@ -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
}

View file

@ -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 `<name>._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 `<name>._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 `<name>._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 `<name>._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 `<name>._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 `<name>._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://<lan-address>:<port>/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://<host>:<port>/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://<host>:<port>/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.

View file

@ -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")
}

View file

@ -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!"))

View file

@ -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

View file

@ -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"}},

View file

@ -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,

View file

@ -628,6 +628,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(&b, `<p>Receipt files: <code>%s</code>. Share this folder read-only. It does not contain the program.</p>`, htmlEscape(cfg.DataDir))
fmt.Fprintf(&b, `<form method="post" action="/settings">
<label>Printer name<input name="name" value="%s"></label>
<p>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.</p>
<label>Forward to printer <input name="forwardPrinter" value="%s" placeholder="queue name or ipp://host/ipp/print"></label>
<label>IPP port<input name="port" value="%d"></label>
<label class="check"><input name="allowPublic" type="checkbox" value="1" %s> Allow Public networks (off by default). Private and Domain stay allowed. On a cafe or guest Wi-Fi this exposes the printer to everyone on that network.</label>
@ -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(),
}

View file

@ -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,24 +226,40 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl
if api == nil {
return fmt.Errorf("DNSServiceRegister client is nil")
}
insts := serviceToDarwinDNS(current())
base := strings.TrimSpace(current().Instance)
if base == "" {
base = legacyServiceName
}
conflicts := conflictWait(api)
for attempt := 1; attempt <= 8; attempt++ {
if conflicts != nil {
select {
case <-conflicts:
default:
}
}
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 {
if err := api.Register(inst); err != nil {
err := api.Register(inst)
if errors.Is(err, errNameConflict) {
conflict = true
break
}
if 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
hard = err
break
}
veraeErr = err
log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err)
@ -174,12 +267,33 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl
}
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
}
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{
@ -189,7 +303,8 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl
LastSent: time.Now(),
})
}
<-ctx.Done()
select {
case <-ctx.Done():
for _, inst := range ok {
if err := api.Deregister(inst); err != nil {
log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err)
@ -197,6 +312,14 @@ func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDCl
}
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)
}
}
return fmt.Errorf("mDNS name %q still conflicts", base)
}
func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error {

View file

@ -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()
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 := &regState{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 {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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

View file

@ -16,8 +16,18 @@ import (
const (
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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -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)
}

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