Keep the macOS receipts SMB share across reboots.
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:
parent
970061d689
commit
5ed87da68f
21 changed files with 648 additions and 121 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"`}},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(`<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>
|
||||
<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>
|
||||
<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>`,
|
||||
checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()))
|
||||
<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>
|
||||
<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
|
||||
b.WriteString("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width, initial-scale=1\"><title>Verae network printer</title>")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</style></head><body>`)
|
||||
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 != "" {
|
||||
shortcut := "http://" + r.Host + "/ios-shortcut"
|
||||
fmt.Fprintf(&b, `<p><a class="btn" href="/ios-shortcut">iPhone Shortcut</a></p>`)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
118
internal/netprint/share.go
Normal file
118
internal/netprint/share.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
101
internal/netprint/share_darwin.go
Normal file
101
internal/netprint/share_darwin.go
Normal 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()
|
||||
}
|
||||
11
internal/netprint/share_other.go
Normal file
11
internal/netprint/share_other.go
Normal 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() {}
|
||||
111
internal/netprint/share_test.go
Normal file
111
internal/netprint/share_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<pkg-ref id="ai.verae.netprint.standalone"/>
|
||||
</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"/>
|
||||
</choice>
|
||||
<pkg-ref id="ai.verae.netprint.standalone" version="${VERSION}" auth="root">netprint-component.pkg</pkg-ref>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue