omarchy-send/internal/config/config.go
28allday e66662feb9 Headless one-shot message send + desktop notifications on incoming
Two related additions for using omarchy-send beyond the focused TUI:

Headless send (no TUI / no TTY) — for scripts, cron, SSH sessions:
  omarchy-send --to "<alias>" --message "<text>" [--send-pin N] [--wait 15s]
Resolves the peer by alias over multicast (discovery only, not the HTTP
receiver, so it co-exists with a running instance), sends, prints a
one-line result, and exits non-zero on not-found / failure. New
discovery.FindPeer/Snapshot and client.SendMessageSync (returns the error
directly, incl. ErrPinRequired).

Bugfix surfaced by the above: the official LocalSend client answers a
message prepare-upload with HTTP 204 No Content (the text rides in the
preview field, nothing to upload). prepareUpload only accepted 200, so
message sends to official peers failed with "prepare-upload status 204"
in BOTH the new headless path and the existing TUI. Now treats 204 as
success (empty response).

Desktop notifications: a running receiver raises a notify-send
notification on an incoming message or file offer, so mako shows it on
Omarchy/Hyprland even when the TUI isn't focused. New internal/notify
(best-effort; self-disabling on headless boxes with no notify-send /
session bus). Off-switch: --no-notify flag, cfg.NoNotify, and a Settings
'n' toggle wired to a live atomic gate via Controller.SetNotify so it
takes effect without a restart.

Tests: discovery/find_test, client/message_sync_test,
client/prepare_204_test, app/events_test. Race-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:43 +01:00

170 lines
4.6 KiB
Go

// Package config loads and persists user settings under the XDG config dir.
package config
import (
"crypto/tls"
"encoding/json"
"os"
"path/filepath"
"omarchy-send/internal/protocol"
"omarchy-send/internal/security"
)
// Config is the persisted user configuration.
type Config struct {
Alias string `json:"alias"`
Fingerprint string `json:"fingerprint"`
Port int `json:"port"`
ReceiveDir string `json:"receiveDir"`
DeviceModel string `json:"deviceModel"`
DeviceType string `json:"deviceType"`
Protocol string `json:"protocol"`
AutoAccept bool `json:"autoAccept"`
PIN string `json:"pin"` // if set, senders must supply it
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals)
NoNotify bool `json:"noNotify"` // don't raise desktop notifications on incoming messages/files
// TLS identity for encrypted (HTTPS) mode, generated once and persisted.
CertPEM string `json:"certPem"`
KeyPEM string `json:"keyPem"`
// path is where this config was loaded from / will be saved to.
path string `json:"-"`
}
// Dir returns the config directory, e.g. ~/.config/omarchy-send.
func Dir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(base, "omarchy-send"), nil
}
// defaults returns a Config populated with sensible defaults for this host.
func defaults() Config {
host, err := os.Hostname()
if err != nil || host == "" {
host = "arch"
}
home, _ := os.UserHomeDir()
return Config{
// Alias is intentionally empty here; Load generates a random sci-fi
// alias once on first run (see randomAlias) and persists it. The
// hostname is still carried in DeviceModel so the machine stays
// identifiable to peers that look past the display name.
Alias: "",
Port: protocol.DefaultPort,
ReceiveDir: filepath.Join(home, "Omarchy-Send"),
DeviceModel: host,
DeviceType: string(protocol.DeviceServer),
Protocol: "https",
AutoAccept: false,
}
}
// Load reads the config from dir/config.json, filling defaults for missing
// fields. If the file does not exist it is created. A fingerprint is generated
// and persisted on first run.
func Load() (Config, error) {
dir, err := Dir()
if err != nil {
return Config{}, err
}
path := filepath.Join(dir, "config.json")
cfg := defaults()
cfg.path = path
data, err := os.ReadFile(path)
switch {
case err == nil:
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.path = path
case !os.IsNotExist(err):
return Config{}, err
}
// Backfill anything still empty after unmarshalling an older/partial file.
d := defaults()
// Generate a sci-fi alias once on first run (no file, or a file with no
// alias). It is persisted below, so the name stays stable across restarts.
if cfg.Alias == "" {
cfg.Alias = randomAlias()
}
if cfg.Port == 0 {
cfg.Port = d.Port
}
if cfg.ReceiveDir == "" {
cfg.ReceiveDir = d.ReceiveDir
}
if cfg.DeviceType == "" {
cfg.DeviceType = d.DeviceType
}
if cfg.Protocol == "" {
cfg.Protocol = d.Protocol
}
// Generate the TLS identity once. The fingerprint is derived from the
// certificate (uppercase-hex SHA-256 of its DER), so it is regenerated
// alongside the cert and persisted.
if cfg.CertPEM == "" || cfg.KeyPEM == "" {
id, err := security.Generate()
if err != nil {
return Config{}, err
}
cfg.CertPEM = id.CertPEM
cfg.KeyPEM = id.KeyPEM
cfg.Fingerprint = id.Fingerprint
}
if err := cfg.Save(); err != nil {
return Config{}, err
}
return cfg, nil
}
// Save atomically writes the config (temp file + rename).
func (c Config) Save() error {
if c.path == "" {
dir, err := Dir()
if err != nil {
return err
}
c.path = filepath.Join(dir, "config.json")
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
}
// DeviceInfo builds the protocol announcement payload from this config. The
// caller sets Announce as appropriate.
func (c Config) DeviceInfo() protocol.DeviceInfo {
return protocol.DeviceInfo{
Alias: c.Alias,
Version: protocol.ProtocolVersion,
DeviceModel: c.DeviceModel,
DeviceType: protocol.DeviceType(c.DeviceType),
Fingerprint: c.Fingerprint,
Port: c.Port,
Protocol: c.Protocol,
}
}
// TLSCertificate returns the parsed TLS keypair for serving HTTPS.
func (c Config) TLSCertificate() (tls.Certificate, error) {
return tls.X509KeyPair([]byte(c.CertPEM), []byte(c.KeyPEM))
}