Previously the advertised device name defaulted to the machine hostname. Since the alias is broadcast in plaintext over multicast to the whole subnet every few seconds while the TUI is open, that leaks the hostname to anyone on the network — a privacy problem on a laptop joining untrusted Wi-Fi (the reason LocalSend randomises its aliases). Now first run generates a random "Colour Object" name from public-domain word lists (e.g. "Crimson Quasar"): 56 colours x 44 celestial objects = 2464 combinations. It is generated once and persisted, so a device keeps the same name across restarts. The hostname is still carried in DeviceModel, and the alias remains overridable via --alias or Settings. Word lists are original and use only generic colours and public-domain astronomy terms, so there is no copyright or trademark exposure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
4.5 KiB
Go
169 lines
4.5 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)
|
|
|
|
// 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))
|
|
}
|