Keep the macOS receipts SMB share across reboots.
Some checks failed
installers / netprint (push) Has been cancelled
installers / macos-pkg (push) Has been cancelled
installers / publish (push) Has been cancelled

Save the installer choice as shareReceiptsSMB, recreate the share when it is missing, and show the live smb:// address on the receipts page.

Co-authored-by: George Lambert <marchon@gmail.com>
This commit is contained in:
Cursor Agent 2026-10-05 16:34:51 +00:00
commit 5ed87da68f
No known key found for this signature in database
21 changed files with 648 additions and 121 deletions

View file

@ -88,6 +88,7 @@ Usage:
verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue] verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue]
verae-print netprint uninstall --owner driver|standalone verae-print netprint uninstall --owner driver|standalone
verae-print netprint launch-tray # Windows: start the tray as the logged-in user verae-print netprint launch-tray # Windows: start the tray as the logged-in user
verae-print netprint enable-receipts-share # macOS: save shareReceiptsSMB and publish the folder
verae-print tray # menu-bar / system-tray control for the network printer verae-print tray # menu-bar / system-tray control for the network printer
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH] verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH]

View file

@ -18,7 +18,7 @@ import (
func runNetprint(args []string) int { func runNetprint(args []string) int {
if len(args) > 0 { if len(args) > 0 {
switch args[0] { switch args[0] {
case "install", "uninstall", "launch-tray": case "install", "uninstall", "launch-tray", "enable-receipts-share":
return runNetprintInstall(args) return runNetprintInstall(args)
} }
// An unknown word must not fall through into the server. That is how // An unknown word must not fall through into the server. That is how
@ -67,6 +67,8 @@ func runNetprintInstall(args []string) int {
switch args[0] { switch args[0] {
case "launch-tray": case "launch-tray":
err = netprint.LaunchTrayNow() err = netprint.LaunchTrayNow()
case "enable-receipts-share":
err = netprint.EnableReceiptsShare()
case "install": case "install":
pub, perr := parseAllowPublic(*allowPublic) pub, perr := parseAllowPublic(*allowPublic)
if perr != nil { if perr != nil {

View file

@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json
## Receipts on the phone ## Receipts on the phone
`http://<host>:<port>/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://<hostname>.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer has an optional checkbox, **Share receipts over SMB (read-only)**, off unless you check it. It shares that folder with guest access off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://<host>:<port>/ios-shortcut`. `http://<host>:<port>/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN. The menu bar **Open Receipts…** item opens the live `receiptsUrl` from status (for example `http://192.168.1.108:631/receipts` or `http://<hostname>.local:631/receipts`), not a fixed address. **Open Settings…** opens the control page. The menu also shows how to print from an iPhone: Share → Print → Select Printer → the advertised printer name. The status JSON fields are `receiptsUrl` and `receiptsDir`. On macOS `receiptsDir` is `/Users/Shared/Verae Receipts`. That folder holds the job files and is readable by the console user. The macOS installer Customize pane has **Share receipts folder over the network (SMB)**, off unless you check it. When checked, postinstall shares that folder as **Verae Receipts** (`sharing -a` with `-s 001 -g 000`: SMB only, guest off) and enables `com.apple.smbd` so File Sharing stays on across reboots. The choice is saved as `shareReceiptsSMB` in `netprint.json`. Settings and the menu bar can turn it on or off later. If the choice is on and the share is missing, the daemon recreates it at startup, and the menu-bar app asks it to again at login. Status JSON includes `shareReceiptsSMB` and `smbUrl` (for example `smb://Georges-MacBook-Pro.local/Verae%20Receipts`). The receipts page shows that address. The first connection from iPhone Files needs a username and password from this Mac. Guest access is off. The Windows installers have the same checkbox, off unless you check it or pass `/SMB=1`, and create a read-only share named `Verae Receipts`. The program stays in `/Library/Application Support/Verae/NetPrint/bin` and is not in that folder. An older install that stored jobs next to the program moves those job files on the next start. The page links to the iPhone shortcut and shows a QR code for `http://<host>:<port>/ios-shortcut`.
## Stamp and return ## Stamp and return
@ -74,7 +74,7 @@ sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target /
`postinstall` checks that `verae-print`, `verae-airdrop`, and `verae-menubar` are in the app, then runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. `postinstall` checks that `verae-print`, `verae-airdrop`, and `verae-menubar` are in the app, then runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there.
`verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts over SMB (read-only)**, unselected unless you check it. `verae-print tray` loads AppKit and stays in `NSApplication run` on the main thread. If that run returns without Quit, or the status item cannot be created, it logs the reason to `~/Library/Logs/Verae/netprint-tray.log` and execs `verae-menubar` in the same app (a small AppKit status item with status, Start, Stop, Open Receipts…, Open Settings…, Share receipts folder over SMB, and AirDrop again). A failure exits 1. It does not exit 0 when the menu bar cannot stay up. `verae-print tray --smoke` prints `smoke ok` or `no window server` and does not enter the run loop. Customize on the installer shows **Share receipts folder over the network (SMB)**, unselected unless you check it. That choice is saved as `shareReceiptsSMB`. The first connection from iPhone Files needs a username and password from this Mac.
## AirDrop on the Mac ## AirDrop on the Mac
@ -84,7 +84,7 @@ Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off
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. 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.
A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, and `receiptsPinSet`. It does not include the SMTP password or the receipts PIN. A silent Windows install (`/S`) does not wait on a dialog. Upgrades stop `VeraeNetPrint`, wait until it is stopped, and close `verae-netprint-tray.exe` before replacing those files. If a file is still locked, setup logs the error to `%ProgramData%\Verae\logs\netprint-installer.log` and exits with a failure instead of leaving the old binary in place. The service is started again after the new files are in place. `verae-print netprint install` starts the tray once, through Explorer (via a one-shot scheduled task when an interactive user is available). A second copy exits immediately on the `Local\VeraeNetPrintTray` mutex. The tray is not a Task Scheduler job and not a child of setup, so `Start-Process -Wait` returns and the icon stays up. Tray startup, mutex, and message-loop errors are appended to `netprint.log`. An unknown `netprint` subcommand does not start a second server. The service appends startup, mDNS, and job lines to `%ProgramData%\Verae\logs\netprint.log` and flushes each line, including when the process has no console. Job numbers continue from `%ProgramData%\Verae\NetPrint\next-job` and from existing `job-N` folders, so a restart does not write a new document into an old folder. `http://127.0.0.1:8630/api/v1/status` includes `mdnsAdvertising`, `mdnsMode`, `mdnsDetail`, `mdnsError`, `receiptsUrl`, `receiptsDir`, `ippBind`, `emailEnabled`, `emailTo`, `emailError`, `smtpHost`, `smtpUser`, `smtpPasswordSet`, `receiptsPinSet`, `shareReceiptsSMB`, and `smbUrl`. It does not include the SMTP password or the receipts PIN.
macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary. macOS upgrades run `launchctl bootout` on `com.verae.netprint` before the package copies the binary. Debian upgrades run `systemctl stop verae-netprint` from `preinst` when the unit is running that package's binary.

View file

@ -17,6 +17,8 @@ type Admin struct {
Name func() string Name func() string
Port func() int Port func() int
Public bool Public bool
// SMBURL is the live receipts share, empty when sharing is off.
SMBURL func() string
} }
func (a *Admin) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (a *Admin) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View file

@ -20,7 +20,7 @@ func TestAirDropDefaultsOffAndMenu(t *testing.T) {
if Defaults().AirDropOn() { if Defaults().AirDropOn() {
t.Fatal("AirDrop is opt-in") t.Fatal("AirDrop is opt-in")
} }
if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) { if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) || !strings.Contains(string(ExampleJSON("/tmp")), `"shareReceiptsSMB": false`) {
t.Fatal("sample config should show the switch off") t.Fatal("sample config should show the switch off")
} }
st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true} st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true}
@ -298,13 +298,14 @@ func TestMacAirDropAndPackageSources(t *testing.T) {
{"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}}, {"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter", "notificationsAllowed", "bundleIdentifier"}},
{"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}}, {"../../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", "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print"}}, {"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", "verae-menubar", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint", "Share receipts over SMB"}}, {"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons", `uname -s`, "Darwin", "productbuild", "verae-airdrop", "verae-menubar", "LSUIElement", "NSLocalNetworkUsageDescription", "ai.verae.netprint", "Share receipts folder over the network (SMB)", "shareReceiptsSMB"}},
{"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "verae-menubar", "Verae NetPrint.app", "LSUIElement", "Share receipts over SMB"}}, {"../../platform/macos/pkg/check-pkg.sh", []string{"pkgutil --expand", "pkgutil --check-signature", "installer -pkginfo", "verae-airdrop", "verae-menubar", "Verae NetPrint.app", "LSUIElement", "Share receipts folder over the network (SMB)"}},
{"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop", "verae-menubar", "tray --smoke"}}, {"../../.github/workflows/installers.yml", []string{"macos-14", "pkgutil --expand", "installer -pkginfo", "verae-airdrop", "verae-menubar", "tray --smoke"}},
{"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild", "netprint-tray.log", "Open Receipts…"}}, {"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed", "/Users/Shared/Verae Receipts", "receiptsDir", "pkgbuild", "netprint-tray.log", "Open Receipts…", "shareReceiptsSMB", "smbUrl", "username and password"}},
{"tray_darwin.go", []string{"netprint-tray.log", "AppKit.framework", "verae-menubar", "NSApplication run returned without quit"}}, {"tray_darwin.go", []string{"netprint-tray.log", "AppKit.framework", "verae-menubar", "NSApplication run returned without quit"}},
{"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server"}}, {"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server", "Share receipts folder over SMB", "receipts-share/ensure"}},
{"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing"}}, {"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing", "-s 001", "-g 000", "launchctl enable system/com.apple.smbd", "enable-receipts-share", "share-receipts-smb.on"}},
{"share_darwin.go", []string{"-s", "001", "-g", "000", "launchctl", "system/com.apple.smbd", "smb_read_only", "receiptsShareName", "darwinSharedReceiptsDir"}},
{"../../platform/windows/installer-netprint.nsi", []string{"/SMB=", "GRANT:Everyone,READ", "Verae Receipts"}}, {"../../platform/windows/installer-netprint.nsi", []string{"/SMB=", "GRANT:Everyone,READ", "Verae Receipts"}},
{"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}},
{"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}},

View file

@ -66,6 +66,9 @@ type Config struct {
// sheet after each completed job. Nil and false do not. The root daemon // sheet after each completed job. Nil and false do not. The root daemon
// never presents the sheet; it only tells the user's agent. // never presents the sheet; it only tells the user's agent.
AirDropEnabled *bool `json:"airDropEnabled,omitempty"` AirDropEnabled *bool `json:"airDropEnabled,omitempty"`
// ShareReceiptsSMB shares the macOS receipts folder over SMB. Nil and
// false do not. The installer checkbox and Settings both write this.
ShareReceiptsSMB *bool `json:"shareReceiptsSMB,omitempty"`
} }
const ( const (
@ -94,6 +97,11 @@ func (c Config) EmailOn() bool { return c.EmailEnabled != nil && *c.EmailEnabled
// AirDropOn reports whether the menu-bar app should AirDrop each completed job. // AirDropOn reports whether the menu-bar app should AirDrop each completed job.
func (c Config) AirDropOn() bool { return c.AirDropEnabled != nil && *c.AirDropEnabled } func (c Config) AirDropOn() bool { return c.AirDropEnabled != nil && *c.AirDropEnabled }
// ShareReceiptsOn reports whether the receipts folder should be an SMB share.
func (c Config) ShareReceiptsOn() bool {
return c.ShareReceiptsSMB != nil && *c.ShareReceiptsSMB
}
// MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo. // MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo.
func (c Config) MailRecipients() []string { func (c Config) MailRecipients() []string {
raw := strings.TrimSpace(c.EmailTo) raw := strings.TrimSpace(c.EmailTo)
@ -125,6 +133,7 @@ func Defaults() Config {
adv := true adv := true
emailOff := false emailOff := false
airDropOff := false airDropOff := false
shareOff := false
return Config{ return Config{
Name: defaultServiceName(), Name: defaultServiceName(),
Port: 631, Port: 631,
@ -135,6 +144,7 @@ func Defaults() Config {
Advertise: &adv, Advertise: &adv,
EmailEnabled: &emailOff, EmailEnabled: &emailOff,
AirDropEnabled: &airDropOff, AirDropEnabled: &airDropOff,
ShareReceiptsSMB: &shareOff,
EmailTo: DefaultEmailTo, EmailTo: DefaultEmailTo,
SMTPHost: DefaultSMTPHost, SMTPHost: DefaultSMTPHost,
SMTPPort: 587, SMTPPort: 587,
@ -341,6 +351,10 @@ func mergeConfig(dst *Config, src Config) {
v := *src.AirDropEnabled v := *src.AirDropEnabled
dst.AirDropEnabled = &v dst.AirDropEnabled = &v
} }
if src.ShareReceiptsSMB != nil {
v := *src.ShareReceiptsSMB
dst.ShareReceiptsSMB = &v
}
} }
func (c *Config) normalize() error { func (c *Config) normalize() error {
@ -378,6 +392,10 @@ func (c *Config) normalize() error {
f := false f := false
c.AirDropEnabled = &f c.AirDropEnabled = &f
} }
if c.ShareReceiptsSMB == nil {
f := false
c.ShareReceiptsSMB = &f
}
if strings.TrimSpace(c.EmailTo) == "" { if strings.TrimSpace(c.EmailTo) == "" {
c.EmailTo = DefaultEmailTo c.EmailTo = DefaultEmailTo
} }
@ -408,6 +426,7 @@ func ExampleJSON(dataDir string) []byte {
public := false public := false
emailOff := false emailOff := false
airDropOff := false airDropOff := false
shareOff := false
c := Config{ c := Config{
Name: defaultServiceName(), Name: defaultServiceName(),
Port: 631, Port: 631,
@ -420,6 +439,7 @@ func ExampleJSON(dataDir string) []byte {
AllowPublic: &public, AllowPublic: &public,
EmailEnabled: &emailOff, EmailEnabled: &emailOff,
AirDropEnabled: &airDropOff, AirDropEnabled: &airDropOff,
ShareReceiptsSMB: &shareOff,
EmailTo: DefaultEmailTo, EmailTo: DefaultEmailTo,
SMTPHost: DefaultSMTPHost, SMTPHost: DefaultSMTPHost,
SMTPPort: 587, SMTPPort: 587,

View file

@ -50,6 +50,15 @@ type Status struct {
Advertise bool `json:"advertise"` Advertise bool `json:"advertise"`
AirDropEnabled bool `json:"airDropEnabled"` AirDropEnabled bool `json:"airDropEnabled"`
SupportsAirDrop bool `json:"supportsAirDrop"` SupportsAirDrop bool `json:"supportsAirDrop"`
// ShareReceiptsSMB is the saved choice to publish the receipts folder.
ShareReceiptsSMB bool `json:"shareReceiptsSMB"`
// SupportsSMBShare is true on macOS, where sharing(8) can create the point.
SupportsSMBShare bool `json:"supportsSMBShare"`
// SMBURL is the live address, for example
// smb://Georges-MacBook-Pro.local/Verae%20Receipts. Empty when the share is off.
SMBURL string `json:"smbUrl,omitempty"`
// SMBError is set when the share could not be created or refreshed.
SMBError string `json:"smbError,omitempty"`
// ReceiptsDir is the folder of job files. On macOS it is separate from // ReceiptsDir is the folder of job files. On macOS it is separate from
// the program so it can be shared read-only. // the program so it can be shared read-only.
ReceiptsDir string `json:"receiptsDir"` ReceiptsDir string `json:"receiptsDir"`
@ -147,6 +156,16 @@ func (c *Client) SetAirDropEnabled(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil) return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil)
} }
// SetShareReceiptsSMB turns the macOS receipts SMB share on or off.
func (c *Client) SetShareReceiptsSMB(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"shareReceiptsSMB": on}, nil)
}
// EnsureReceiptsShare asks the daemon to recreate the share when the saved choice is on.
func (c *Client) EnsureReceiptsShare() error {
return c.post("/api/v1/receipts-share/ensure", map[string]bool{}, nil)
}
// Auth asks the service to store an xVerae- signing key. // Auth asks the service to store an xVerae- signing key.
func (c *Client) Auth(apiKey string) error { func (c *Client) Auth(apiKey string) error {
return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil)
@ -210,6 +229,7 @@ func (s *Server) status() Status {
s.mu.Lock() s.mu.Lock()
mdns := s.mdns mdns := s.mdns
emailErr := s.emailErr emailErr := s.emailErr
smbErr := s.smbErr
s.mu.Unlock() s.mu.Unlock()
last := "" last := ""
if !mdns.LastSent.IsZero() { if !mdns.LastSent.IsZero() {
@ -253,6 +273,10 @@ func (s *Server) status() Status {
Advertise: cfg.MDNS(), Advertise: cfg.MDNS(),
AirDropEnabled: cfg.AirDropOn(), AirDropEnabled: cfg.AirDropOn(),
SupportsAirDrop: runtime.GOOS == "darwin", SupportsAirDrop: runtime.GOOS == "darwin",
ShareReceiptsSMB: cfg.ShareReceiptsOn(),
SupportsSMBShare: supportsSMBShare(),
SMBURL: smbURLIfOn(cfg),
SMBError: smbErr,
ReceiptsDir: cfg.DataDir, ReceiptsDir: cfg.DataDir,
IPPBind: s.ippBind, IPPBind: s.ippBind,
} }
@ -388,6 +412,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
ClearPIN *bool `json:"clearPin"` ClearPIN *bool `json:"clearPin"`
Advertise *bool `json:"advertise"` Advertise *bool `json:"advertise"`
AirDropEnabled *bool `json:"airDropEnabled"` AirDropEnabled *bool `json:"airDropEnabled"`
ShareReceiptsSMB *bool `json:"shareReceiptsSMB"`
} }
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "bad settings", http.StatusBadRequest) http.Error(w, "bad settings", http.StatusBadRequest)
@ -421,6 +446,12 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
v := *body.AirDropEnabled v := *body.AirDropEnabled
cfg.AirDropEnabled = &v cfg.AirDropEnabled = &v
} }
shareChanged := false
if body.ShareReceiptsSMB != nil {
v := *body.ShareReceiptsSMB
cfg.ShareReceiptsSMB = &v
shareChanged = true
}
if body.EmailTo != nil { if body.EmailTo != nil {
cfg.EmailTo = strings.TrimSpace(*body.EmailTo) cfg.EmailTo = strings.TrimSpace(*body.EmailTo)
} }
@ -461,8 +492,11 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
applyFirewall(*body.AllowPublic) applyFirewall(*body.AllowPublic)
log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) log.Printf("netprint: firewall public networks=%v", *body.AllowPublic)
} }
log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn())
s.syncAdvertiser() s.syncAdvertiser()
if shareChanged {
s.noteShare(cfg.ShareReceiptsOn())
}
writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"})
} }
@ -543,11 +577,15 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
cfg.AllowPublic = &pub cfg.AllowPublic = &pub
emailOn := r.FormValue("emailEnabled") == "1" emailOn := r.FormValue("emailEnabled") == "1"
cfg.EmailEnabled = &emailOn cfg.EmailEnabled = &emailOn
shareChanged := false
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
adv := r.FormValue("advertise") == "1" adv := r.FormValue("advertise") == "1"
cfg.Advertise = &adv cfg.Advertise = &adv
air := r.FormValue("airDropEnabled") == "1" air := r.FormValue("airDropEnabled") == "1"
cfg.AirDropEnabled = &air cfg.AirDropEnabled = &air
share := r.FormValue("shareReceiptsSMB") == "1"
cfg.ShareReceiptsSMB = &share
shareChanged = true
} }
cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo"))
cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost"))
@ -591,8 +629,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn())
s.syncAdvertiser() s.syncAdvertiser()
if shareChanged {
s.noteShare(cfg.ShareReceiptsOn())
}
http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
return return
} }
@ -609,12 +650,19 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
} }
mac := "" mac := ""
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
shareNote := ""
if u := smbURLIfOn(cfg); u != "" {
shareNote = fmt.Sprintf(`<p>Files: <a href="%s">%s</a>. On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.</p>`, htmlEscape(u), htmlEscape(u))
}
mac = fmt.Sprintf(`<h2>Mac</h2> mac = fmt.Sprintf(`<h2>Mac</h2>
<label class="check"><input name="advertise" type="checkbox" value="1" %s> Advertise on the network</label> <label class="check"><input name="advertise" type="checkbox" value="1" %s> Advertise on the network</label>
<p>mDNSResponder registers <code>_ipp._tcp</code> with the AirPrint <code>_universal</code> and IPP Everywhere <code>_print</code> subtypes. macOS asks once for Local Network permission for verae-print. Allow it.</p> <p>mDNSResponder registers <code>_ipp._tcp</code> with the AirPrint <code>_universal</code> and IPP Everywhere <code>_print</code> subtypes. macOS asks once for Local Network permission for verae-print. Allow it.</p>
<label class="check"><input name="airDropEnabled" type="checkbox" value="1" %s> AirDrop stamped PDF after each job</label> <label class="check"><input name="airDropEnabled" type="checkbox" value="1" %s> AirDrop stamped PDF after each job</label>
<p>The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.</p>`, <p>The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.</p>
checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn())) <label class="check"><input name="shareReceiptsSMB" type="checkbox" value="1" %s> Share receipts folder over the network (SMB)</label>
<p>Shares <code>/Users/Shared/Verae Receipts</code> as Verae Receipts. SMB only, guest access off, read-only. File Sharing stays on across reboots. The folder does not contain the program.</p>
%s`,
checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()), checkedAttr(cfg.ShareReceiptsOn()), shareNote)
} }
var b strings.Builder var b strings.Builder
b.WriteString("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width, initial-scale=1\"><title>Verae network printer</title>") b.WriteString("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width, initial-scale=1\"><title>Verae network printer</title>")

View file

@ -159,6 +159,11 @@ p{margin:4px 0}
a.btn{display:inline-block;min-height:44px;line-height:44px;padding:0 14px;margin:8px 8px 0 0;background:#111;color:#fff;text-decoration:none;border-radius:8px} a.btn{display:inline-block;min-height:44px;line-height:44px;padding:0 14px;margin:8px 8px 0 0;background:#111;color:#fff;text-decoration:none;border-radius:8px}
</style></head><body>`) </style></head><body>`)
fmt.Fprintf(&b, "<h1>%s</h1><p>Recent timestamped jobs, newest first.</p>", html.EscapeString(name)) fmt.Fprintf(&b, "<h1>%s</h1><p>Recent timestamped jobs, newest first.</p>", html.EscapeString(name))
if a.SMBURL != nil {
if u := strings.TrimSpace(a.SMBURL()); u != "" {
fmt.Fprintf(&b, `<p>Open these files from another device: <a href="%s">%s</a></p><p>On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.</p>`, html.EscapeString(u), html.EscapeString(u))
}
}
if r.Host != "" { if r.Host != "" {
shortcut := "http://" + r.Host + "/ios-shortcut" shortcut := "http://" + r.Host + "/ios-shortcut"
fmt.Fprintf(&b, `<p><a class="btn" href="/ios-shortcut">iPhone Shortcut</a></p>`) fmt.Fprintf(&b, `<p><a class="btn" href="/ios-shortcut">iPhone Shortcut</a></p>`)

View file

@ -33,6 +33,7 @@ type Server struct {
mdnsGen int mdnsGen int
mdns mdnsReport mdns mdnsReport
emailErr string emailErr string
smbErr string
uuid string uuid string
jobs jobBus jobs jobBus
ippBind string ippBind string
@ -110,7 +111,12 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob)
} }
admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} admin := &Admin{
Store: st,
Name: func() string { return s.snapshot().Name },
Port: func() int { return s.port },
SMBURL: func() string { return smbURLIfOn(s.snapshot()) },
}
pub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && (strings.Contains(r.Header.Get("Content-Type"), "application/ipp") || strings.HasPrefix(r.URL.Path, "/ipp")) { if r.Method == http.MethodPost && (strings.Contains(r.Header.Get("Content-Type"), "application/ipp") || strings.HasPrefix(r.URL.Path, "/ipp")) {
s.printer.ServeHTTP(w, r) s.printer.ServeHTTP(w, r)
@ -155,6 +161,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth)
ctrlMux.HandleFunc("/api/v1/events", s.handleEvents) ctrlMux.HandleFunc("/api/v1/events", s.handleEvents)
ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck)
ctrlMux.HandleFunc("/api/v1/receipts-share/ensure", s.handleEnsureShare)
ctrlMux.HandleFunc("/settings", s.handleSettingsPage) ctrlMux.HandleFunc("/settings", s.handleSettingsPage)
ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !loopback(r) { if !loopback(r) {
@ -177,10 +184,56 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl)
go func() { _ = s.ippSrv.Serve(ippLn) }() go func() { _ = s.ippSrv.Serve(ippLn) }()
go func() { _ = s.controlSrv.Serve(ctlLn) }() go func() { _ = s.controlSrv.Serve(ctlLn) }()
s.syncAdvertiser() s.syncAdvertiser()
go s.syncReceiptsShare()
log.Printf("netprint: IPP on %s (%s), receipts %s, control on %s", listenerAddrs(ippLn), s.ippBind, cfg.DataDir, ctlLn.Addr()) log.Printf("netprint: IPP on %s (%s), receipts %s, control on %s", listenerAddrs(ippLn), s.ippBind, cfg.DataDir, ctlLn.Addr())
return s, nil return s, nil
} }
func (s *Server) noteShare(on bool) {
err := applyReceiptsShare(on)
s.mu.Lock()
if err != nil {
s.smbErr = err.Error()
} else {
s.smbErr = ""
}
s.mu.Unlock()
if err != nil {
log.Printf("netprint: receipts share: %v", err)
return
}
if on {
if u := smbURLIfOn(s.snapshot()); u != "" {
log.Printf("netprint: receipts share %s", u)
}
return
}
log.Printf("netprint: receipts share off")
}
func (s *Server) syncReceiptsShare() {
if !s.snapshot().ShareReceiptsOn() {
return
}
s.noteShare(true)
}
func (s *Server) handleEnsureShare(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.snapshot().ShareReceiptsOn() {
s.noteShare(true)
}
st := s.status()
writeJSON(w, map[string]any{
"shareReceiptsSMB": st.ShareReceiptsSMB,
"smbUrl": st.SMBURL,
"smbError": st.SMBError,
})
}
func (s *Server) snapshot() Config { func (s *Server) snapshot() Config {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View file

@ -141,18 +141,31 @@ type InstallOptions struct {
} }
func noteInstallOptions(cfgPath string, opts InstallOptions) { func noteInstallOptions(cfgPath string, opts InstallOptions) {
if opts.AllowPublic == nil {
return
}
cfg, err := Load(cfgPath, Config{}) cfg, err := Load(cfgPath, Config{})
if err != nil { if err != nil {
log.Printf("netprint: install options: %v", err) log.Printf("netprint: install options: %v", err)
return return
} }
changed := false
if opts.AllowPublic != nil {
v := *opts.AllowPublic v := *opts.AllowPublic
cfg.AllowPublic = &v cfg.AllowPublic = &v
changed = true
}
if consumeReceiptsShareStamp(&cfg) {
changed = true
}
if !changed {
return
}
if err := Save(cfgPath, cfg); err != nil { if err := Save(cfgPath, cfg); err != nil {
log.Printf("netprint: save allowPublic: %v", err) log.Printf("netprint: save install options: %v", err)
return
}
if cfg.ShareReceiptsOn() {
if err := applyReceiptsShare(true); err != nil {
log.Printf("netprint: receipts share: %v", err)
}
} }
} }

View file

@ -222,15 +222,6 @@ func Uninstall(owner string) error {
return nil return nil
} }
// removeReceiptsShare drops the optional read-only SMB point. The receipts
// folder itself stays. sharing -r is a no-op when the point was never created.
func removeReceiptsShare() {
if _, err := exec.LookPath("sharing"); err != nil {
return
}
_ = exec.Command("sharing", "-r", "Verae Receipts").Run()
}
func currentServiceExe() string { func currentServiceExe() string {
b, err := os.ReadFile(daemonPlist) b, err := os.ReadFile(daemonPlist)
if err != nil { if err != nil {

118
internal/netprint/share.go Normal file
View file

@ -0,0 +1,118 @@
package netprint
import (
"log"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const receiptsShareName = "Verae Receipts"
// receiptsShareStampPath is written by the optional installer package when
// the config file does not exist yet. Install consumes it once.
func receiptsShareStampPath() string {
return filepath.Join(filepath.Dir(SystemConfigPath()), "share-receipts-smb.on")
}
// formatSMBURL is the address shown on the receipts page, for example
// smb://Georges-MacBook-Pro.local/Verae%20Receipts. An IP is used as-is.
func formatSMBURL(host string) string {
host = strings.TrimSpace(host)
host = strings.TrimSuffix(host, ".")
if host == "" || strings.EqualFold(host, "localhost") {
host = "localhost"
} else if net.ParseIP(host) == nil && !strings.HasSuffix(strings.ToLower(host), ".local") {
host += ".local"
}
return "smb://" + host + "/" + url.PathEscape(receiptsShareName)
}
func smbHost() string {
h, err := os.Hostname()
if err == nil {
h = strings.TrimSpace(h)
h = strings.TrimSuffix(h, ".local")
if h != "" && !strings.EqualFold(h, "localhost") {
return h
}
}
if ips := lanIPv4(); len(ips) > 0 {
return ips[0].String()
}
return "localhost"
}
func smbURLIfOn(cfg Config) string {
if !cfg.ShareReceiptsOn() || !supportsSMBShare() {
return ""
}
return formatSMBURL(smbHost())
}
// EnableReceiptsShare records shareReceiptsSMB and creates the share.
// It does not start the print server. The macOS installer calls it.
func EnableReceiptsShare() error {
path := SystemConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
cfg, err := Load(path, Config{})
if err != nil {
return err
}
on := true
cfg.ShareReceiptsSMB = &on
if err := cfg.normalize(); err != nil {
return err
}
if err := Save(path, cfg); err != nil {
return err
}
_ = os.Remove(receiptsShareStampPath())
if err := applyReceiptsShare(true); err != nil {
return err
}
kickstartNetprint()
return nil
}
// consumeReceiptsShareStamp turns the share on when the installer left a
// marker because the config file was not there yet. The marker is removed
// so a later upgrade does not turn the share back on after Settings saved off.
func consumeReceiptsShareStamp(cfg *Config) bool {
p := receiptsShareStampPath()
if _, err := os.Stat(p); err != nil {
return false
}
on := true
cfg.ShareReceiptsSMB = &on
_ = os.Remove(p)
return true
}
// healReceiptsShare asks a running daemon to recreate a missing share.
// The menu-bar process is not root, so it does not call sharing(8) itself.
func healReceiptsShare() {
if os.Getenv("VERAE_TRAY_SMOKE") == "1" {
return
}
go func() {
c := NewClient(DefaultControlPort)
var last error
for i := 0; i < 5; i++ {
if err := c.EnsureReceiptsShare(); err == nil {
return
} else {
last = err
}
time.Sleep(time.Second)
}
if last != nil {
log.Printf("netprint tray: receipts share: %v", last)
}
}()
}

View file

@ -0,0 +1,101 @@
//go:build darwin
package netprint
import (
"fmt"
"os"
"os/exec"
"strings"
)
func supportsSMBShare() bool { return true }
// applyReceiptsShare creates or removes the read-only SMB point.
// The folder stays root-owned and not world-writable. Guests stay off.
// File Sharing is enabled so the share is still there after reboot.
func applyReceiptsShare(on bool) error {
if !on {
removeReceiptsShare()
return nil
}
if err := os.MkdirAll(darwinSharedReceiptsDir, 0o755); err != nil {
return err
}
_ = os.Chown(darwinSharedReceiptsDir, 0, 0)
if err := os.Chmod(darwinSharedReceiptsDir, 0o755); err != nil {
return err
}
if _, err := exec.LookPath("sharing"); err != nil {
return fmt.Errorf("sharing(8) is not available")
}
if !receiptsSharePresent() {
// -s 001 is SMB only. -g 000 keeps guest access off.
out, err := exec.Command("sharing",
"-a", darwinSharedReceiptsDir,
"-n", receiptsShareName,
"-S", receiptsShareName,
"-s", "001",
"-g", "000",
).CombinedOutput()
if err != nil && !receiptsSharePresent() {
return fmt.Errorf("sharing: %w (%s)", err, strings.TrimSpace(string(out)))
}
}
setReceiptsShareReadOnly()
ensureFileSharing()
return nil
}
func receiptsSharePresent() bool {
out, err := exec.Command("sharing", "-l").CombinedOutput()
if err != nil {
return false
}
text := string(out)
return strings.Contains(text, receiptsShareName) && strings.Contains(text, darwinSharedReceiptsDir)
}
func setReceiptsShareReadOnly() {
if _, err := exec.LookPath("dscl"); err != nil {
return
}
rec := "/SharePoints/" + receiptsShareName
if err := exec.Command("dscl", ".", "-read", rec).Run(); err != nil {
return
}
for _, kv := range [][2]string{
{"smb_shared", "1"},
{"smb_guestaccess", "0"},
{"smb_read_only", "1"},
{"directory_path", darwinSharedReceiptsDir},
} {
_ = exec.Command("dscl", ".", "-create", rec, kv[0], kv[1]).Run()
}
}
// ensureFileSharing turns smbd on and leaves it enabled across reboots.
// A running smbd is not restarted.
func ensureFileSharing() {
_ = exec.Command("launchctl", "enable", "system/com.apple.smbd").Run()
out, err := exec.Command("launchctl", "print", "system/com.apple.smbd").CombinedOutput()
if err == nil && strings.Contains(string(out), "state = running") {
return
}
if exec.Command("launchctl", "bootstrap", "system", "/System/Library/LaunchDaemons/com.apple.smbd.plist").Run() == nil {
return
}
_ = exec.Command("launchctl", "kickstart", "system/com.apple.smbd").Run()
}
// removeReceiptsShare drops the SMB point. The receipts folder stays.
func removeReceiptsShare() {
if _, err := exec.LookPath("sharing"); err != nil {
return
}
_ = exec.Command("sharing", "-r", receiptsShareName).Run()
}
func kickstartNetprint() {
_ = exec.Command("launchctl", "kickstart", "-k", "system/com.verae.netprint").Run()
}

View file

@ -0,0 +1,11 @@
//go:build !darwin
package netprint
func supportsSMBShare() bool { return false }
func applyReceiptsShare(bool) error { return nil }
func removeReceiptsShare() {}
func kickstartNetprint() {}

View file

@ -0,0 +1,111 @@
package netprint
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func TestFormatSMBURL(t *testing.T) {
cases := map[string]string{
"Georges-MacBook-Pro": "smb://Georges-MacBook-Pro.local/Verae%20Receipts",
"Georges-MacBook-Pro.local": "smb://Georges-MacBook-Pro.local/Verae%20Receipts",
"192.168.1.108": "smb://192.168.1.108/Verae%20Receipts",
"": "smb://localhost/Verae%20Receipts",
}
for in, want := range cases {
if got := formatSMBURL(in); got != want {
t.Fatalf("%q -> %s, want %s", in, got, want)
}
}
if Defaults().ShareReceiptsOn() {
t.Fatal("share defaults off")
}
sample := string(ExampleJSON("/tmp"))
if !strings.Contains(sample, `"shareReceiptsSMB": false`) {
t.Fatal(sample)
}
}
func TestReceiptsPageShowsSMBURL(t *testing.T) {
st, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
const link = "smb://Georges-MacBook-Pro.local/Verae%20Receipts"
a := &Admin{Store: st, SMBURL: func() string { return link }}
req := httptest.NewRequest(http.MethodGet, "/receipts", nil)
rr := httptest.NewRecorder()
a.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, link) || !strings.Contains(body, "username and password") {
t.Fatal(body)
}
}
func TestMenuShareToggleUsesLiveURL(t *testing.T) {
st := Status{
Running: true, SupportsSMBShare: true, ShareReceiptsSMB: true,
SMBURL: "smb://Georges-MacBook-Pro.local/Verae%20Receipts",
}
var sawToggle, sawURL bool
for _, e := range BuildMenu(st) {
if e.Kind == "share" && e.Checked && e.Label == "Share receipts folder over SMB" {
sawToggle = true
}
if strings.Contains(e.Label, st.SMBURL) {
sawURL = true
}
}
if !sawToggle || !sawURL {
t.Fatalf("toggle %v url %v", sawToggle, sawURL)
}
off := BuildMenu(Status{Running: true})
for _, e := range off {
if e.Kind == "share" {
t.Fatal("share toggle is macOS only")
}
}
}
func TestShareReceiptsSettingRoundTrip(t *testing.T) {
cfg, path := testConfig(t)
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.Shutdown(ctx)
})
c := NewClient(cfg.ControlPort)
if err := c.SetShareReceiptsSMB(true); err != nil {
t.Fatal(err)
}
st, err := c.Status()
if err != nil || !st.ShareReceiptsSMB {
t.Fatalf("%+v %v", st, err)
}
if supportsSMBShare() {
if st.SMBURL == "" || !strings.Contains(st.SMBURL, "Verae%20Receipts") {
t.Fatal(st.SMBURL)
}
} else if st.SMBURL != "" {
t.Fatal(st.SMBURL)
}
saved, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(saved), `"shareReceiptsSMB": true`) {
t.Fatal(string(saved))
}
if err := c.EnsureReceiptsShare(); err != nil {
t.Fatal(err)
}
}

View file

@ -5,7 +5,10 @@ import "context"
// RunTray blocks in the platform menu (Windows notification area, macOS status // RunTray blocks in the platform menu (Windows notification area, macOS status
// item, or a freedesktop StatusNotifierItem). It talks to the print service // item, or a freedesktop StatusNotifierItem). It talks to the print service
// over the loopback control API. // over the loopback control API.
func RunTray(ctx context.Context) error { return runTray(ctx) } func RunTray(ctx context.Context) error {
healReceiptsShare()
return runTray(ctx)
}
// trayLoopExit describes why the Windows message loop returned. // trayLoopExit describes why the Windows message loop returned.
func trayLoopExit(code int32) string { func trayLoopExit(code int32) string {

View file

@ -19,6 +19,7 @@ const (
idAirDrop = 13 idAirDrop = 13
idAdvertise = 14 idAdvertise = 14
idAirDropAgain = 15 idAirDropAgain = 15
idShare = 16
idQuit = 100 idQuit = 100
idRecent = 20 idRecent = 20
) )
@ -85,6 +86,14 @@ func BuildMenu(st Status) []MenuEntry {
if st.SupportsAirDrop { if st.SupportsAirDrop {
out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"}) out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"})
} }
if st.SupportsSMBShare {
out = append(out, MenuEntry{ID: idShare, Label: "Share receipts folder over SMB", Enabled: st.Running, Checked: st.ShareReceiptsSMB, Kind: "share"})
}
if st.SMBError != "" {
out = append(out, MenuEntry{Label: "SMB — " + clipMenu(st.SMBError, 90), Kind: "status"})
} else if st.SMBURL != "" {
out = append(out, MenuEntry{Label: "Files — " + st.SMBURL, Kind: "status"})
}
if st.EmailError != "" { if st.EmailError != "" {
out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"})
} else if st.EmailEnabled { } else if st.EmailEnabled {
@ -231,6 +240,8 @@ func Dispatch(st Status, e MenuEntry) error {
return c.SetAdvertise(!st.Advertise) return c.SetAdvertise(!st.Advertise)
case "airdrop": case "airdrop":
return c.SetAirDropEnabled(!st.AirDropEnabled) return c.SetAirDropEnabled(!st.AirDropEnabled)
case "share":
return c.SetShareReceiptsSMB(!st.ShareReceiptsSMB)
case "airdrop-again": case "airdrop-again":
return airDropAgain() return airDropAgain()
case "open": case "open":

View file

@ -71,6 +71,9 @@ struct PrinterStatus {
var control = controlDefault var control = controlDefault
var address = "" var address = ""
var port = 0 var port = 0
var supportsShare = false
var shareOn = false
var smbURL = ""
} }
private func fetchStatus() -> PrinterStatus { private func fetchStatus() -> PrinterStatus {
@ -96,6 +99,9 @@ private func fetchStatus() -> PrinterStatus {
if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c } if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c }
st.port = obj["port"] as? Int ?? 0 st.port = obj["port"] as? Int ?? 0
if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first } if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first }
st.supportsShare = obj["supportsSMBShare"] as? Bool ?? false
st.shareOn = obj["shareReceiptsSMB"] as? Bool ?? false
st.smbURL = obj["smbUrl"] as? String ?? ""
return st return st
} }
@ -254,6 +260,7 @@ final class MenuTray: NSObject, NSMenuDelegate {
item.menu = menu item.menu = menu
events.tray = self events.tray = self
events.start() events.start()
_ = postJSON("/api/v1/receipts-share/ensure", [String: Any]())
rebuild() rebuild()
} }
@ -300,6 +307,13 @@ final class MenuTray: NSObject, NSMenuDelegate {
menu.addItem(.separator()) menu.addItem(.separator())
add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty) add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty)
add("Open Settings…", action: #selector(openSettings), enabled: true) add("Open Settings…", action: #selector(openSettings), enabled: true)
if status.supportsShare {
let title = status.shareOn ? "Share receipts folder over SMB ✓" : "Share receipts folder over SMB"
add(title, action: #selector(toggleShare), enabled: status.running)
}
if !status.smbURL.isEmpty {
add("Files — \(status.smbURL)", action: nil, enabled: false)
}
if !lastPDF.isEmpty { if !lastPDF.isEmpty {
let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)" let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)"
add(label, action: #selector(airDropAgain), enabled: true) add(label, action: #selector(airDropAgain), enabled: true)
@ -335,6 +349,12 @@ final class MenuTray: NSObject, NSMenuDelegate {
@objc func openReceipts() { open(receiptsURL()) } @objc func openReceipts() { open(receiptsURL()) }
@objc func openSettings() { open(settingsURL()) } @objc func openSettings() { open(settingsURL()) }
@objc func toggleShare() {
_ = postJSON("/api/v1/settings", ["shareReceiptsSMB": !status.shareOn])
status = fetchStatus()
rebuild()
}
@objc func airDropAgain() { @objc func airDropAgain() {
guard !lastPDF.isEmpty else { guard !lastPDF.isEmpty else {
trayLog("no stamped PDF to AirDrop") trayLog("no stamped PDF to AirDrop")

View file

@ -157,7 +157,7 @@ cat >"$DIST" <<EOF
<choice id="netprint" title="Verae Network Printer" description="LAN timestamp printer and menu-bar app." start_selected="true" enabled="false"> <choice id="netprint" title="Verae Network Printer" description="LAN timestamp printer and menu-bar app." start_selected="true" enabled="false">
<pkg-ref id="ai.verae.netprint.standalone"/> <pkg-ref id="ai.verae.netprint.standalone"/>
</choice> </choice>
<choice id="smb" title="Share receipts over SMB (read-only)" description="Optional. Shares /Users/Shared/Verae Receipts so another computer can open stamped files. Guests are off; sign in with an account on this Mac. Off unless you check this." start_selected="false"> <choice id="smb" title="Share receipts folder over the network (SMB)" description="Optional. Shares /Users/Shared/Verae Receipts as Verae Receipts, SMB only, guest access off, read-only. File Sharing stays on across reboots. The first connection from iPhone Files needs a username and password from this Mac. Off unless you check this. The choice is saved as shareReceiptsSMB and can be changed later in Settings." start_selected="false">
<pkg-ref id="ai.verae.netprint.smb"/> <pkg-ref id="ai.verae.netprint.smb"/>
</choice> </choice>
<pkg-ref id="ai.verae.netprint.standalone" version="${VERSION}" auth="root">netprint-component.pkg</pkg-ref> <pkg-ref id="ai.verae.netprint.standalone" version="${VERSION}" auth="root">netprint-component.pkg</pkg-ref>

View file

@ -63,5 +63,5 @@ if [[ ! -f "$dist" ]]; then
echo "expanded package has no Distribution" >&2 echo "expanded package has no Distribution" >&2
exit 1 exit 1
fi fi
grep -q 'Share receipts over SMB' "$dist" grep -q 'Share receipts folder over the network (SMB)' "$dist"
echo "package ok: $PKG" echo "package ok: $PKG"

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