diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index c62d16e..3bcf4a6 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -88,6 +88,7 @@ Usage: verae-print netprint install --owner driver|standalone [--allow-public true|false] [--local-queue] verae-print netprint uninstall --owner driver|standalone verae-print netprint launch-tray # Windows: start the tray as the logged-in user + verae-print netprint enable-receipts-share # macOS: save shareReceiptsSMB and publish the folder verae-print tray # menu-bar / system-tray control for the network printer verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH] diff --git a/cmd/verae-print/netprint_cmd.go b/cmd/verae-print/netprint_cmd.go index 64069d6..5d7007b 100644 --- a/cmd/verae-print/netprint_cmd.go +++ b/cmd/verae-print/netprint_cmd.go @@ -18,7 +18,7 @@ import ( func runNetprint(args []string) int { if len(args) > 0 { switch args[0] { - case "install", "uninstall", "launch-tray": + case "install", "uninstall", "launch-tray", "enable-receipts-share": return runNetprintInstall(args) } // 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] { case "launch-tray": err = netprint.LaunchTrayNow() + case "enable-receipts-share": + err = netprint.EnableReceiptsShare() case "install": pub, perr := parseAllowPublic(*allowPublic) if perr != nil { diff --git a/docs/network-printer.md b/docs/network-printer.md index 7391dfb..3768cb4 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/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://.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://:/ios-shortcut`. +`http://:/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://.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://:/ios-shortcut`. ## 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. -`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 @@ -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. -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. diff --git a/internal/netprint/admin.go b/internal/netprint/admin.go index 97035db..c097bc1 100644 --- a/internal/netprint/admin.go +++ b/internal/netprint/admin.go @@ -17,6 +17,8 @@ type Admin struct { Name func() string Port func() int 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) { diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go index b37f76d..9cae5fc 100644 --- a/internal/netprint/airdrop_test.go +++ b/internal/netprint/airdrop_test.go @@ -20,7 +20,7 @@ func TestAirDropDefaultsOffAndMenu(t *testing.T) { if Defaults().AirDropOn() { 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") } 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"}}, {"../../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"}}, - {"../../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/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/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 folder over the network (SMB)"}}, {"../../.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"}}, - {"../../platform/macos/menubar/main.swift", []string{"NSStatusItem", "Open Receipts…", "Open Settings…", "AirDrop again", "no window server"}}, - {"../../platform/macos/pkg/smb-share.sh", []string{"/Users/Shared/Verae Receipts", "smb_read_only", "sharing"}}, + {"../../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", "-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"}}, {"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}}, {"advertise_darwin.go", []string{`advertiseForOS("darwin"`}}, diff --git a/internal/netprint/config.go b/internal/netprint/config.go index 34bbe92..cdf119c 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -66,6 +66,9 @@ type Config struct { // sheet after each completed job. Nil and false do not. The root daemon // never presents the sheet; it only tells the user's agent. 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 ( @@ -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. 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. func (c Config) MailRecipients() []string { raw := strings.TrimSpace(c.EmailTo) @@ -125,21 +133,23 @@ func Defaults() Config { adv := true emailOff := false airDropOff := false + shareOff := false return Config{ - Name: defaultServiceName(), - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: defaultDataDir(), - KeepPDF: &keep, - Advertise: &adv, - EmailEnabled: &emailOff, - AirDropEnabled: &airDropOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: defaultServiceName(), + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: defaultDataDir(), + KeepPDF: &keep, + Advertise: &adv, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + ShareReceiptsSMB: &shareOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } } @@ -341,6 +351,10 @@ func mergeConfig(dst *Config, src Config) { v := *src.AirDropEnabled dst.AirDropEnabled = &v } + if src.ShareReceiptsSMB != nil { + v := *src.ShareReceiptsSMB + dst.ShareReceiptsSMB = &v + } } func (c *Config) normalize() error { @@ -378,6 +392,10 @@ func (c *Config) normalize() error { f := false c.AirDropEnabled = &f } + if c.ShareReceiptsSMB == nil { + f := false + c.ShareReceiptsSMB = &f + } if strings.TrimSpace(c.EmailTo) == "" { c.EmailTo = DefaultEmailTo } @@ -408,23 +426,25 @@ func ExampleJSON(dataDir string) []byte { public := false emailOff := false airDropOff := false + shareOff := false c := Config{ - Name: defaultServiceName(), - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: dataDir, - KeepPDF: &keep, - ForwardPrinter: "", - Advertise: &adv, - AllowPublic: &public, - EmailEnabled: &emailOff, - AirDropEnabled: &airDropOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: defaultServiceName(), + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: dataDir, + KeepPDF: &keep, + ForwardPrinter: "", + Advertise: &adv, + AllowPublic: &public, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + ShareReceiptsSMB: &shareOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } b, _ := json.MarshalIndent(c, "", " ") return append(b, '\n') diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 1285a11..d94eeea 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -50,6 +50,15 @@ type Status struct { Advertise bool `json:"advertise"` AirDropEnabled bool `json:"airDropEnabled"` 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 // the program so it can be shared read-only. 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) } +// 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. func (c *Client) Auth(apiKey string) error { return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) @@ -210,6 +229,7 @@ func (s *Server) status() Status { s.mu.Lock() mdns := s.mdns emailErr := s.emailErr + smbErr := s.smbErr s.mu.Unlock() last := "" if !mdns.LastSent.IsZero() { @@ -222,39 +242,43 @@ func (s *Server) status() Status { emailErr = "SMTP password is not set" } return Status{ - Running: true, - Name: cfg.Name, - Port: s.port, - Addresses: addrs, - AdminURL: admin, - JobsToday: today, - Recent: recent, - ForwardPrinter: cfg.ForwardPrinter, - LaunchAtLogin: launch, - TrayAtLogin: tray, - KeyPresent: auth.HasXVeraeToken(), - ControlURL: s.controlURL, - MDNSAdvertising: mdns.Advertising, - MDNSMode: mdns.Mode, - MDNSDetail: mdns.Detail, - MDNSError: mdns.Err, - MDNSLastSent: last, - AllowPublic: cfg.PublicNetworks(), - NetworkPublic: cachedNetworkPublic(), - SupportsPublic: supportsPublicFirewall(), - ReceiptsURL: admin, - EmailEnabled: cfg.EmailOn(), - EmailTo: cfg.EmailTo, - EmailError: emailErr, - SMTPHost: cfg.SMTPHost, - SMTPUser: cfg.SMTPUser, - SMTPPasswordSet: passwordSet, - ReceiptsPINSet: pinSet, - Advertise: cfg.MDNS(), - AirDropEnabled: cfg.AirDropOn(), - SupportsAirDrop: runtime.GOOS == "darwin", - ReceiptsDir: cfg.DataDir, - IPPBind: s.ippBind, + Running: true, + Name: cfg.Name, + Port: s.port, + Addresses: addrs, + AdminURL: admin, + JobsToday: today, + Recent: recent, + ForwardPrinter: cfg.ForwardPrinter, + LaunchAtLogin: launch, + TrayAtLogin: tray, + KeyPresent: auth.HasXVeraeToken(), + ControlURL: s.controlURL, + MDNSAdvertising: mdns.Advertising, + MDNSMode: mdns.Mode, + MDNSDetail: mdns.Detail, + MDNSError: mdns.Err, + MDNSLastSent: last, + AllowPublic: cfg.PublicNetworks(), + NetworkPublic: cachedNetworkPublic(), + SupportsPublic: supportsPublicFirewall(), + ReceiptsURL: admin, + EmailEnabled: cfg.EmailOn(), + EmailTo: cfg.EmailTo, + EmailError: emailErr, + SMTPHost: cfg.SMTPHost, + SMTPUser: cfg.SMTPUser, + SMTPPasswordSet: passwordSet, + ReceiptsPINSet: pinSet, + Advertise: cfg.MDNS(), + AirDropEnabled: cfg.AirDropOn(), + SupportsAirDrop: runtime.GOOS == "darwin", + ShareReceiptsSMB: cfg.ShareReceiptsOn(), + SupportsSMBShare: supportsSMBShare(), + SMBURL: smbURLIfOn(cfg), + SMBError: smbErr, + ReceiptsDir: cfg.DataDir, + IPPBind: s.ippBind, } } @@ -372,22 +396,23 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name *string `json:"name"` - ForwardPrinter *string `json:"forwardPrinter"` - Port *int `json:"port"` - AllowPublic *bool `json:"allowPublic"` - EmailEnabled *bool `json:"emailEnabled"` - EmailTo *string `json:"emailTo"` - SMTPHost *string `json:"smtpHost"` - SMTPPort *int `json:"smtpPort"` - SMTPTLS *string `json:"smtpTLS"` - SMTPUser *string `json:"smtpUser"` - SMTPPassword *string `json:"smtpPassword"` - ClearPassword *bool `json:"clearPassword"` - ReceiptsPIN *string `json:"receiptsPin"` - ClearPIN *bool `json:"clearPin"` - Advertise *bool `json:"advertise"` - AirDropEnabled *bool `json:"airDropEnabled"` + Name *string `json:"name"` + ForwardPrinter *string `json:"forwardPrinter"` + Port *int `json:"port"` + AllowPublic *bool `json:"allowPublic"` + EmailEnabled *bool `json:"emailEnabled"` + EmailTo *string `json:"emailTo"` + SMTPHost *string `json:"smtpHost"` + SMTPPort *int `json:"smtpPort"` + SMTPTLS *string `json:"smtpTLS"` + SMTPUser *string `json:"smtpUser"` + SMTPPassword *string `json:"smtpPassword"` + ClearPassword *bool `json:"clearPassword"` + ReceiptsPIN *string `json:"receiptsPin"` + ClearPIN *bool `json:"clearPin"` + Advertise *bool `json:"advertise"` + AirDropEnabled *bool `json:"airDropEnabled"` + ShareReceiptsSMB *bool `json:"shareReceiptsSMB"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "bad settings", http.StatusBadRequest) @@ -421,6 +446,12 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { v := *body.AirDropEnabled cfg.AirDropEnabled = &v } + shareChanged := false + if body.ShareReceiptsSMB != nil { + v := *body.ShareReceiptsSMB + cfg.ShareReceiptsSMB = &v + shareChanged = true + } if body.EmailTo != nil { cfg.EmailTo = strings.TrimSpace(*body.EmailTo) } @@ -461,8 +492,11 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { applyFirewall(*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() + if shareChanged { + s.noteShare(cfg.ShareReceiptsOn()) + } 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 emailOn := r.FormValue("emailEnabled") == "1" cfg.EmailEnabled = &emailOn + shareChanged := false if runtime.GOOS == "darwin" { adv := r.FormValue("advertise") == "1" cfg.Advertise = &adv air := r.FormValue("airDropEnabled") == "1" cfg.AirDropEnabled = &air + share := r.FormValue("shareReceiptsSMB") == "1" + cfg.ShareReceiptsSMB = &share + shareChanged = true } cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) @@ -591,8 +629,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { 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() + if shareChanged { + s.noteShare(cfg.ShareReceiptsOn()) + } http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) return } @@ -609,12 +650,19 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { } mac := "" if runtime.GOOS == "darwin" { + shareNote := "" + if u := smbURLIfOn(cfg); u != "" { + shareNote = fmt.Sprintf(`

