PrintToVeraeTimeStamp/internal/settings/settings.go
Grok Bot (for George) 13ebc351f5 0.3.2: Save As filename prompt per print (Windows + macOS)
- promptForFilename setting (default true; env VERAE_PROMPT_FOR_FILENAME),
  'verae-print config --prompt-for-filename', last Save As folder in
  ~/.verae/state.json
- Windows helper: common file dialog (PDF filter, overwrite prompt, topmost
  owner) before stamping; macOS printer path: choose file name (NSSavePanel)
  via 'stamp -prompt-name -title'; cancel discards the job (no stamp/credit)
- job.Options.OutPath: original saved at the chosen path, certificate and
  .verae.json next to it with the chosen base name (never overwritten)
- held jobs keep the chosen path (pendingMeta.OutPath)
- installer: 'Ask for a file name for each print' checkbox; removed the
  'Open the user guide' finish-page checkbox; mac-key-setup asks too
- auth test no longer touches the real macOS keychain (VERAE_SKIP_KEYCHAIN)
- version 0.3.2
2026-10-03 20:00:56 -04:00

190 lines
5.5 KiB
Go

// Package settings holds user preferences for the print helper.
//
// Lookup order (first hit wins per field):
// 1. env VERAE_OPEN_ON_COMPLETE
// 2. user file ~/.verae/config.json (written by the installers for the console/logged-in user)
// 3. machine file %ProgramData%\Verae\PrintStamp\config.json (Windows)
// or /Library/Application Support/Verae/PrintStamp/config.json (macOS)
// 4. defaults (OpenOnComplete = "folder", LogLevel = info)
package settings
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// OpenOnComplete values.
const (
OpenFolder = "folder" // reveal the certificate PDF in Explorer/Finder
OpenPDF = "pdf" // open the certificate PDF in the default viewer
OpenNone = "none"
)
// Settings is the on-disk JSON shape.
type Settings struct {
OpenOnComplete string `json:"openOnComplete,omitempty"`
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
LogLevel string `json:"logLevel,omitempty"`
// PromptForFilename shows a Save As dialog for every print (default true).
PromptForFilename *bool `json:"promptForFilename,omitempty"`
}
// Prompt reports the effective promptForFilename value.
func (s Settings) Prompt() bool { return s.PromptForFilename == nil || *s.PromptForFilename }
func parseBool(v string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
return false, false
}
// Default settings.
var Default = Settings{OpenOnComplete: OpenFolder}
// NormalizeOpen maps user input to a valid value ("" if invalid).
func NormalizeOpen(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "folder", "reveal", "finder", "explorer", "show":
return OpenFolder
case "pdf", "open", "file":
return OpenPDF
case "none", "off", "no", "false", "0":
return OpenNone
}
return ""
}
// UserPath is ~/.verae/config.json for the given home ("" = current user).
func UserPath(home string) string {
if home == "" {
home, _ = os.UserHomeDir()
}
return filepath.Join(home, ".verae", "config.json")
}
// MachinePath is the all-users default file.
func MachinePath() string {
if runtime.GOOS == "windows" {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
return filepath.Join(pd, "Verae", "PrintStamp", "config.json")
}
return "/Library/Application Support/Verae/PrintStamp/config.json"
}
func readFile(p string) (Settings, bool) {
var s Settings
b, err := os.ReadFile(p)
if err != nil {
return s, false
}
b = []byte(strings.TrimPrefix(string(b), "\uFEFF"))
if json.Unmarshal(b, &s) != nil {
return Settings{}, false
}
return s, true
}
// Load resolves the effective settings and a short description of where each came from.
func Load() (Settings, string) {
eff := Default
src := "default"
if s, ok := readFile(MachinePath()); ok {
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
eff.OpenOnComplete, src = v, MachinePath()
}
}
if s, ok := readFile(UserPath("")); ok {
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
eff.OpenOnComplete, src = v, UserPath("")
}
}
for _, p := range []string{MachinePath(), UserPath("")} {
if s, ok := readFile(p); ok {
if s.LogLevel != "" {
eff.LogLevel = strings.ToLower(strings.TrimSpace(s.LogLevel))
}
if s.PromptForFilename != nil {
v := *s.PromptForFilename
eff.PromptForFilename = &v
}
}
}
if b, ok := parseBool(os.Getenv("VERAE_PROMPT_FOR_FILENAME")); ok {
eff.PromptForFilename = &b
}
if v := NormalizeOpen(os.Getenv("VERAE_OPEN_ON_COMPLETE")); v != "" {
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
}
return eff, src
}
// SetKey merges one key into the JSON file at path (unknown keys preserved).
func SetKey(path, key string, value any) error {
m := map[string]any{}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &m)
}
m[key] = value
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(m, "", " ")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// SetPromptForFilename writes promptForFilename (true/false, yes/no, 1/0).
func SetPromptForFilename(path, value string) error {
b, ok := parseBool(value)
if !ok {
return fmt.Errorf("promptForFilename must be true or false (got %q)", value)
}
return SetKey(path, "promptForFilename", b)
}
// statePath holds non-preference state (last Save As folder).
func statePath() string { return filepath.Join(filepath.Dir(UserPath("")), "state.json") }
// LastSaveDir returns the folder used last in the Save As dialog ("" if none/gone).
func LastSaveDir() string {
b, err := os.ReadFile(statePath())
if err != nil {
return ""
}
var st struct {
LastSaveDir string `json:"lastSaveDir"`
}
if json.Unmarshal(b, &st) != nil || st.LastSaveDir == "" {
return ""
}
if fi, err := os.Stat(st.LastSaveDir); err != nil || !fi.IsDir() {
return ""
}
return st.LastSaveDir
}
// SetLastSaveDir remembers the folder chosen in the Save As dialog.
func SetLastSaveDir(dir string) error { return SetKey(statePath(), "lastSaveDir", dir) }
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
func SetOpenOnComplete(path, value string) error {
v := NormalizeOpen(value)
if v == "" {
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
}
return SetKey(path, "openOnComplete", v)
}