Files: %s. 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.

`, htmlEscape(u), htmlEscape(u)) + } mac = fmt.Sprintf(`

Mac

mDNSResponder registers _ipp._tcp with the AirPrint _universal and IPP Everywhere _print subtypes. macOS asks once for Local Network permission for verae-print. Allow it.

-

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.

`, - checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn())) +

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.

+ +

Shares /Users/Shared/Verae Receipts as Verae Receipts. SMB only, guest access off, read-only. File Sharing stays on across reboots. The folder does not contain the program.

+%s`, + checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()), checkedAttr(cfg.ShareReceiptsOn()), shareNote) } var b strings.Builder b.WriteString("Verae network printer") diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go index b32167d..5ef2d9b 100644 --- a/internal/netprint/receipts.go +++ b/internal/netprint/receipts.go @@ -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} `) fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + if a.SMBURL != nil { + if u := strings.TrimSpace(a.SMBURL()); u != "" { + fmt.Fprintf(&b, `

Open these files from another device: %s

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.

`, html.EscapeString(u), html.EscapeString(u)) + } + } if r.Host != "" { shortcut := "http://" + r.Host + "/ios-shortcut" fmt.Fprintf(&b, `

iPhone Shortcut

`) diff --git a/internal/netprint/server.go b/internal/netprint/server.go index c1c537e..4cc07d6 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -33,6 +33,7 @@ type Server struct { mdnsGen int mdns mdnsReport emailErr string + smbErr string uuid string jobs jobBus 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) } - 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) { 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) @@ -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/events", s.handleEvents) ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) + ctrlMux.HandleFunc("/api/v1/receipts-share/ensure", s.handleEnsureShare) ctrlMux.HandleFunc("/settings", s.handleSettingsPage) ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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.controlSrv.Serve(ctlLn) }() 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()) 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 { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/netprint/service.go b/internal/netprint/service.go index eb58803..662e15c 100644 --- a/internal/netprint/service.go +++ b/internal/netprint/service.go @@ -141,18 +141,31 @@ type InstallOptions struct { } func noteInstallOptions(cfgPath string, opts InstallOptions) { - if opts.AllowPublic == nil { - return - } cfg, err := Load(cfgPath, Config{}) if err != nil { log.Printf("netprint: install options: %v", err) return } - v := *opts.AllowPublic - cfg.AllowPublic = &v + changed := false + if opts.AllowPublic != nil { + v := *opts.AllowPublic + cfg.AllowPublic = &v + changed = true + } + if consumeReceiptsShareStamp(&cfg) { + changed = true + } + if !changed { + return + } 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) + } } } diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go index 9ab8b2e..9855c50 100644 --- a/internal/netprint/service_darwin.go +++ b/internal/netprint/service_darwin.go @@ -222,15 +222,6 @@ func Uninstall(owner string) error { 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 { b, err := os.ReadFile(daemonPlist) if err != nil { diff --git a/internal/netprint/share.go b/internal/netprint/share.go new file mode 100644 index 0000000..3dce608 --- /dev/null +++ b/internal/netprint/share.go @@ -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) + } + }() +} diff --git a/internal/netprint/share_darwin.go b/internal/netprint/share_darwin.go new file mode 100644 index 0000000..0f5f861 --- /dev/null +++ b/internal/netprint/share_darwin.go @@ -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() +} diff --git a/internal/netprint/share_other.go b/internal/netprint/share_other.go new file mode 100644 index 0000000..a5d6a3f --- /dev/null +++ b/internal/netprint/share_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package netprint + +func supportsSMBShare() bool { return false } + +func applyReceiptsShare(bool) error { return nil } + +func removeReceiptsShare() {} + +func kickstartNetprint() {} diff --git a/internal/netprint/share_test.go b/internal/netprint/share_test.go new file mode 100644 index 0000000..5127c09 --- /dev/null +++ b/internal/netprint/share_test.go @@ -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) + } +} diff --git a/internal/netprint/tray.go b/internal/netprint/tray.go index 889f9aa..321e951 100644 --- a/internal/netprint/tray.go +++ b/internal/netprint/tray.go @@ -5,7 +5,10 @@ import "context" // RunTray blocks in the platform menu (Windows notification area, macOS status // item, or a freedesktop StatusNotifierItem). It talks to the print service // 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. func trayLoopExit(code int32) string { diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index d8fae34..3f3b7f3 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -19,6 +19,7 @@ const ( idAirDrop = 13 idAdvertise = 14 idAirDropAgain = 15 + idShare = 16 idQuit = 100 idRecent = 20 ) @@ -85,6 +86,14 @@ func BuildMenu(st Status) []MenuEntry { if st.SupportsAirDrop { 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 != "" { out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) } else if st.EmailEnabled { @@ -231,6 +240,8 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetAdvertise(!st.Advertise) case "airdrop": return c.SetAirDropEnabled(!st.AirDropEnabled) + case "share": + return c.SetShareReceiptsSMB(!st.ShareReceiptsSMB) case "airdrop-again": return airDropAgain() case "open": diff --git a/platform/macos/menubar/main.swift b/platform/macos/menubar/main.swift index 5efc166..52ab0d9 100644 --- a/platform/macos/menubar/main.swift +++ b/platform/macos/menubar/main.swift @@ -71,6 +71,9 @@ struct PrinterStatus { var control = controlDefault var address = "" var port = 0 + var supportsShare = false + var shareOn = false + var smbURL = "" } private func fetchStatus() -> PrinterStatus { @@ -96,6 +99,9 @@ private func fetchStatus() -> PrinterStatus { if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c } st.port = obj["port"] as? Int ?? 0 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 } @@ -254,6 +260,7 @@ final class MenuTray: NSObject, NSMenuDelegate { item.menu = menu events.tray = self events.start() + _ = postJSON("/api/v1/receipts-share/ensure", [String: Any]()) rebuild() } @@ -300,6 +307,13 @@ final class MenuTray: NSObject, NSMenuDelegate { menu.addItem(.separator()) add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty) 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 { let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)" add(label, action: #selector(airDropAgain), enabled: true) @@ -335,6 +349,12 @@ final class MenuTray: NSObject, NSMenuDelegate { @objc func openReceipts() { open(receiptsURL()) } @objc func openSettings() { open(settingsURL()) } + @objc func toggleShare() { + _ = postJSON("/api/v1/settings", ["shareReceiptsSMB": !status.shareOn]) + status = fetchStatus() + rebuild() + } + @objc func airDropAgain() { guard !lastPDF.isEmpty else { trayLog("no stamped PDF to AirDrop") diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 8edda15..1052a8e 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -157,7 +157,7 @@ cat >"$DIST" < - + netprint-component.pkg diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh index eb8f8ba..8f8ebbb 100644 --- a/platform/macos/pkg/check-pkg.sh +++ b/platform/macos/pkg/check-pkg.sh @@ -63,5 +63,5 @@ if [[ ! -f "$dist" ]]; then echo "expanded package has no Distribution" >&2 exit 1 fi -grep -q 'Share receipts over SMB' "$dist" +grep -q 'Share receipts folder over the network (SMB)' "$dist" echo "package ok: $PKG" diff --git a/platform/macos/pkg/smb-share.sh b/platform/macos/pkg/smb-share.sh old mode 100644 new mode 100755 index 494343e..0fa7a5c --- a/platform/macos/pkg/smb-share.sh +++ b/platform/macos/pkg/smb-share.sh @@ -1,13 +1,19 @@ #!/bin/bash -# Optional read-only SMB share of the macOS receipts folder. +# Optional SMB share of the macOS receipts folder. # The installer runs this only when that choice is checked. # The program stays in Application Support and is not in this folder. +# The first connection from iPhone Files needs a username and password +# from this Mac. Guest access stays off. set -euo pipefail DIR="/Users/Shared/Verae Receipts" NAME="Verae Receipts" +STAMP="/Library/Application Support/Verae/PrintStamp/share-receipts-smb.on" +BIN="/Library/Application Support/Verae/NetPrint/bin/verae-print" mkdir -p "$DIR" +# Root-owned, console-user-readable, not world-writable. No binaries live here. +chown root:wheel "$DIR" 2>/dev/null || chown root:admin "$DIR" 2>/dev/null || true chmod 755 "$DIR" if ! command -v sharing >/dev/null 2>&1; then @@ -15,15 +21,13 @@ if ! command -v sharing >/dev/null 2>&1; then exit 1 fi -# Replace a previous point so a rerun stays read-only and does not turn on write. +# Replace a previous point so a rerun stays SMB-only, guest-off, and read-only. if sharing -l 2>/dev/null | grep -F "$NAME" >/dev/null 2>&1; then sharing -r "$NAME" || true fi -# -S publishes SMB. -g 000 keeps guest access off. Authenticated users of -# this Mac can mount the share. smb_read_only is set explicitly because the -# sharing flags differ across macOS 14 and macOS 26. -sharing -a "$DIR" -S "$NAME" -n "$NAME" -g 000 +# -s 001 is SMB only. -g 000 keeps guest access off. +sharing -a "$DIR" -n "$NAME" -S "$NAME" -s 001 -g 000 if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then dscl . -create "/SharePoints/${NAME}" smb_shared 1 @@ -32,8 +36,20 @@ if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then dscl . -create "/SharePoints/${NAME}" directory_path "$DIR" fi +# File Sharing stays enabled across reboots. Do not bounce smbd if it is already up. launchctl enable system/com.apple.smbd >/dev/null 2>&1 || true -launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ - launchctl kickstart -k system/com.apple.smbd >/dev/null 2>&1 || true +if ! launchctl print system/com.apple.smbd 2>/dev/null | grep -q "state = running"; then + launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ + launchctl kickstart system/com.apple.smbd >/dev/null 2>&1 || true +fi -echo "SMB share \"${NAME}\" -> ${DIR} (read-only)" +mkdir -p "$(dirname "$STAMP")" +echo enabled >"$STAMP" + +# Persist shareReceiptsSMB in netprint.json and reload the daemon when it is already installed. +# If this package runs before the main package, the stamp above is enough. +if [[ -x "$BIN" ]]; then + "$BIN" netprint enable-receipts-share +fi + +echo "SMB share \"${NAME}\" -> ${DIR} (SMB only, guest off, read-only)"