From 10778efbeedd259ede1e4c21f8e99150f805a016 Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:13:24 +0300 Subject: [PATCH 01/22] Tiered discovery: cache probe, multicast bursts, TCP pre-filter, --quick flag - PeerCache: LRU 50-entry cap, 14-day stale eviction, auto-cache on --ip - SendDiscoveryAnnouncement: 3 UDP bursts 30ms apart to reduce Wi-Fi loss - ScanNetwork: 150ms TCP pre-probe before HTTP register, semaphore 256 - SendFiles: cache probe (400ms) -> multicast (1.2s) -> subnet scan cascade with fast mode skipping the scan tier - DiscoveryStrategy config field, --quick/-q flag on send command --- cmd/localgo/cmd/send.go | 23 ++++---- pkg/config/config.go | 63 +++++++++++++--------- pkg/discovery/announce.go | 19 +++++-- pkg/discovery/http_discovery.go | 22 +++++++- pkg/discovery/peercache.go | 93 ++++++++++++++++++++++++++++----- pkg/discovery/quick.go | 5 +- pkg/send/send.go | 49 ++++++++++++----- 7 files changed, 207 insertions(+), 67 deletions(-) diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go index 4d84ed2..259e342 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -24,16 +24,17 @@ import ( ) var ( - sendfiles []string - sendip string - sendto string - sendport int - sendtimeout int - sendalias string - sendconcurrency int + sendfiles []string + sendip string + sendto string + sendport int + sendtimeout int + sendalias string + sendconcurrency int sendmulticastiface string - sendclipboard bool - sendstdin bool + sendclipboard bool + sendstdin bool + sendquick bool ) var sendCmd = &cobra.Command{ @@ -252,6 +253,9 @@ var sendCmd = &cobra.Command{ if sendmulticastiface != "" { Cfg.MulticastInterface = sendmulticastiface } + if sendquick { + Cfg.DiscoveryStrategy = "fast" + } cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files))) for _, file := range files { @@ -299,6 +303,7 @@ func init() { sendCmd.Flags().StringVar(&sendmulticastiface, "iface", "", "Multicast network interface name") sendCmd.Flags().BoolVarP(&sendclipboard, "clipboard", "c", false, "Send current system clipboard text directly") sendCmd.Flags().BoolVar(&sendstdin, "stdin", false, "Send text read from standard input (stdin)") + sendCmd.Flags().BoolVarP(&sendquick, "quick", "q", false, "Skip subnet scan; use cache + multicast only") sendCmd.RegisterFlagCompletionFunc("to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cache := discovery.NewPeerCache(nil) diff --git a/pkg/config/config.go b/pkg/config/config.go index a7bfaa9..2a13ce9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -44,7 +44,10 @@ type Config struct { OpenDir bool `json:"-"` // open download directory after transfer Concurrency int `json:"-"` // max parallel uploads (0 = use default) MulticastInterface string `json:"-"` // multicast network interface name - Private bool `json:"-"` // anonymize device identities + Private bool `json:"-"` // anonymize device identities + DiscoveryStrategy string `json:"-"` // discovery strategy: "full" (default) or "fast" (skip subnet scan) + FileConflictResolve string `json:"-"` // conflict resolution: "rename" (default), "overwrite", "skip" + BindAddress string `json:"-"` // bind to specific interface/IP for listening Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") ClipboardWriteCmd string `json:"-"` // custom clipboard write command @@ -201,32 +204,44 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) { customTLSCertPath := v.GetString("tls_cert") customTLSKeyPath := v.GetString("tls_key") notificationCmd := v.GetString("notification_cmd") + discoveryStrategy := v.GetString("discovery_strategy") + if discoveryStrategy == "" { + discoveryStrategy = "full" + } + fileConflictResolve := v.GetString("file_conflict_resolution") + if fileConflictResolve == "" { + fileConflictResolve = "rename" + } + bindAddress := v.GetString("bind_address") cfg := &Config{ - Alias: alias, - Port: port, - MulticastGroup: multicastGroup, - HttpsEnabled: HttpsEnabled, - SecurityContext: securityContext, - SecurityPath: securityFilePath, - DeviceModel: &deviceModel, - DeviceType: deviceType, - DownloadDir: downloadDir, - AutoAccept: autoAccept, - RandomFingerprint: generateRandomID(64), - MaxBodySize: maxBodySize, - NoClipboard: noClipboard, - HistoryFile: historyFile, - Quiet: quiet, - ExecHook: execHook, - Concurrency: concurrency, + Alias: alias, + Port: port, + MulticastGroup: multicastGroup, + HttpsEnabled: HttpsEnabled, + SecurityContext: securityContext, + SecurityPath: securityFilePath, + DeviceModel: &deviceModel, + DeviceType: deviceType, + DownloadDir: downloadDir, + AutoAccept: autoAccept, + RandomFingerprint: generateRandomID(64), + MaxBodySize: maxBodySize, + NoClipboard: noClipboard, + HistoryFile: historyFile, + Quiet: quiet, + ExecHook: execHook, + Concurrency: concurrency, MulticastInterface: multicastInterface, - Shell: shell, - ClipboardWriteCmd: clipboardWriteCmd, - ClipboardReadCmd: clipboardReadCmd, - CustomTLSCertPath: customTLSCertPath, - CustomTLSKeyPath: customTLSKeyPath, - NotificationCmd: notificationCmd, + DiscoveryStrategy: discoveryStrategy, + FileConflictResolve: fileConflictResolve, + BindAddress: bindAddress, + Shell: shell, + ClipboardWriteCmd: clipboardWriteCmd, + ClipboardReadCmd: clipboardReadCmd, + CustomTLSCertPath: customTLSCertPath, + CustomTLSKeyPath: customTLSKeyPath, + NotificationCmd: notificationCmd, } return cfg, nil diff --git a/pkg/discovery/announce.go b/pkg/discovery/announce.go index 7621f03..c92ab99 100644 --- a/pkg/discovery/announce.go +++ b/pkg/discovery/announce.go @@ -11,6 +11,12 @@ import ( "github.com/bethropolis/localgo/pkg/model" ) +// multicastBurstCount is the number of rapid UDP multicast bursts sent. +const multicastBurstCount = 3 + +// multicastBurstInterval is the delay between consecutive bursts. +const multicastBurstInterval = 30 * time.Millisecond + // SendDiscoveryAnnouncement sends a multicast announcement func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { announcementDto := md.dto @@ -48,13 +54,16 @@ func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { } defer conn.Close() - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send multicast announcement: %w", err) + for burst := 0; burst < multicastBurstCount; burst++ { + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send multicast announcement (burst %d): %w", burst, err) + } + time.Sleep(multicastBurstInterval) } - md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", - md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) + md.logger.Debugf("Sent %d multicast announcement bursts as %s (fingerprint: %s) to %s", + multicastBurstCount, md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) return nil } diff --git a/pkg/discovery/http_discovery.go b/pkg/discovery/http_discovery.go index b7b68a2..76077db 100644 --- a/pkg/discovery/http_discovery.go +++ b/pkg/discovery/http_discovery.go @@ -132,13 +132,26 @@ func (hd *HTTPDiscovery) RegisterWithDevice(ctx context.Context, ip net.IP, port }, nil } +// tcpPreProbe performs a quick TCP dial to check if a host is reachable. +func tcpPreProbe(ctx context.Context, ip net.IP, port int) bool { + addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) + dialer := net.Dialer{Timeout: 150 * time.Millisecond} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return false + } + conn.Close() + return true +} + func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int) ([]*model.Device, error) { var devices []*model.Device + var mu sync.Mutex var wg sync.WaitGroup deviceChan := make(chan *model.Device, len(ips)) // Semaphore limits parallel pinging to prevent socket exhaustion - sem := make(chan struct{}, 100) + sem := make(chan struct{}, 256) hd.logger.Debugf("Scanning %d IPs on port %d", len(ips), port) @@ -150,6 +163,11 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int sem <- struct{}{} defer func() { <-sem }() + // TCP pre-probe: 150ms dial to quickly eliminate dead IPs + if !tcpPreProbe(ctx, ip, port) { + return + } + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { device, err = hd.RegisterWithDevice(ctx, ip, port, "http") @@ -166,7 +184,9 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int close(deviceChan) for device := range deviceChan { + mu.Lock() devices = append(devices, device) + mu.Unlock() } return devices, nil diff --git a/pkg/discovery/peercache.go b/pkg/discovery/peercache.go index 2afe942..228430b 100644 --- a/pkg/discovery/peercache.go +++ b/pkg/discovery/peercache.go @@ -16,11 +16,18 @@ import ( "go.uber.org/zap" ) +// MaxCachedPeers is the maximum number of peers to keep in cache. +const MaxCachedPeers = 50 + +// StaleThreshold is how long without contact before a peer is evicted. +const StaleThreshold = 14 * 24 * time.Hour + // PeerCache persists discovered peers to disk and provides thread-safe access. type PeerCache struct { mu sync.RWMutex filePath string peers map[string]*model.Device + order []string // LRU order (most recent at end) logger *zap.SugaredLogger } @@ -39,35 +46,83 @@ func NewPeerCache(logger *zap.SugaredLogger) *PeerCache { pc := &PeerCache{ filePath: path, peers: make(map[string]*model.Device), + order: make([]string, 0, MaxCachedPeers), logger: logger, } pc.load() return pc } -// Save adds or updates a peer and persists atomically. +// Save adds or updates a peer, updates LRU order, evicts stale/over-limit entries, and persists. func (pc *PeerCache) Save(device *model.Device) { pc.mu.Lock() defer pc.mu.Unlock() + now := time.Now() + device.SetLastSeen(now) + + if _, exists := pc.peers[device.Fingerprint]; !exists { + pc.order = append(pc.order, device.Fingerprint) + } pc.peers[device.Fingerprint] = device + + // Evict stale peers first + staleCutoff := now.Add(-StaleThreshold) + var fresh []string + for _, fp := range pc.order { + d, ok := pc.peers[fp] + if !ok || (!d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff)) { + delete(pc.peers, fp) + continue + } + fresh = append(fresh, fp) + } + pc.order = fresh + + // LRU evict oldest entries if over cap + for len(pc.order) > MaxCachedPeers { + fp := pc.order[0] + pc.order = pc.order[1:] + delete(pc.peers, fp) + } + if err := pc.persist(); err != nil { pc.logger.Warnf("Failed to persist peer cache: %v", err) } } -// GetPeers returns a snapshot of all cached peers. +// touchLRU moves the given fingerprint to the end (most recently used). +func (pc *PeerCache) touchLRU(fingerprint string) { + for i, fp := range pc.order { + if fp == fingerprint { + pc.order = append(pc.order[:i], pc.order[i+1:]...) + pc.order = append(pc.order, fp) + break + } + } +} + +// GetPeers returns a snapshot of all cached peers (most recently seen first). func (pc *PeerCache) GetPeers() []*model.Device { pc.mu.RLock() defer pc.mu.RUnlock() - list := make([]*model.Device, 0, len(pc.peers)) - for _, d := range pc.peers { - list = append(list, d) + list := make([]*model.Device, 0, len(pc.order)) + for i := len(pc.order) - 1; i >= 0; i-- { + if d, ok := pc.peers[pc.order[i]]; ok { + list = append(list, d) + } } return list } +// GetByFingerprint returns a cached peer by fingerprint. +func (pc *PeerCache) GetByFingerprint(fp string) *model.Device { + pc.mu.RLock() + defer pc.mu.RUnlock() + return pc.peers[fp] +} + // load reads peers.json into the in-memory map. Must be called with mu held. func (pc *PeerCache) load() { pc.mu.Lock() @@ -87,22 +142,25 @@ func (pc *PeerCache) load() { return } - staleThreshold := 30 * 24 * time.Hour now := time.Now() + staleCutoff := now.Add(-StaleThreshold) evictedCount := 0 for _, d := range list { - // Evict peers not seen in the last 30 days - if !d.GetLastSeen().IsZero() && now.Sub(d.GetLastSeen()) > staleThreshold { + if !d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff) { evictedCount++ continue } + if len(pc.order) >= MaxCachedPeers { + evictedCount++ + continue + } + pc.order = append(pc.order, d.Fingerprint) pc.peers[d.Fingerprint] = d } if evictedCount > 0 { - pc.logger.Debugf("Evicted %d stale peer(s) from the local cache (older than 30 days)", evictedCount) - // Persist the cleaned cache back to disk in the background + pc.logger.Debugf("Evicted %d stale/over-limit peer(s) from the local cache (older than 14 days or >%d entries)", evictedCount, MaxCachedPeers) go func() { pc.mu.Lock() defer pc.mu.Unlock() @@ -111,12 +169,19 @@ func (pc *PeerCache) load() { } } -// persist writes the in-memory map to disk atomically via a temp file + rename. +// cachedPeer represents a peer with its LRU ordering for serialization. +type cachedPeer struct { + Device *model.Device `json:"device"` +} + +// persist writes the in-memory cache to disk atomically via a temp file + rename. // Must be called with mu held. func (pc *PeerCache) persist() error { - list := make([]*model.Device, 0, len(pc.peers)) - for _, d := range pc.peers { - list = append(list, d) + list := make([]*model.Device, 0, len(pc.order)) + for _, fp := range pc.order { + if d, ok := pc.peers[fp]; ok { + list = append(list, d) + } } data, err := json.MarshalIndent(list, "", " ") diff --git a/pkg/discovery/quick.go b/pkg/discovery/quick.go index cbca9a0..e527fa1 100644 --- a/pkg/discovery/quick.go +++ b/pkg/discovery/quick.go @@ -8,6 +8,9 @@ import ( "github.com/bethropolis/localgo/pkg/model" ) +// DiscoverDevices performs tiered discovery: cache probe, then multicast. +// The subnet scan fallback is omitted here — the caller (interactive send) +// already falls back to a subnet scan if no devices are found. func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *config.Config, httpsEnabled bool) ([]*model.Device, error) { if serviceCfg == nil { serviceCfg = DefaultServiceConfig() @@ -23,8 +26,6 @@ func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *con svc := NewService(serviceCfg, multicast, nil) svc.SetPeerCache(peerCache) - - if err := svc.Start(ctx, multicastDto); err != nil { return nil, err } diff --git a/pkg/send/send.go b/pkg/send/send.go index 770d002..de34fbb 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -60,20 +60,38 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci var targetDevice *model.Device - // --- Multicast Discovery (Fast) --- - logger.Info("Sending multicast announcement...") - discoverySvcConfig := discovery.DefaultServiceConfig() discoverySvcConfig.MulticastConfig.Port = cfg.Port discoverySvcConfig.MulticastConfig.MulticastAddr = fmt.Sprintf("%s:%d", cfg.MulticastGroup, cfg.Port) discoverySvcConfig.MulticastConfig.InterfaceName = cfg.MulticastInterface multicastDto := cfg.ToMulticastDto(false) + peerCache := discovery.NewPeerCache(logger) + + // --- Tier 1: Cache Probe (400ms) --- + logger.Info("Probing cached peers...") + cacheCtx, cancelCache := context.WithTimeout(ctx, 400*time.Millisecond) + discovery.ProbeCached(cacheCtx, peerCache, func(device *model.Device) { + if device.Alias == recipientAlias && targetDevice == nil { + targetDevice = device + } + }, logger) + cancelCache() + + if targetDevice != nil { + logger.Infof("Discovered recipient via cache: %s (%s)", targetDevice.Alias, targetDevice.IP) + if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { + return err + } + return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) + } + + // --- Tier 2: Multicast Discovery --- + logger.Info("Sending multicast announcement...") + multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, logger) httpDiscoverer := discovery.NewHTTPDiscovery(nil, cfg.ToRegisterDto(), nil, logger) multicast.SetHTTPDiscoverer(httpDiscoverer) - - peerCache := discovery.NewPeerCache(logger) multicast.SetPeerCache(peerCache) discoverySvc := discovery.NewService(discoverySvcConfig, multicast, logger) @@ -89,8 +107,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } }) - multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1500*time.Millisecond) - defer cancelMulticast() + multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1200*time.Millisecond) err := discoverySvc.Start(multicastCtx, cfg.ToMulticastDto(false)) if err != nil { @@ -103,9 +120,10 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci targetDevice = device discoverySvc.Stop() case <-multicastCtx.Done(): - logger.Info("Multicast discovery timed out, falling back to HTTP scan...") - discoverySvc.Stop() + logger.Info("Multicast discovery timed out") } + cancelMulticast() + discoverySvc.Stop() if targetDevice != nil { if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { @@ -114,6 +132,14 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...) } + // Skip Tier 3 (subnet scan) in fast mode + if cfg.DiscoveryStrategy == "fast" { + return fmt.Errorf("recipient '%s' not found on network (cache + multicast missed, scan skipped via fast strategy)", recipientAlias) + } + + // --- Tier 3: Subnet Scan --- + logger.Info("Scanning local subnets...") + registerDto := cfg.ToRegisterDto() httpFallback := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger) @@ -131,7 +157,6 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } ips = append(ips, net.ParseIP("127.0.0.1")) - // Give the scan a proper chunk of time to test all IPs safely scanCtx, cancelScan := context.WithTimeout(ctx, 15*time.Second) defer cancelScan() @@ -148,10 +173,10 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci } if targetDevice == nil { - return fmt.Errorf("recipient '%s' not found on network after scan", recipientAlias) + return fmt.Errorf("recipient '%s' not found on network after all discovery tiers", recipientAlias) } - logger.Infof("Discovered recipient via HTTP Scan: %s (%s)", targetDevice.Alias, targetDevice.IP) + logger.Infof("Discovered recipient via subnet scan: %s (%s)", targetDevice.Alias, targetDevice.IP) if err := verifyDeviceFingerprint(peerCache, targetDevice); err != nil { return err From d05ddb46b02b75abf5babfa6342cf5705af27abf Mon Sep 17 00:00:00 2001 From: bethropolis <66518866+bethropolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:16:29 +0300 Subject: [PATCH 02/22] Web share: landing page, QR code, download notifications, --once flag - Root GET / handler with embedded HTML template fixes browser 404 - Terminal QR code generation via qrterminal for mobile scanning - Download success notification printed to terminal - --once flag stops server automatically after first download - HTTPS default guidance notice for self-signed certs --- cmd/localgo/cmd/share.go | 24 ++++- go.mod | 2 + go.sum | 4 + pkg/config/config.go | 1 + pkg/server/handlers/download_handlers.go | 121 +++++++++++++++++++++++ pkg/server/server.go | 11 +++ 6 files changed, 162 insertions(+), 1 deletion(-) diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 37f361f..08b2596 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -18,6 +18,7 @@ import ( "github.com/bethropolis/localgo/pkg/network" "github.com/bethropolis/localgo/pkg/server" "github.com/google/uuid" + "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" "go.uber.org/zap" ) @@ -35,8 +36,9 @@ var ( shareexecHook string sharequiet bool sharezip bool - shareconcurrency int + shareconcurrency int sharemulticastiface string + shareOnce bool ) var shareCmd = &cobra.Command{ @@ -96,6 +98,9 @@ var shareCmd = &cobra.Command{ if sharemulticastiface != "" { Cfg.MulticastInterface = sharemulticastiface } + if shareOnce { + Cfg.ShareOnce = true + } protocol := "HTTPS" if !Cfg.HttpsEnabled { @@ -247,6 +252,22 @@ var shareCmd = &cobra.Command{ cli.PrintInfo(" %s://%s:%d", scheme, ip.String(), Cfg.Port) } fmt.Println() + + // Terminal QR code for mobile scanning + primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port) + cli.PrintHeader("Scan QR Code on Mobile:") + cfg := qrterminal.Config{ + Level: qrterminal.M, + Writer: os.Stdout, + HalfBlocks: true, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + } + qrterminal.GenerateWithConfig(primaryURL, cfg) + } + + if Cfg.HttpsEnabled { + cli.PrintWarning("HTTPS notice: browsers will show a security warning for self-signed certificates") } cli.PrintWarning("Press Ctrl+C to stop sharing") @@ -281,6 +302,7 @@ func init() { shareCmd.Flags().BoolVar(&sharezip, "zip", false, "Zip directories before sharing") shareCmd.Flags().IntVar(&shareconcurrency, "concurrency", 0, "Max parallel uploads (0 = use default)") shareCmd.Flags().StringVar(&sharemulticastiface, "iface", "", "Multicast network interface name") + shareCmd.Flags().BoolVar(&shareOnce, "once", false, "Stop sharing automatically after the first download completes") shareCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if h := help.GetCommandHelp("share"); h != nil { diff --git a/go.mod b/go.mod index dcc4029..e64cac9 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -77,4 +78,5 @@ require ( golang.org/x/text v0.37.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 57b7755..89a9f3d 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -193,3 +195,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/config/config.go b/pkg/config/config.go index 2a13ce9..51d661c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,7 @@ type Config struct { DiscoveryStrategy string `json:"-"` // discovery strategy: "full" (default) or "fast" (skip subnet scan) FileConflictResolve string `json:"-"` // conflict resolution: "rename" (default), "overwrite", "skip" BindAddress string `json:"-"` // bind to specific interface/IP for listening + ShareOnce bool `json:"-"` // stop after first download (--once) Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c") ClipboardWriteCmd string `json:"-"` // custom clipboard write command diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 0b007aa..79c7b88 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -3,10 +3,13 @@ package handlers import ( "crypto/subtle" "fmt" + "html/template" "io" "net/http" "os" + "time" + "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/httputil" "github.com/bethropolis/localgo/pkg/model" @@ -19,6 +22,113 @@ type DownloadHandler struct { config *config.Config sendService *services.SendService logger *zap.SugaredLogger + shutdownFn func() // optional; set by Server for --once support +} + +// SetShutdownFn registers a shutdown callback (used for --once mode). +func (h *DownloadHandler) SetShutdownFn(fn func()) { + h.shutdownFn = fn +} + +const webShareHTML = ` + +
+ + +This share is PIN protected.
+ + {{else}} + adds PIN to prepare-upload and upload URLs
- Receiver already validates PIN on prepare-upload
- Works for both --ip and discovery-based send
---
cmd/localgo/cmd/send.go | 8 ++++++++
pkg/send/upload.go | 9 ++++++---
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go
index 76f65c8..f8d23f7 100644
--- a/cmd/localgo/cmd/send.go
+++ b/cmd/localgo/cmd/send.go
@@ -35,6 +35,7 @@ var (
sendclipboard bool
sendstdin bool
sendquick bool
+ sendpin string
)
var sendCmd = &cobra.Command{
@@ -270,6 +271,12 @@ var sendCmd = &cobra.Command{
if sendquick {
Cfg.DiscoveryStrategy = "fast"
}
+ if sendpin != "" {
+ Cfg.PIN = sendpin
+ }
+ if sendpin != "" {
+ Cfg.PIN = sendpin
+ }
cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files)))
for _, file := range files {
@@ -318,6 +325,7 @@ func init() {
sendCmd.Flags().BoolVarP(&sendclipboard, "clipboard", "c", false, "Send current system clipboard text directly")
sendCmd.Flags().BoolVar(&sendstdin, "stdin", false, "Send text read from standard input (stdin)")
sendCmd.Flags().BoolVarP(&sendquick, "quick", "q", false, "Skip subnet scan; use cache + multicast only")
+ sendCmd.Flags().StringVar(&sendpin, "pin", "", "PIN for receiver authentication")
sendCmd.RegisterFlagCompletionFunc("to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cache := discovery.NewPeerCache(nil)
diff --git a/pkg/send/upload.go b/pkg/send/upload.go
index 83ad4c4..f9465cc 100644
--- a/pkg/send/upload.go
+++ b/pkg/send/upload.go
@@ -29,7 +29,7 @@ type fileReader interface {
io.Closer
}
-func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error {
+func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *zap.SugaredLogger) error {
if logger == nil {
logger = zap.NewNop().Sugar()
}
@@ -45,15 +45,18 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device,
return fmt.Errorf("failed to get file stats: %w", err)
}
- return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, trackProgress, logger)
+ return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, pin, trackProgress, logger)
}
-func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error {
+func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *zap.SugaredLogger) error {
if logger == nil {
logger = zap.NewNop().Sugar()
}
url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token)
+ if pin != "" {
+ url += "&pin=" + pin
+ }
var body io.ReadCloser = io.NopCloser(r)
if trackProgress != nil {
From 598eabc1c7fe97c6e2313eb4f5498a0697d8642b Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 24 Jul 2026 13:23:32 +0300
Subject: [PATCH 06/22] Fix .gitignore config entry, README clipboard env vars,
send.go PIN URL
- .gitignore: narrow 'config' to '/config' so pkg/config/ is not ignored
- README: add LOCALSEND_CLIPBOARD_READ/WRITE_CMD env vars
- send.go: add PIN query param to prepare-upload URL
---
.gitignore | 2 +-
README.md | 2 ++
pkg/send/send.go | 10 +++++++---
3 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/.gitignore b/.gitignore
index 4a20521..26338a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,7 +39,7 @@ coverage.*
/mise.toml
-config
+/config
test
dist/
.coverage/
diff --git a/README.md b/README.md
index b9191e5..f0142df 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,8 @@ For full details on deployment, macvlan networking, read-only root filesystem, w
| `LOCALSEND_CONCURRENCY` | 4 | Max parallel upload workers |
| `LOCALSEND_MULTICAST_INTERFACE` | (all) | Network interface for multicast |
| `LOCALSEND_SHELL` | (auto) | Shell prefix for exec hooks |
+| `LOCALSEND_CLIPBOARD_WRITE_CMD` | (auto) | Custom clipboard write command |
+| `LOCALSEND_CLIPBOARD_READ_CMD` | (auto) | Custom clipboard read command |
| `LOCALSEND_TLS_CERT` | — | Custom TLS certificate path |
| `LOCALSEND_TLS_KEY` | — | Custom TLS private key path |
| `LOCALSEND_NOTIFICATION_CMD` | (auto) | Custom notification command |
diff --git a/pkg/send/send.go b/pkg/send/send.go
index d093dcf..616fede 100644
--- a/pkg/send/send.go
+++ b/pkg/send/send.go
@@ -394,7 +394,11 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device,
return fmt.Errorf("failed to marshal prepare dto: %w", err)
}
- url := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)))
+ baseURL := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)))
+ if cfg.PIN != "" {
+ baseURL += "?pin=" + cfg.PIN
+ }
+ url := baseURL
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create prepare request: %w", err)
@@ -448,7 +452,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device,
defer func() { <-sem }()
logger.Infof("Uploading in-memory file: %s", name)
- err := uploadStream(ctx, client, device, rdr, sz, fID, prepareResponse.SessionID, tkn, scheme, track, logger)
+ err := uploadStream(ctx, client, device, rdr, sz, fID, prepareResponse.SessionID, tkn, scheme, cfg.PIN, track, logger)
if err != nil {
logger.Errorf("Failed to upload %s: %v", name, err)
errCh <- fmt.Errorf("failed to upload %s: %w", name, err)
@@ -469,7 +473,7 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device,
defer func() { <-sem }()
logger.Infof("Uploading file: %s", filepath.Base(fPath))
- err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, track, logger)
+ err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, cfg.PIN, track, logger)
if err != nil {
logger.Errorf("Failed to upload file %s: %v", filepath.Base(fPath), err)
errCh <- fmt.Errorf("failed to upload %s: %w", filepath.Base(fPath), err)
From 9a5ec6d0b4d9ff0be1377517b201c152c22a9148 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 24 Jul 2026 13:47:39 +0300
Subject: [PATCH 07/22] Fix QR code rendering: use GenerateHalfBlock helper
- qrterminal.BLACK/WHITE are ANSI full-block sequences, not compatible
with HalfBlocks mode
- Switched to GenerateHalfBlock() which sets correct unicode characters
---
cmd/localgo/cmd/share.go | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go
index 08b2596..7b24a84 100644
--- a/cmd/localgo/cmd/share.go
+++ b/cmd/localgo/cmd/share.go
@@ -256,14 +256,7 @@ var shareCmd = &cobra.Command{
// Terminal QR code for mobile scanning
primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port)
cli.PrintHeader("Scan QR Code on Mobile:")
- cfg := qrterminal.Config{
- Level: qrterminal.M,
- Writer: os.Stdout,
- HalfBlocks: true,
- BlackChar: qrterminal.BLACK,
- WhiteChar: qrterminal.WHITE,
- }
- qrterminal.GenerateWithConfig(primaryURL, cfg)
+ qrterminal.GenerateHalfBlock(primaryURL, qrterminal.M, os.Stdout)
}
if Cfg.HttpsEnabled {
From f2e6a588218608eac5e597ee2e227442c2328a7d Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 24 Jul 2026 13:55:59 +0300
Subject: [PATCH 08/22] Update help: document new flags, examples, config
subcommands, env vars
- share: --once, --https, --once examples, --iface
- send: --quick/-q, --pin, mDNS --ip example, --iface
- config: subcommand listing (get/set/add/remove/list/unset/open/path)
- Main help: new examples for --quick, --ip, --once, config set/list
- Env vars: DISCOVERY_STRATEGY, FILE_CONFLICT_RESOLUTION, BIND_ADDRESS,
CONCURRENCY, SHELL, CLIPBOARD_READ/WRITE_CMD, TLS_CERT/KEY, NOTIFICATION_CMD
---
pkg/help/commands.go | 31 ++++++++++++++++++++++++++++---
pkg/help/help.go | 15 +++++++++++++++
2 files changed, 43 insertions(+), 3 deletions(-)
diff --git a/pkg/help/commands.go b/pkg/help/commands.go
index ee26392..9735463 100644
--- a/pkg/help/commands.go
+++ b/pkg/help/commands.go
@@ -47,11 +47,17 @@ func GetCommandHelp(commandName string) *CommandHelp {
"localgo share --file data.zip --auto-accept",
"localgo share --file report.pdf --no-clipboard",
"localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'",
+ "localgo share --file large.zip --zip",
+ "localgo share --file urgent.pdf --once",
+ "localgo share --file document.pdf --https",
+ "localgo share --file photo.jpg --iface eth0",
},
Flags: []FlagHelp{
{Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"},
{Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"},
{Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"},
+ {Name: "--https", Type: "bool", Default: "false", Description: "Use HTTPS (browsers will show self-signed cert warning)"},
+ {Name: "--once", Type: "bool", Default: "false", Description: "Stop sharing automatically after the first download completes"},
{Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"},
{Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"},
{Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"},
@@ -59,7 +65,7 @@ func GetCommandHelp(commandName string) *CommandHelp {
{Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"},
{Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"},
{Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"},
- {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"},
+ {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"},
{Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"},
{Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"},
},
@@ -107,18 +113,24 @@ func GetCommandHelp(commandName string) *CommandHelp {
"localgo send --file document.pdf --to MyPhone",
"localgo send --ip 192.168.1.42 --file document.pdf",
"localgo send --ip 192.168.1.42:53317 --file document.pdf",
+ "localgo send --ip myphone.local --file photo.jpg",
"localgo send --clipboard --to MyPhone",
"localgo send -c --to MyPhone",
"localgo send --stdin --to MyPhone < list.txt",
"echo 'message' | localgo send --stdin --to MyPhone",
+ "localgo send --file large.zip --quick",
+ "localgo send -q --file document.pdf --to Phone",
+ "localgo send --file secret.pdf --to MyPhone --pin 1234",
"localgo send (starts interactive clipboard or file picker if empty)",
},
Flags: []FlagHelp{
{Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"},
- {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"},
+ {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (supports hostname/mDNS, with optional :port)"},
{Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"},
{Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"},
{Name: "--stdin", Type: "bool", Default: "false", Description: "Send text read from standard input (stdin)"},
+ {Name: "--quick, -q", Type: "bool", Default: "false", Description: "Skip subnet scan; use cache + multicast only for faster discovery"},
+ {Name: "--pin", Type: "string", Default: "", Description: "PIN for receiver authentication"},
{Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"},
{Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"},
{Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"},
@@ -195,8 +207,21 @@ func GetCommandHelp(commandName string) *CommandHelp {
"localgo config set alias MyDevice",
"localgo config list",
"localgo config path",
+ "localgo config unset port",
+ "localgo config open",
+ "localgo config add static_peers 10.0.0.5:53317",
+ "localgo config remove static_peers 10.0.0.5:53317",
+ },
+ Flags: []FlagHelp{
+ {Name: "get ", Type: "", Default: "", Description: "Get a config value"},
+ {Name: "set ", Type: "", Default: "", Description: "Set a config value (with type/enum validation)"},
+ {Name: "add ", Type: "", Default: "", Description: "Append a value to a list config key"},
+ {Name: "remove ", Type: "", Default: "", Description: "Remove a value from a list config key"},
+ {Name: "list", Type: "", Default: "", Description: "List all config values with origin ([file]/[env]/[default])"},
+ {Name: "unset ", Type: "", Default: "", Description: "Remove a config key (reverts to default)"},
+ {Name: "open", Type: "", Default: "", Description: "Open config file in system editor"},
+ {Name: "path", Type: "", Default: "", Description: "Show config file path"},
},
- Flags: []FlagHelp{},
},
"version": {
Name: "version",
diff --git a/pkg/help/help.go b/pkg/help/help.go
index 183a597..ac75574 100644
--- a/pkg/help/help.go
+++ b/pkg/help/help.go
@@ -100,8 +100,13 @@ func ShowMainUsage() {
"localgo send --file document.pdf --to MyPhone",
"localgo send --clipboard --to MyPhone",
"localgo send --stdin < document.txt --to MyPhone",
+ "localgo send --file large.zip --quick",
+ "localgo send --ip 192.168.1.42:53317 --file photo.jpg",
"localgo share --file document.pdf",
+ "localgo share --file photo.jpg --once",
"localgo history --limit 20",
+ "localgo config set alias MyDevice",
+ "localgo config list",
"localgo help send",
}
@@ -131,6 +136,16 @@ func ShowMainUsage() {
{"LOCALSEND_MULTICAST_GROUP", "Multicast group address"},
{"LOCALSEND_SECURITY_DIR", "Security directory path"},
{"LOCALSEND_LOG_LEVEL", "Log verbosity (debug/info/warn/error)"},
+ {"LOCALSEND_DISCOVERY_STRATEGY", "Discovery strategy: full (default) or fast"},
+ {"LOCALSEND_FILE_CONFLICT_RESOLUTION", "File conflict: rename (default), overwrite, skip"},
+ {"LOCALSEND_BIND_ADDRESS", "Bind to specific IP/interface"},
+ {"LOCALSEND_CONCURRENCY", "Max parallel upload workers (default: 4)"},
+ {"LOCALSEND_SHELL", "Shell prefix for exec hooks (default: sh -c)"},
+ {"LOCALSEND_CLIPBOARD_WRITE_CMD", "Custom clipboard write command"},
+ {"LOCALSEND_CLIPBOARD_READ_CMD", "Custom clipboard read command"},
+ {"LOCALSEND_TLS_CERT", "Custom TLS certificate path"},
+ {"LOCALSEND_TLS_KEY", "Custom TLS private key path"},
+ {"LOCALSEND_NOTIFICATION_CMD", "Custom notification command"},
}
for _, env := range envVars {
From a4fd3218d0191dda0b6d878a4e6b717fe7063541 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Sat, 25 Jul 2026 21:00:11 +0300
Subject: [PATCH 09/22] Wire config fields into runtime (BindAddress,
FileConflictResolve, StaticPeers, TrustedFingerprints)
- BindAddress: server.go binds to configured address instead of 0.0.0.0
- FileConflictResolve: receive_upload.go supports skip/overwrite/rename
- StaticPeers: Config field + viper load + Tier 1 probe in send.go (400ms per peer)
- TrustedFingerprints: Config field + viper load + bypass transfer prompt on match
- Fix duplicate sendpin check in cmd/send.go
- Extract web share code to webshare.go with green monospace theme
- Fix CSP to allow inline styles for web share page
- QR scheme follows actual http/https, HTTPS fingerprint shown in share output
- Add web share tests
---
cmd/localgo/cmd/config.go | 2 +
cmd/localgo/cmd/send.go | 3 -
cmd/localgo/cmd/share.go | 26 +-
docs/CLI_REFERENCE.md | 7 +-
pkg/config/config.go | 6 +
pkg/help/commands.go | 4 +-
pkg/send/send.go | 34 ++
pkg/server/handlers/download_handlers.go | 101 ----
pkg/server/handlers/download_handlers_test.go | 82 +++
pkg/server/handlers/receive_handlers.go | 27 +-
pkg/server/handlers/receive_upload.go | 27 +-
pkg/server/handlers/webshare.go | 551 ++++++++++++++++++
pkg/server/server.go | 13 +-
13 files changed, 752 insertions(+), 131 deletions(-)
create mode 100644 pkg/server/handlers/webshare.go
diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go
index ff26d4c..5c4ad0e 100644
--- a/cmd/localgo/cmd/config.go
+++ b/cmd/localgo/cmd/config.go
@@ -41,6 +41,8 @@ var knownConfigKeys = map[string]configKey{
"discovery_strategy": {typ: "enum", enums: []string{"full", "fast"}},
"file_conflict_resolution": {typ: "enum", enums: []string{"rename", "overwrite", "skip"}},
"bind_address": {typ: "string"},
+ "static_peers": {typ: "string"},
+ "trusted_fingerprints": {typ: "string"},
"clipboard_write_cmd": {typ: "string"},
"clipboard_read_cmd": {typ: "string"},
"tls_cert": {typ: "string"},
diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go
index f8d23f7..cc296cf 100644
--- a/cmd/localgo/cmd/send.go
+++ b/cmd/localgo/cmd/send.go
@@ -274,9 +274,6 @@ var sendCmd = &cobra.Command{
if sendpin != "" {
Cfg.PIN = sendpin
}
- if sendpin != "" {
- Cfg.PIN = sendpin
- }
cli.PrintHeader(fmt.Sprintf("Sending %d files", len(files)))
for _, file := range files {
diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go
index 7b24a84..f1b5936 100644
--- a/cmd/localgo/cmd/share.go
+++ b/cmd/localgo/cmd/share.go
@@ -66,7 +66,8 @@ var shareCmd = &cobra.Command{
if shareport > 0 {
Cfg.Port = shareport
}
- // Browser download API must use HTTP (browsers reject self-signed certs)
+ // Default to HTTP for browser compatibility; opt into HTTPS with --https.
+ // Self-signed certs trigger a browser security warning that users can bypass.
Cfg.HttpsEnabled = false
if shareuseHTTPS {
Cfg.HttpsEnabled = true
@@ -117,6 +118,9 @@ var shareCmd = &cobra.Command{
cli.PrintInfo("Alias: %s", displayAlias)
cli.PrintInfo("Protocol: %s", protocol)
cli.PrintInfo("Port: %d", Cfg.Port)
+ if Cfg.HttpsEnabled && Cfg.SecurityContext != nil && Cfg.SecurityContext.CertificateHash != "" {
+ cli.PrintInfo("Fingerprint: %s...", Cfg.SecurityContext.CertificateHash[:16])
+ }
}
// Verify and prepare files
@@ -240,27 +244,29 @@ var shareCmd = &cobra.Command{
if !sharequiet {
cli.PrintSuccess("Server ready! Waiting for connections...")
+ scheme := "http"
+ if Cfg.HttpsEnabled {
+ scheme = "https"
+ }
+
// Retrieve active network interfaces to display direct URLs
localIPs, err := network.GetLocalIPAddresses()
if err == nil && len(localIPs) > 0 {
cli.PrintHeader("\nAccess URLs:")
for _, ip := range localIPs {
- scheme := "https"
- if !Cfg.HttpsEnabled {
- scheme = "http"
- }
cli.PrintInfo(" %s://%s:%d", scheme, ip.String(), Cfg.Port)
}
fmt.Println()
- // Terminal QR code for mobile scanning
- primaryURL := fmt.Sprintf("http://%s:%d", localIPs[0].String(), Cfg.Port)
+ // Terminal QR code for mobile scanning (must match server scheme)
+ primaryURL := fmt.Sprintf("%s://%s:%d", scheme, localIPs[0].String(), Cfg.Port)
cli.PrintHeader("Scan QR Code on Mobile:")
qrterminal.GenerateHalfBlock(primaryURL, qrterminal.M, os.Stdout)
}
if Cfg.HttpsEnabled {
- cli.PrintWarning("HTTPS notice: browsers will show a security warning for self-signed certificates")
+ cli.PrintWarning("HTTPS notice: browsers will show a security warning for the self-signed certificate.")
+ cli.PrintWarning("Proceed past the warning (Advanced → Proceed) to download files.")
}
cli.PrintWarning("Press Ctrl+C to stop sharing")
@@ -283,8 +289,8 @@ func init() {
rootCmd.AddCommand(shareCmd)
shareCmd.Flags().StringSliceVar(&sharefiles, "file", []string{}, "File or directory to share")
shareCmd.Flags().IntVar(&shareport, "port", 0, "Port to run the server on")
- shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is now default for share)")
- shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS (browsers will reject self-signed certs)")
+ shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is already the default for share)")
+ shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS with a self-signed certificate (browsers show a security warning)")
shareCmd.Flags().StringVar(&sharepin, "pin", "", "PIN for authentication")
shareCmd.Flags().StringVar(&sharealias, "alias", "", "Device alias")
shareCmd.Flags().BoolVar(&shareautoAccept, "auto-accept", false, "Auto-accept incoming files")
diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md
index 2f19977..ba09dc6 100644
--- a/docs/CLI_REFERENCE.md
+++ b/docs/CLI_REFERENCE.md
@@ -84,8 +84,9 @@ localgo share --file FILE [flags]
|------|------|---------|-------------|
| `--file` | stringSlice | — | File or directory to share (can be repeated) |
| `--port` | int | from config | Port to run the server on |
-| `--http` | bool | false | Deprecated (HTTP is now default for share) |
-| `--https` | bool | false | Use HTTPS (browsers will reject self-signed certs) |
+| `--http` | bool | false | Deprecated (HTTP is already the default for share) |
+| `--https` | bool | false | Use HTTPS with a self-signed certificate (browsers show a security warning) |
+| `--once` | bool | false | Stop sharing automatically after the first download completes |
| `--pin` | string | — | PIN for authentication |
| `--alias` | string | from config | Device alias |
| `--auto-accept` | bool | false | Auto-accept incoming files without prompting |
@@ -103,6 +104,8 @@ localgo share --file document.pdf
localgo share --file document.pdf --file image.jpg
localgo share --file data.zip --pin 1234
localgo share --file mydir --zip
+localgo share --file document.pdf --https
+localgo share --file photo.jpg --once
```
---
diff --git a/pkg/config/config.go b/pkg/config/config.go
index a31a000..7650d73 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -49,6 +49,8 @@ type Config struct {
FileConflictResolve string `json:"-"` // conflict resolution: "rename" (default), "overwrite", "skip"
BindAddress string `json:"-"` // bind to specific interface/IP for listening
ShareOnce bool `json:"-"` // stop after first download (--once)
+ StaticPeers []string `json:"-"` // statically defined peer addresses for Tier 1 cache probe
+ TrustedFingerprints []string `json:"-"` // fingerprints that bypass the transfer acceptance prompt
Shell string `json:"-"` // shell command prefix for exec hooks (default: "sh -c" or "cmd /c")
ClipboardWriteCmd string `json:"-"` // custom clipboard write command
@@ -214,6 +216,8 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
fileConflictResolve = "rename"
}
bindAddress := v.GetString("bind_address")
+ staticPeers := v.GetStringSlice("static_peers")
+ trustedFingerprints := v.GetStringSlice("trusted_fingerprints")
cfg := &Config{
Alias: alias,
@@ -237,6 +241,8 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
DiscoveryStrategy: discoveryStrategy,
FileConflictResolve: fileConflictResolve,
BindAddress: bindAddress,
+ StaticPeers: staticPeers,
+ TrustedFingerprints: trustedFingerprints,
Shell: shell,
ClipboardWriteCmd: clipboardWriteCmd,
ClipboardReadCmd: clipboardReadCmd,
diff --git a/pkg/help/commands.go b/pkg/help/commands.go
index 9735463..5002eed 100644
--- a/pkg/help/commands.go
+++ b/pkg/help/commands.go
@@ -55,8 +55,8 @@ func GetCommandHelp(commandName string) *CommandHelp {
Flags: []FlagHelp{
{Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"},
{Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"},
- {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"},
- {Name: "--https", Type: "bool", Default: "false", Description: "Use HTTPS (browsers will show self-signed cert warning)"},
+ {Name: "--http", Type: "bool", Default: "false", Description: "Deprecated (HTTP is already the default for share)"},
+ {Name: "--https", Type: "bool", Default: "false", Description: "Use HTTPS with a self-signed certificate (browsers show a security warning)"},
{Name: "--once", Type: "bool", Default: "false", Description: "Stop sharing automatically after the first download completes"},
{Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"},
{Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"},
diff --git a/pkg/send/send.go b/pkg/send/send.go
index 616fede..b467de6 100644
--- a/pkg/send/send.go
+++ b/pkg/send/send.go
@@ -78,6 +78,40 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci
}, logger)
cancelCache()
+ // --- Tier 1b: Static Peers Probe (400ms per peer) ---
+ if targetDevice == nil && len(cfg.StaticPeers) > 0 {
+ logger.Infof("Probing %d static peer(s)...", len(cfg.StaticPeers))
+ registerDto := cfg.ToRegisterDto()
+ httpDisc := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger)
+ for _, staticPeer := range cfg.StaticPeers {
+ host, portStr, err := net.SplitHostPort(staticPeer)
+ if err != nil {
+ host = staticPeer
+ portStr = strconv.Itoa(recipientPort)
+ }
+ port, _ := strconv.Atoi(portStr)
+ ip := net.ParseIP(host)
+ if ip == nil {
+ ips, lookupErr := net.LookupIP(host)
+ if lookupErr != nil || len(ips) == 0 {
+ logger.Warnf("Static peer %s: hostname lookup failed: %v", staticPeer, lookupErr)
+ continue
+ }
+ ip = ips[0]
+ }
+
+ peerCtx, cancelPeer := context.WithTimeout(ctx, 400*time.Millisecond)
+ dev, fetchErr := httpDisc.FetchDeviceInfo(peerCtx, ip, port)
+ cancelPeer()
+
+ if fetchErr == nil && dev != nil && dev.Alias == recipientAlias {
+ logger.Infof("Discovered recipient via static peer: %s (%s)", dev.Alias, staticPeer)
+ targetDevice = dev
+ break
+ }
+ }
+ }
+
if targetDevice != nil {
logger.Infof("Discovered recipient via cache: %s (%s)", targetDevice.Alias, targetDevice.IP)
if err := VerifyDeviceFingerprint(peerCache, targetDevice); err != nil {
diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go
index 79c7b88..7d1bd15 100644
--- a/pkg/server/handlers/download_handlers.go
+++ b/pkg/server/handlers/download_handlers.go
@@ -3,7 +3,6 @@ package handlers
import (
"crypto/subtle"
"fmt"
- "html/template"
"io"
"net/http"
"os"
@@ -30,106 +29,6 @@ func (h *DownloadHandler) SetShutdownFn(fn func()) {
h.shutdownFn = fn
}
-const webShareHTML = `
-
-
-
-
- LocalGo File Share
-
-
-
-
- LocalGo File Share
- Shared by {{.Alias}}
- {{if .PinLocked}}
- This share is PIN protected.
-
- {{else}}
-
- {{range $id, $f := .Files}}
- -
-
- {{$f.FileName}}
- {{formatBytes $f.Size}}
-
- Download
-
- {{end}}
-
- {{end}}
-
-
-`
-
-// WebShareData holds template data for the web landing page.
-type WebShareData struct {
- Alias string
- SessionID string
- Files map[string]model.FileDto
- PinLocked bool
- PIN string
-}
-
-// WebShareHandler serves the root web landing page for browser access.
-func (h *DownloadHandler) WebShareHandler(w http.ResponseWriter, r *http.Request) {
- session := h.sendService.GetSession()
- if session == nil {
- http.Error(w, "No files are currently being shared.", http.StatusNotFound)
- return
- }
-
- pinLocked := false
- pin := r.URL.Query().Get("pin")
- if h.config.PIN != "" {
- if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 {
- pinLocked = true
- }
- }
-
- funcMap := template.FuncMap{
- "formatBytes": cli.FormatBytes,
- }
-
- tmpl, err := template.New("webshare").Funcs(funcMap).Parse(webShareHTML)
- if err != nil {
- http.Error(w, "Template error", http.StatusInternalServerError)
- return
- }
-
- alias := h.config.Alias
- if h.config.Private {
- alias = "Anonymous"
- }
-
- data := WebShareData{
- Alias: alias,
- SessionID: session.SessionID,
- Files: session.Files,
- PinLocked: pinLocked,
- PIN: pin,
- }
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = tmpl.Execute(w, data)
-}
// NewDownloadHandler creates a new DownloadHandler.
func NewDownloadHandler(cfg *config.Config, sendService *services.SendService, logger *zap.SugaredLogger) *DownloadHandler {
diff --git a/pkg/server/handlers/download_handlers_test.go b/pkg/server/handlers/download_handlers_test.go
index 71184e2..f7b5dc7 100644
--- a/pkg/server/handlers/download_handlers_test.go
+++ b/pkg/server/handlers/download_handlers_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/bethropolis/localgo/pkg/config"
@@ -158,3 +159,84 @@ func TestDownloadHandler_InvalidSession(t *testing.T) {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNotFound)
}
}
+
+func TestWebShareHandler_RendersFiles(t *testing.T) {
+ handler, sendService, _ := setupDownloadHandler(t, &config.Config{Alias: "Studio-PC"})
+
+ files := map[string]model.FileDto{
+ "b": {ID: "b", FileName: "photo.jpg", Size: 2048, FileType: "image/jpeg"},
+ "a": {ID: "a", FileName: "notes.txt", Size: 12, FileType: "text/plain"},
+ }
+ _, err := sendService.CreateSession(files, map[string]string{"a": "/tmp/a", "b": "/tmp/b"})
+ if err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rr := httptest.NewRecorder()
+ handler.WebShareHandler(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+ for _, want := range []string{
+ "LocalGo Share",
+ "Studio-PC",
+ "notes.txt",
+ "photo.jpg",
+ "2 files",
+ `class="file-icon image"`,
+ "no-store",
+ } {
+ if want == "no-store" {
+ if rr.Header().Get("Cache-Control") != "no-store" {
+ t.Errorf("Cache-Control = %q, want no-store", rr.Header().Get("Cache-Control"))
+ }
+ continue
+ }
+ if !strings.Contains(body, want) {
+ t.Errorf("response missing %q", want)
+ }
+ }
+ // Sorted by name: notes before photo
+ if i, j := strings.Index(body, "notes.txt"), strings.Index(body, "photo.jpg"); i < 0 || j < 0 || i > j {
+ t.Errorf("expected notes.txt before photo.jpg in body")
+ }
+}
+
+func TestWebShareHandler_PINLockAndError(t *testing.T) {
+ handler, sendService, _ := setupDownloadHandler(t, &config.Config{Alias: "Phone", PIN: "4242"})
+ _, _ = sendService.CreateSession(
+ map[string]model.FileDto{"f": {ID: "f", FileName: "secret.pdf", Size: 100}},
+ map[string]string{"f": "/tmp/secret.pdf"},
+ )
+
+ // Locked without PIN
+ rr := httptest.NewRecorder()
+ handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/", nil))
+ body := rr.Body.String()
+ if !strings.Contains(body, "PIN protected") {
+ t.Error("expected PIN lock screen")
+ }
+ if strings.Contains(body, "secret.pdf") {
+ t.Error("file name should be hidden while locked")
+ }
+ if strings.Contains(body, "Incorrect PIN") {
+ t.Error("should not show incorrect PIN when none was submitted")
+ }
+
+ // Wrong PIN
+ rr = httptest.NewRecorder()
+ handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/?pin=0000", nil))
+ if !strings.Contains(rr.Body.String(), "Incorrect PIN") {
+ t.Error("expected incorrect PIN message")
+ }
+
+ // Correct PIN
+ rr = httptest.NewRecorder()
+ handler.WebShareHandler(rr, httptest.NewRequest(http.MethodGet, "/?pin=4242", nil))
+ if !strings.Contains(rr.Body.String(), "secret.pdf") {
+ t.Error("expected unlocked file list")
+ }
+}
diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go
index f00b2da..a3b9043 100644
--- a/pkg/server/handlers/receive_handlers.go
+++ b/pkg/server/handlers/receive_handlers.go
@@ -190,14 +190,27 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R
// --- Interactive Accept/Reject Prompt ---
if !h.config.AutoAccept {
- h.promptMutex.Lock()
- accepted := h.promptUserForAcceptance(sender, requestDto.Files)
- h.promptMutex.Unlock()
+ // Check if sender fingerprint is in trusted_fingerprints whitelist
+ isTrusted := false
+ if sender.Fingerprint != "" && len(h.config.TrustedFingerprints) > 0 {
+ for _, trusted := range h.config.TrustedFingerprints {
+ if strings.EqualFold(trusted, sender.Fingerprint) {
+ isTrusted = true
+ break
+ }
+ }
+ }
- if !accepted {
- h.logger.Infof("Transfer rejected by user")
- httputil.RespondError(w, http.StatusForbidden, "Rejected") // 403 Forbidden
- return
+ if !isTrusted {
+ h.promptMutex.Lock()
+ accepted := h.promptUserForAcceptance(sender, requestDto.Files)
+ h.promptMutex.Unlock()
+
+ if !accepted {
+ h.logger.Infof("Transfer rejected by user")
+ httputil.RespondError(w, http.StatusForbidden, "Rejected") // 403 Forbidden
+ return
+ }
}
}
diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go
index ececde7..f5bcdca 100644
--- a/pkg/server/handlers/receive_upload.go
+++ b/pkg/server/handlers/receive_upload.go
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
+ "os"
"path/filepath"
"strings"
@@ -67,7 +68,23 @@ func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request)
// Normalize incoming filenames: convert Windows backslashes to forward
// slashes so cross-OS directory transfers create correct subdirectories.
rawFileName := filepath.ToSlash(dto.FileName)
- destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName)
+
+ // Determine destination path based on conflict resolution mode
+ var destinationPath string
+ switch h.config.FileConflictResolve {
+ case "skip":
+ destinationPath = filepath.Join(h.config.DownloadDir, rawFileName)
+ if _, err := os.Stat(destinationPath); err == nil {
+ h.logger.Infof("Skipping file transfer for existing file %s (file_conflict_resolution=skip)", rawFileName)
+ h.receiveService.CompleteFile(reqSessionId, reqFileId)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ case "overwrite":
+ destinationPath = filepath.Join(h.config.DownloadDir, rawFileName)
+ default:
+ destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName)
+ }
// Path traversal prevention: ensure the resolved path is still within DownloadDir
cleanPath := filepath.Clean(destinationPath)
@@ -193,7 +210,13 @@ func (h *ReceiveHandler) saveTextAsFileTo(sender model.DeviceInfo, reqSessionId,
} else {
combinedReader = bytes.NewReader(textBytes)
}
- destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName)
+ var destinationPath string
+ switch h.config.FileConflictResolve {
+ case "overwrite":
+ destinationPath = filepath.Join(h.config.DownloadDir, rawFileName)
+ default:
+ destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName)
+ }
cleanPath := filepath.Clean(destinationPath)
if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) &&
cleanPath != filepath.Clean(h.config.DownloadDir) {
diff --git a/pkg/server/handlers/webshare.go b/pkg/server/handlers/webshare.go
new file mode 100644
index 0000000..6f6fbe5
--- /dev/null
+++ b/pkg/server/handlers/webshare.go
@@ -0,0 +1,551 @@
+package handlers
+
+import (
+ "crypto/subtle"
+ "html/template"
+ "net/http"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/bethropolis/localgo/pkg/cli"
+ "github.com/bethropolis/localgo/pkg/model"
+)
+
+const webShareHTML = `
+
+
+
+
+
+
+ {{if .PinLocked}}Unlock · {{end}}LocalGo Share
+
+
+
+
+
+
+ LocalGo Share
+ Direct from {{.Alias}} — same network, no upload.
+ {{if not .PinLocked}}
+
+
+
+ {{.FileCount}} {{if eq .FileCount 1}}file{{else}}files{{end}}
+
+
+
+ {{formatBytes .TotalSize}}
+
+
+ {{end}}
+
+
+
+ {{if .PinLocked}}
+
+
+ PIN protected
+ Enter the PIN from the sender to view and download shared files.
+ {{if .PinError}}
+ Incorrect PIN. Please try again.
+ {{end}}
+
+
+ {{else if eq .FileCount 0}}
+ No files are currently shared.
+ {{else}}
+
+ {{range .Files}}
+ -
+
+
+
+
+ Download
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+
+ Local network transfer
+
+ Powered by LocalGo
+
+
+
+
+`
+
+// WebShareFile is one entry on the landing page file list.
+type WebShareFile struct {
+ ID string
+ FileName string
+ Size int64
+ Ext string
+ Kind string
+}
+
+// WebShareData holds template data for the web landing page.
+type WebShareData struct {
+ Alias string
+ SessionID string
+ Files []WebShareFile
+ FileCount int
+ TotalSize int64
+ PinLocked bool
+ PinError bool
+ PIN string
+}
+
+func fileKind(name, mime string) string {
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
+ mime = strings.ToLower(mime)
+
+ switch {
+ case strings.HasPrefix(mime, "image/"), ext == "png", ext == "jpg", ext == "jpeg", ext == "gif", ext == "webp", ext == "svg", ext == "heic", ext == "avif":
+ return "image"
+ case strings.HasPrefix(mime, "video/"), ext == "mp4", ext == "mov", ext == "mkv", ext == "webm", ext == "avi":
+ return "video"
+ case strings.HasPrefix(mime, "audio/"), ext == "mp3", ext == "wav", ext == "flac", ext == "ogg", ext == "m4a", ext == "aac":
+ return "audio"
+ case ext == "zip", ext == "tar", ext == "gz", ext == "tgz", ext == "bz2", ext == "xz", ext == "7z", ext == "rar", ext == "zst":
+ return "archive"
+ case ext == "go", ext == "rs", ext == "py", ext == "js", ext == "ts", ext == "tsx", ext == "jsx", ext == "java", ext == "c", ext == "cpp", ext == "h", ext == "json", ext == "yaml", ext == "yml", ext == "toml", ext == "xml", ext == "html", ext == "css", ext == "sh", ext == "md":
+ return "code"
+ default:
+ return "file"
+ }
+}
+
+func buildWebShareFiles(files map[string]model.FileDto) ([]WebShareFile, int64) {
+ out := make([]WebShareFile, 0, len(files))
+ var total int64
+ for id, f := range files {
+ ext := strings.ToUpper(strings.TrimPrefix(filepath.Ext(f.FileName), "."))
+ out = append(out, WebShareFile{
+ ID: id,
+ FileName: f.FileName,
+ Size: f.Size,
+ Ext: ext,
+ Kind: fileKind(f.FileName, f.FileType),
+ })
+ total += f.Size
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return strings.ToLower(out[i].FileName) < strings.ToLower(out[j].FileName)
+ })
+ return out, total
+}
+
+const webShareEmptyHTML = `
+
+
+
+
+
+ LocalGo Share
+
+
+
+
+ Nothing shared right now
+ This LocalGo share session has no files available. Ask the sender to start sharing again.
+
+
+`
+
+// WebShareHandler serves the root web landing page for browser access.
+func (h *DownloadHandler) WebShareHandler(w http.ResponseWriter, r *http.Request) {
+ session := h.sendService.GetSession()
+ if session == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(webShareEmptyHTML))
+ return
+ }
+
+ pinLocked := false
+ pinError := false
+ pin := r.URL.Query().Get("pin")
+ if h.config.PIN != "" {
+ if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 {
+ pinLocked = true
+ pinError = pin != ""
+ }
+ }
+
+ funcMap := template.FuncMap{
+ "formatBytes": cli.FormatBytes,
+ }
+
+ tmpl, err := template.New("webshare").Funcs(funcMap).Parse(webShareHTML)
+ if err != nil {
+ http.Error(w, "Template error", http.StatusInternalServerError)
+ return
+ }
+
+ alias := h.config.Alias
+ if h.config.Private {
+ alias = "Anonymous"
+ }
+
+ files, totalSize := buildWebShareFiles(session.Files)
+ data := WebShareData{
+ Alias: alias,
+ SessionID: session.SessionID,
+ Files: files,
+ FileCount: len(files),
+ TotalSize: totalSize,
+ PinLocked: pinLocked,
+ PinError: pinError,
+ PIN: pin,
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = tmpl.Execute(w, data)
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 9e4e10b..cf4e52f 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -64,7 +64,7 @@ func securityMiddleware(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1; mode=block")
- w.Header().Set("Content-Security-Policy", "default-src 'self'")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'")
w.Header().Set("Referrer-Policy", "no-referrer")
// Block cross-origin requests from external websites.
@@ -140,7 +140,12 @@ func (s *Server) configureRoutes() {
func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error {
s.configureRoutes()
- addr := fmt.Sprintf("0.0.0.0:%d", s.config.Port)
+ bindHost := "0.0.0.0"
+ if s.config.BindAddress != "" {
+ bindHost = s.config.BindAddress
+ }
+
+ addr := fmt.Sprintf("%s:%d", bindHost, s.config.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: s.muxRouter,
@@ -158,7 +163,7 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error {
cli.Notify("LocalGo: Port Changed",
fmt.Sprintf("Port %d was busy. Now running on a different port.", s.config.Port))
- addr = "0.0.0.0:0"
+ addr = fmt.Sprintf("%s:0", bindHost)
ln, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to bind port: %w", err)
@@ -166,7 +171,7 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error {
actualPort := ln.Addr().(*net.TCPAddr).Port
s.config.Port = actualPort
- addr = fmt.Sprintf("0.0.0.0:%d", actualPort)
+ addr = fmt.Sprintf("%s:%d", bindHost, actualPort)
s.httpServer.Addr = addr
s.logger.Infof("Server bound to port %d", actualPort)
}
From 65acc1b56dcad53c84860916d1d724df83f2b9b0 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Tue, 28 Jul 2026 18:33:06 +0300
Subject: [PATCH 10/22] Add 'slice' type to configKey validation; change
static_peers/trusted_fingerprints to typ:slice
---
cmd/localgo/cmd/config.go | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go
index 5c4ad0e..ae497d8 100644
--- a/cmd/localgo/cmd/config.go
+++ b/cmd/localgo/cmd/config.go
@@ -17,7 +17,7 @@ import (
// configKey describes a known config key with its type and valid values.
type configKey struct {
- typ string // "string", "int", "bool", "enum"
+ typ string // "string", "int", "bool", "enum", "slice"
enums []string // valid values for enum type
intMin int // minimum for int type
intMax int // maximum for int type
@@ -41,8 +41,8 @@ var knownConfigKeys = map[string]configKey{
"discovery_strategy": {typ: "enum", enums: []string{"full", "fast"}},
"file_conflict_resolution": {typ: "enum", enums: []string{"rename", "overwrite", "skip"}},
"bind_address": {typ: "string"},
- "static_peers": {typ: "string"},
- "trusted_fingerprints": {typ: "string"},
+ "static_peers": {typ: "slice"},
+ "trusted_fingerprints": {typ: "slice"},
"clipboard_write_cmd": {typ: "string"},
"clipboard_read_cmd": {typ: "string"},
"tls_cert": {typ: "string"},
@@ -147,6 +147,8 @@ func validateValue(ck configKey, raw string) (interface{}, error) {
}
}
return nil, fmt.Errorf("invalid value %q; valid values: %s", raw, strings.Join(ck.enums, ", "))
+ case "slice":
+ return nil, fmt.Errorf("use 'config add %s' or 'config remove %s' to manage list values", ck.typ, ck.typ)
}
return raw, nil
}
From 40a3e8e8013ff6c037f3bf9e4ca98978f46da1fe Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:22:20 +0300
Subject: [PATCH 11/22] Fix clipboard send: remove 204 No Content early return
in prepare-upload
The prepare-upload handler was short-circuiting clipboard messages with a
204 No Content response after writing to clipboard. The official LocalSend
mobile app does not handle 204 and remains in a waiting state.
Fix: remove the early return and let clipboard messages fall through to the
normal create-session + upload flow. The upload handler already writes to
clipboard or saves as file when it receives the text/plain upload.
---
pkg/server/handlers/receive_handlers.go | 77 ++++++++++---------------
1 file changed, 30 insertions(+), 47 deletions(-)
diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go
index a3b9043..f59134e 100644
--- a/pkg/server/handlers/receive_handlers.go
+++ b/pkg/server/handlers/receive_handlers.go
@@ -6,14 +6,12 @@ import (
"encoding/json"
"net"
"net/http"
- "os"
"os/exec"
"runtime"
"strings"
"sync"
"github.com/bethropolis/localgo/pkg/cli"
- "github.com/bethropolis/localgo/pkg/clipboard"
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/history"
"github.com/bethropolis/localgo/pkg/httputil"
@@ -95,21 +93,28 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R
return
}
- // Extract IP from RemoteAddr early (used by clipboard path and elsewhere)
+ // Extract IP from RemoteAddr early
senderIP, _, _ := net.SplitHostPort(r.RemoteAddr)
+ sender := model.DeviceInfo{
+ Alias: cli.Sanitize(requestDto.Info.Alias),
+ Version: requestDto.Info.Version,
+ DeviceModel: requestDto.Info.DeviceModel,
+ DeviceType: requestDto.Info.DeviceType,
+ Fingerprint: requestDto.Info.Fingerprint,
+ IP: senderIP,
+ }
+
// --- Clipboard Message Detection ---
// The official LocalSend embeds clipboard text in the Preview field.
// Only short-circuit when it's a single clipboard message (full content
// already present, Size matches Preview length). Fall through to the
// normal upload path otherwise.
var clipboardMessage string
- var clipboardFileID string
- for id, f := range requestDto.Files {
+ for _, f := range requestDto.Files {
if f.Preview != nil && *f.Preview != "" && strings.HasPrefix(f.FileType, "text/plain") {
if len(requestDto.Files) == 1 && f.Size == int64(len(*f.Preview)) {
clipboardMessage = *f.Preview
- clipboardFileID = id
}
break
}
@@ -118,41 +123,28 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R
if clipboardMessage != "" {
h.logger.Infof("Clipboard message from %s", cli.Sanitize(requestDto.Info.Alias))
if !h.config.AutoAccept {
- h.promptMutex.Lock()
- accepted := h.promptForClipboard(cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, clipboardMessage)
- h.promptMutex.Unlock()
- if !accepted {
- httputil.RespondError(w, http.StatusForbidden, "Rejected")
- return
+ isTrusted := false
+ if sender.Fingerprint != "" && len(h.config.TrustedFingerprints) > 0 {
+ for _, trusted := range h.config.TrustedFingerprints {
+ if strings.EqualFold(trusted, sender.Fingerprint) {
+ isTrusted = true
+ break
+ }
+ }
}
- }
-
- sanitizedAlias := cli.Sanitize(requestDto.Info.Alias)
-
- if !h.config.NoClipboard {
- if err := clipboard.Write(clipboardMessage); err != nil {
- h.logger.Warnf("Clipboard write failed (%v), saving text as file instead", err)
- } else {
- h.logger.Infof("Clipboard message from %s accepted and copied", sanitizedAlias)
- h.logTransfer(sanitizedAlias, senderIP, clipboardFileID, "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard)
- h.runExecHook("", clipboardFileID, sanitizedAlias, senderIP, int64(len(clipboardMessage)))
- w.WriteHeader(http.StatusNoContent)
- return
+ if !isTrusted {
+ h.promptMutex.Lock()
+ accepted := h.promptForClipboard(cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, clipboardMessage)
+ h.promptMutex.Unlock()
+ if !accepted {
+ httputil.RespondError(w, http.StatusForbidden, "Rejected")
+ return
+ }
}
}
-
- // Fallback: save as file (NoClipboard mode or clipboard write failed)
- clipboardPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, "clipboard.txt")
- if err := os.WriteFile(clipboardPath, []byte(clipboardMessage), 0600); err != nil {
- h.logger.Errorf("Failed to save clipboard text to %s: %v", clipboardPath, err)
- httputil.RespondError(w, http.StatusInternalServerError, "Failed to save clipboard")
- return
- }
- h.logger.Infof("Clipboard message from %s saved to %s", sanitizedAlias, clipboardPath)
- h.logTransfer(sanitizedAlias, senderIP, clipboardFileID, clipboardPath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard)
- h.runExecHook(clipboardPath, clipboardFileID, sanitizedAlias, senderIP, int64(len(clipboardMessage)))
- w.WriteHeader(http.StatusNoContent)
- return
+ // Fall through to normal create-session + upload flow.
+ // The upload handler will write to clipboard or save as file
+ // when the sender completes the upload.
}
// --- Check Disk Space ---
@@ -179,15 +171,6 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R
h.logger.Infof("PrepareUpload request from %s (%s) for %d files:", cli.Sanitize(requestDto.Info.Alias), r.RemoteAddr, len(requestDto.Files))
- sender := model.DeviceInfo{
- Alias: cli.Sanitize(requestDto.Info.Alias),
- Version: requestDto.Info.Version,
- DeviceModel: requestDto.Info.DeviceModel,
- DeviceType: requestDto.Info.DeviceType,
- Fingerprint: requestDto.Info.Fingerprint,
- IP: senderIP,
- }
-
// --- Interactive Accept/Reject Prompt ---
if !h.config.AutoAccept {
// Check if sender fingerprint is in trusted_fingerprints whitelist
From db7325f9de6a5122417f72bfafd2e2826af83d26 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Tue, 28 Jul 2026 21:07:06 +0300
Subject: [PATCH 12/22] Fix clipboard send: write immediately in
prepare-upload, return 204
Clipboard send was broken by two issues:
1. prepare-upload handler fell through to create-session + upload flow,
causing the mobile app to wait for an upload that never came and a
double-prompt on the receiver.
2. clipboard.Write() used CombinedOutput() which created stdout/stderr
pipes. xclip/wl-copy fork a background daemon that inherits those
pipes, causing Go's cmd.Wait() to hang forever. The 204 response was
never sent until Ctrl+C killed the daemon.
Fix:
- Write clipboard content immediately in prepare-upload (after prompt
acceptance), print confirmation to stderr, pause 1s for client timing,
then return 204 No Content. The mobile app receives 204 and closes
the session immediately. No session is created for clipboard messages.
- Replace CombinedOutput() with CommandContext(3s timeout) + /dev/null
for stdout/stderr + Run(). The xclip daemon no longer inherits pipes,
so clipboard.Write returns in <10ms.
- Same timeout guard applied to Read() for consistency.
---
pkg/clipboard/clipboard.go | 38 ++++++++++++++++++++---
pkg/server/handlers/receive_handlers.go | 40 +++++++++++++++++++++++--
2 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go
index 9362f83..8de8f5f 100644
--- a/pkg/clipboard/clipboard.go
+++ b/pkg/clipboard/clipboard.go
@@ -6,9 +6,12 @@
package clipboard
import (
+ "context"
"fmt"
+ "os"
"os/exec"
"strings"
+ "time"
)
// provider holds the resolved clipboard commands for this run.
@@ -32,10 +35,30 @@ func Write(text string) error {
if provider == nil {
return fmt.Errorf("clipboard unavailable: no supported tool found (install xclip, xsel, wl-copy, pbcopy, or clip.exe)")
}
- cmd := exec.Command(provider.cmd, provider.args...) //nolint:gosec
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, provider.cmd, provider.args...) //nolint:gosec
cmd.Stdin = strings.NewReader(text)
- if out, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("clipboard write failed (%s): %w: %s", provider.cmd, err, strings.TrimSpace(string(out)))
+
+ // Do NOT use CombinedOutput() here. Tools like xclip and wl-copy fork into
+ // the background to hold the selection and inherit stdout/stderr write pipes.
+ // Go's exec pipe reader goroutines wait indefinitely for EOF on those pipes,
+ // causing cmd.Wait() to hang until the process or daemon is killed.
+ // Using os.DevNull prevents pipe creation and inheritance.
+ devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+ if err == nil {
+ cmd.Stdout = devNull
+ cmd.Stderr = devNull
+ defer devNull.Close()
+ }
+
+ if err := cmd.Run(); err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ return fmt.Errorf("clipboard write timed out (%s)", provider.cmd)
+ }
+ return fmt.Errorf("clipboard write failed (%s): %w", provider.cmd, err)
}
return nil
}
@@ -46,9 +69,16 @@ func Read() (string, error) {
if provider == nil || provider.readCmd == "" {
return "", fmt.Errorf("clipboard read unavailable: no supported tool found (install xclip, xsel, wl-paste, pbpaste, or Get-Clipboard)")
}
- cmd := exec.Command(provider.readCmd, provider.readArgs...) //nolint:gosec
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, provider.readCmd, provider.readArgs...) //nolint:gosec
out, err := cmd.Output()
if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("clipboard read timed out (%s)", provider.readCmd)
+ }
// Some tools (xclip, wl-paste) exit with 1 when the clipboard is empty
// and produce no output. Treat this as empty, not an error.
if len(out) == 0 {
diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go
index f59134e..4f1f3f7 100644
--- a/pkg/server/handlers/receive_handlers.go
+++ b/pkg/server/handlers/receive_handlers.go
@@ -4,14 +4,18 @@ import (
"context"
"crypto/subtle"
"encoding/json"
+ "fmt"
"net"
"net/http"
+ "os"
"os/exec"
"runtime"
"strings"
"sync"
+ "time"
"github.com/bethropolis/localgo/pkg/cli"
+ "github.com/bethropolis/localgo/pkg/clipboard"
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/history"
"github.com/bethropolis/localgo/pkg/httputil"
@@ -142,9 +146,39 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R
}
}
}
- // Fall through to normal create-session + upload flow.
- // The upload handler will write to clipboard or save as file
- // when the sender completes the upload.
+
+ sanitizedAlias := cli.Sanitize(requestDto.Info.Alias)
+
+ if !h.config.NoClipboard {
+ if err := clipboard.Write(clipboardMessage); err != nil {
+ h.logger.Warnf("Clipboard write failed (%v), saving text as file instead", err)
+ } else {
+ h.logger.Infof("Clipboard message from %s accepted and copied", sanitizedAlias)
+ fmt.Fprintln(os.Stderr, "✓ Copied to clipboard!")
+ os.Stderr.Sync()
+ h.logTransfer(sanitizedAlias, senderIP, "", "", int64(len(clipboardMessage)), "text/plain", history.StatusClipboard)
+ h.runExecHook("", "", sanitizedAlias, senderIP, int64(len(clipboardMessage)))
+ time.Sleep(1 * time.Second)
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ }
+
+ // Fallback: save as file
+ clipboardFilePath := storage.ResolveDuplicateFilename(h.config.DownloadDir, "clipboard.txt")
+ if err := os.WriteFile(clipboardFilePath, []byte(clipboardMessage), 0600); err != nil {
+ h.logger.Errorf("Failed to save clipboard text to %s: %v", clipboardFilePath, err)
+ httputil.RespondError(w, http.StatusInternalServerError, "Failed to save clipboard")
+ return
+ }
+ h.logger.Infof("Clipboard message from %s saved to %s", sanitizedAlias, clipboardFilePath)
+ fmt.Fprintln(os.Stderr, "✓ Saved clipboard to file")
+ os.Stderr.Sync()
+ h.logTransfer(sanitizedAlias, senderIP, "clipboard.txt", clipboardFilePath, int64(len(clipboardMessage)), "text/plain", history.StatusClipboard)
+ h.runExecHook(clipboardFilePath, "clipboard.txt", sanitizedAlias, senderIP, int64(len(clipboardMessage)))
+ time.Sleep(1 * time.Second)
+ w.WriteHeader(http.StatusNoContent)
+ return
}
// --- Check Disk Space ---
From eca4d8b9448aa32bfd72cca508d1108e8b565d7f Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Wed, 29 Jul 2026 16:10:10 +0300
Subject: [PATCH 13/22] Fix go.mod: run go mod tidy (move qrterminal to direct
deps)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
go mod tidy moves github.com/mdp/qrterminal/v3 from // indirect to
direct dependency. Also fix protocol description in goreleaser.yaml
and README (v2.1 → v2 per spec).
---
.goreleaser.yaml | 8 ++++----
README.md | 2 +-
go.mod | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index dac0b7f..d67b978 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -135,7 +135,7 @@ homebrew_casks:
directory: Casks
homepage: "https://github.com/bethropolis/localgo"
- description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI"
+ description: "LocalSend v2 protocol implementation. LAN file transfer CLI"
license: "MIT"
caveats: "Shell completions are installed automatically.\n\nTo start as a background service:\n localgo serve --quiet --auto-accept"
@@ -147,7 +147,7 @@ aurs:
- name: localgo-bin
ids: [default]
homepage: "https://github.com/bethropolis/localgo"
- description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI"
+ description: "LocalSend v2 protocol implementation. LAN file transfer CLI"
maintainers:
- "bethropolis "
license: MIT
@@ -222,7 +222,7 @@ scoops:
name: github-actions[bot]
email: github-actions[bot]@users.noreply.github.com
homepage: "https://github.com/bethropolis/localgo"
- description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI"
+ description: "LocalSend v2 protocol implementation. LAN file transfer CLI"
license: MIT
skip_upload: auto
@@ -236,7 +236,7 @@ nfpms:
vendor: bethropolis
homepage: "https://github.com/bethropolis/localgo"
maintainer: "bethropolis"
- description: "LocalSend v2.1 protocol implementation — LAN file transfer CLI"
+ description: "LocalSend v2 protocol implementation. LAN file transfer CLI"
license: MIT
formats:
- deb
diff --git a/README.md b/README.md
index f0142df..89f34a8 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi
## Features
-- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps
+- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps (V3 planned)
- **Secure** - HTTPS with certificates, optional PIN protection
- **Fast Discovery** - Multicast UDP + HTTP fallback
- **Multi-file Transfers** - Send multiple files concurrently
diff --git a/go.mod b/go.mod
index e64cac9..592b69b 100644
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/jackpal/gateway v1.2.0
+ github.com/mdp/qrterminal/v3 v3.2.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.11.1
@@ -51,7 +52,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
- github.com/mdp/qrterminal/v3 v3.2.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
From 0fed8de1e37183f1efd91398ca732e63cf7f7b6a Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Wed, 29 Jul 2026 16:33:32 +0300
Subject: [PATCH 14/22] docs: update CLI reference, config docs, and help text
for v0.6.5
- Expand CLI_REFERENCE.md with --quick, --pin, config add/remove/open/unset
- Add missing env vars to README.md and CONFIGURATION.md
- Fix --probe flag (no -p short flag, conflicts with --private)
- Fix config.go error message showing internal type instead of key name
- Add --no-color, --version to docs, --once/--range examples
---
README.md | 38 ++++++++++++++++++++++++++++++--------
cmd/localgo/cmd/config.go | 6 +++---
docs/CLI_REFERENCE.md | 20 ++++++++++++++++++++
docs/CONFIGURATION.md | 16 +++++++++++++++-
pkg/help/commands.go | 2 +-
pkg/help/help.go | 11 ++++++++++-
6 files changed, 79 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 89f34a8..6da05f9 100644
--- a/README.md
+++ b/README.md
@@ -24,13 +24,14 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi
## Features
-- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps (V3 planned)
-- **Secure** - HTTPS with certificates, optional PIN protection
-- **Fast Discovery** - Multicast UDP + HTTP fallback
-- **Multi-file Transfers** - Send multiple files concurrently
-- **Web Share** - Share files via browser download link
-- **Clipboard Integration** - Incoming text/plain transfers copied to clipboard automatically
-- **Metadata Preserved** - File timestamps preserved on transfer
+- **Complete LocalSend v2.1 Protocol** - Works with LocalSend apps (v3 planned)
+- **Secure** - HTTPS with auto-generated certificates, PIN-protected transfers, fingerprint TOFU verification
+- **Fast Discovery** - Multicast UDP + HTTP fallback + CIDR range scanning
+- **Multi-file Transfers** - Send multiple files concurrently with progress bars
+- **Web Share** - Share files via browser download link with QR code and one-shot mode
+- **Clipboard Integration** - Incoming text/plain transfers copied to clipboard instantly; send clipboard text directly
+- **Privacy Mode** - Anonymize device identity during discovery and transfer (`--private`)
+- **Metadata Preserved** - File timestamps preserved on transfer; EXIF/metadata stripping in private mode
- **Cross-Platform** - Linux, macOS, Windows
## Quick Start
@@ -98,9 +99,24 @@ localgo send --file document.pdf --to "My Phone"
# Send clipboard contents directly
localgo send --clipboard --to "My Phone"
+# Send with PIN authentication
+localgo send --file secret.pdf --to "My Phone" --pin 1234
+
+# Fast discovery (skip cache probe)
+localgo send --file large.zip --quick
+
+# Send directly to an IP (skips discovery)
+localgo send --ip 192.168.1.42:53317 --file photo.jpg
+
+# Scan a specific CIDR range
+localgo scan --range 192.168.1.0/24
+
# Inspect transfer history logs
localgo history
+# Share files for web download (single-use)
+localgo share --file document.pdf --once
+
# Share files for web download
localgo share --file document.pdf
```
@@ -133,6 +149,8 @@ For full details on deployment, macvlan networking, read-only root filesystem, w
| `LOCALSEND_QUIET` | false | Minimal output mode |
| `LOCALSEND_CONCURRENCY` | 4 | Max parallel upload workers |
| `LOCALSEND_MULTICAST_INTERFACE` | (all) | Network interface for multicast |
+| `LOCALSEND_MULTICAST_GROUP` | 224.0.0.167 | Multicast group address |
+| `LOCALSEND_DISCOVERY_STRATEGY` | full | Discovery strategy (full/fast) |
| `LOCALSEND_SHELL` | (auto) | Shell prefix for exec hooks |
| `LOCALSEND_CLIPBOARD_WRITE_CMD` | (auto) | Custom clipboard write command |
| `LOCALSEND_CLIPBOARD_READ_CMD` | (auto) | Custom clipboard read command |
@@ -141,6 +159,10 @@ For full details on deployment, macvlan networking, read-only root filesystem, w
| `LOCALSEND_NOTIFICATION_CMD` | (auto) | Custom notification command |
| `LOCALSEND_MAX_BODY_SIZE` | 0 | Max request body size (0 = unlimited) |
| `LOCALSEND_SECURITY_DIR` | (auto) | Security context directory |
+| `LOCALSEND_FILE_CONFLICT_RESOLUTION` | rename | File conflict mode (rename/overwrite/skip) |
+| `LOCALSEND_BIND_ADDRESS` | (all) | Bind to specific IP address |
+| `LOCALSEND_STATIC_PEERS` | — | Comma-separated list of static peer IP:port |
+| `LOCALSEND_TRUSTED_FINGERPRINTS` | — | Comma-separated list of trusted device fingerprints |
### Example
@@ -164,7 +186,7 @@ localgo serve
| `devices` | List discovered devices |
| `history` | Show transfer history log |
| `stop` | Stop a running daemon |
-| `config` | Manage configuration (get/set/list/path) |
+| `config` | Manage configuration (get/set/add/remove/open/unset/list/path) |
| `version` | Show version information |
Run `localgo help` for more options.
diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go
index ae497d8..5d92ee4 100644
--- a/cmd/localgo/cmd/config.go
+++ b/cmd/localgo/cmd/config.go
@@ -121,7 +121,7 @@ func validateKey(key string) (configKey, error) {
return ck, nil
}
-func validateValue(ck configKey, raw string) (interface{}, error) {
+func validateValue(ck configKey, key, raw string) (interface{}, error) {
switch ck.typ {
case "string":
return raw, nil
@@ -148,7 +148,7 @@ func validateValue(ck configKey, raw string) (interface{}, error) {
}
return nil, fmt.Errorf("invalid value %q; valid values: %s", raw, strings.Join(ck.enums, ", "))
case "slice":
- return nil, fmt.Errorf("use 'config add %s' or 'config remove %s' to manage list values", ck.typ, ck.typ)
+ return nil, fmt.Errorf("use 'config add %s' or 'config remove %s' to manage list values", key, key)
}
return raw, nil
}
@@ -219,7 +219,7 @@ var configSetCmd = &cobra.Command{
return err
}
- val, err := validateValue(ck, args[1])
+ val, err := validateValue(ck, key, args[1])
if err != nil {
return err
}
diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md
index ba09dc6..afd88cb 100644
--- a/docs/CLI_REFERENCE.md
+++ b/docs/CLI_REFERENCE.md
@@ -132,6 +132,8 @@ localgo send --file FILE [flags]
| `--iface` | string | — | Multicast network interface name |
| `--clipboard`, `-c` | bool | false | Send current system clipboard text directly |
| `--stdin` | bool | false | Send text read from standard input (stdin) |
+| `--quick`, `-q` | bool | false | Fast discovery mode (skip cache probe, multicast burst only) |
+| `--pin` | string | — | PIN for sender authentication |
**Discovery Logic:**
1. **Direct IP** (`--ip`): Skips discovery entirely, sends directly to the given IP:port.
@@ -151,6 +153,8 @@ localgo send --file data.zip --to RemotePC --timeout 60
localgo send --ip 192.168.1.100:53317 --file doc.pdf
localgo send --clipboard --to MyPhone
cat report.txt | localgo send --stdin --to MyPhone
+localgo send --file large.zip --quick
+localgo send --file secret.pdf --to MyPhone --pin 1234
```
---
@@ -281,6 +285,18 @@ Get a single config value by key.
### `localgo config set `
Set a config value. Automatically detects the type (int, bool, float64, string).
+### `localgo config add `
+Append a value to a list config key (e.g. `static_peers`, `trusted_fingerprints`).
+
+### `localgo config remove `
+Remove a value from a list config key.
+
+### `localgo config open`
+Open the config file in the default text editor.
+
+### `localgo config unset `
+Unset a config key, reverting it to its default value.
+
### `localgo config list`
List all config values.
@@ -291,6 +307,10 @@ Show the config file path.
```bash
localgo config get port
localgo config set alias "MyDevice"
+localgo config add static_peers "10.0.0.5:53317"
+localgo config remove static_peers "10.0.0.5:53317"
+localgo config open
+localgo config unset port
localgo config list
localgo config path
```
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 775f4fc..4a76e9c 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -24,6 +24,8 @@ These can be passed before any subcommand.
| `--no-color` | Disable colored output | `false` |
| `--config` | Config file path | — |
| `--private`, `-p` | Hide device identity during discovery and transfer | `false` |
+| `-v`, `--version` | Show version information | — |
+| `-h`, `--help` | Show help | — |
### `serve` Flags
| Flag | Description | Default |
@@ -75,6 +77,8 @@ These can be passed before any subcommand.
| `--iface` | Multicast network interface name | — |
| `--clipboard`, `-c` | Send current system clipboard text directly | `false` |
| `--stdin` | Send text read from standard input (stdin) | `false` |
+| `--quick`, `-q` | Fast discovery mode (skip cache probe, multicast burst only) | `false` |
+| `--pin` | PIN for sender authentication | — |
### `discover` Flags
| Flag | Description | Default |
@@ -92,12 +96,17 @@ These can be passed before any subcommand.
| `--json` | Output results in JSON format | `false` |
| `--quiet` | Only show results, no status messages | `false` |
-### `devices` / `info` Flags
+### `devices` Flags
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Output results in JSON format | `false` |
| `--probe` | Probe cached devices to verify if they are currently online | `false` |
+### `info` Flags
+| Flag | Description | Default |
+|------|-------------|---------|
+| `--json` | Output results in JSON format | `false` |
+
### `history` Flags
| Flag | Description | Default |
|------|-------------|---------|
@@ -129,6 +138,11 @@ You can set these globally to avoid repeating flags.
| `LOCALSEND_QUIET` | Minimal output mode | `false` |
| `LOCALSEND_CONCURRENCY` | Max parallel upload workers | `4` |
| `LOCALSEND_MULTICAST_INTERFACE` | Network interface to bind multicast to | (all) |
+| `LOCALSEND_DISCOVERY_STRATEGY` | Discovery strategy (`full`/`fast`) | `full` |
+| `LOCALSEND_FILE_CONFLICT_RESOLUTION` | File conflict mode (`rename`/`overwrite`/`skip`) | `rename` |
+| `LOCALSEND_BIND_ADDRESS` | Bind to specific IP address | (all) |
+| `LOCALSEND_STATIC_PEERS` | Comma-separated list of static peer IP:port | — |
+| `LOCALSEND_TRUSTED_FINGERPRINTS` | Comma-separated list of trusted device fingerprints | — |
| `LOCALSEND_SHELL` | Shell prefix for exec hooks | (auto-detected) |
| `LOCALSEND_CLIPBOARD_WRITE_CMD` | Custom clipboard write command | (auto-detected) |
| `LOCALSEND_CLIPBOARD_READ_CMD` | Custom clipboard read command | (auto-detected) |
diff --git a/pkg/help/commands.go b/pkg/help/commands.go
index 5002eed..300db54 100644
--- a/pkg/help/commands.go
+++ b/pkg/help/commands.go
@@ -162,7 +162,7 @@ func GetCommandHelp(commandName string) *CommandHelp {
"localgo devices --json",
},
Flags: []FlagHelp{
- {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"},
+ {Name: "--probe", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"},
{Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"},
},
},
diff --git a/pkg/help/help.go b/pkg/help/help.go
index ac75574..6510fea 100644
--- a/pkg/help/help.go
+++ b/pkg/help/help.go
@@ -47,7 +47,7 @@ func ShowMainUsage() {
{"devices", "List recently discovered devices"},
{"history", "Show file transfer history log"},
{"stop", "Stop the running LocalGo daemon"},
- {"config", "Manage LocalGo configuration (get/set/list/path)"},
+ {"config", "Manage LocalGo configuration (get/set/add/remove/open/unset/list/path)"},
{"info", "Show device information"},
{"completion", "Generate shell completion scripts"},
{"help", "Show help information"},
@@ -74,6 +74,7 @@ func ShowMainUsage() {
{"-v, --version", "Show version"},
{"--verbose", "Enable debug logging"},
{"--json", "Enable JSON log output"},
+ {"--no-color", "Disable colored output"},
{"--private, -p", "Hide device identity during discovery/transfer"},
{"--config", "Config file path"},
}
@@ -102,10 +103,16 @@ func ShowMainUsage() {
"localgo send --stdin < document.txt --to MyPhone",
"localgo send --file large.zip --quick",
"localgo send --ip 192.168.1.42:53317 --file photo.jpg",
+ "localgo send --file secret.pdf --to MyPhone --pin 1234",
"localgo share --file document.pdf",
"localgo share --file photo.jpg --once",
"localgo history --limit 20",
+ "localgo scan --range 192.168.1.0/24",
"localgo config set alias MyDevice",
+ "localgo config add static_peers 10.0.0.5:53317",
+ "localgo config remove static_peers 10.0.0.5:53317",
+ "localgo config open",
+ "localgo config unset port",
"localgo config list",
"localgo help send",
}
@@ -139,6 +146,8 @@ func ShowMainUsage() {
{"LOCALSEND_DISCOVERY_STRATEGY", "Discovery strategy: full (default) or fast"},
{"LOCALSEND_FILE_CONFLICT_RESOLUTION", "File conflict: rename (default), overwrite, skip"},
{"LOCALSEND_BIND_ADDRESS", "Bind to specific IP/interface"},
+ {"LOCALSEND_STATIC_PEERS", "Comma-separated list of static peer IP:port"},
+ {"LOCALSEND_TRUSTED_FINGERPRINTS", "Comma-separated list of trusted device fingerprints"},
{"LOCALSEND_CONCURRENCY", "Max parallel upload workers (default: 4)"},
{"LOCALSEND_SHELL", "Shell prefix for exec hooks (default: sh -c)"},
{"LOCALSEND_CLIPBOARD_WRITE_CMD", "Custom clipboard write command"},
From e8d051dc3fd2b3eac1bf0087f8811ee161497bea Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:00:51 +0300
Subject: [PATCH 15/22] feat: add Termux/Android support to installer and
GoReleaser
- Add android/arm64 build target to GoReleaser (.goreleaser.yaml)
- Ignore android/amd64 (requires CGo, not supported with CGO_ENABLED=0)
- Update online-install.sh: detect Termux via TERMUX_VERSION / uname -o
- Skip systemd service installation on Android
- Update header and platform detection messages
---
.goreleaser.yaml | 3 +++
scripts/online-install.sh | 19 +++++++++++++------
2 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index d67b978..7912099 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -30,6 +30,7 @@ builds:
- linux
- darwin
- windows
+ - android
goarch:
- amd64
- arm64
@@ -37,6 +38,8 @@ builds:
ignore:
- goos: windows
goarch: arm64
+ - goos: android
+ goarch: amd64
# ---------------------------------------------------------------------------
# Archives
diff --git a/scripts/online-install.sh b/scripts/online-install.sh
index 52e7b3d..3ba72b4 100644
--- a/scripts/online-install.sh
+++ b/scripts/online-install.sh
@@ -2,7 +2,8 @@
#
# LocalGo Online Installer
# Downloads and installs the latest pre-built LocalGo binary from GitHub Releases.
-# No Go toolchain required. Works on Linux (amd64/arm64) and macOS (amd64/arm64).
+# No Go toolchain required. Works on Linux (amd64/arm64), macOS (amd64/arm64),
+# and Android/Termux (arm64).
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/bethropolis/localgo/main/scripts/online-install.sh | bash
@@ -126,11 +127,16 @@ detect_platform() {
os_raw=$(uname -s | tr '[:upper:]' '[:lower:]')
arch_raw=$(uname -m)
- case "$os_raw" in
- linux) OS="linux" ;;
- darwin) OS="darwin" ;;
- *) die "Unsupported OS: $os_raw (expected linux or darwin)" ;;
- esac
+ # Detect Android/Termux
+ if [[ -n "${TERMUX_VERSION:-}" ]] || [[ "$(uname -o 2>/dev/null)" == "Android" ]]; then
+ OS="android"
+ else
+ case "$os_raw" in
+ linux) OS="linux" ;;
+ darwin) OS="darwin" ;;
+ *) die "Unsupported OS: $os_raw (expected linux, darwin, or android)" ;;
+ esac
+ fi
case "$arch_raw" in
x86_64|amd64) ARCH="amd64" ;;
@@ -331,6 +337,7 @@ install_completions() {
install_service() {
header "Installing systemd service..."
+ [[ "$OS" == "android" ]] && { warn "systemd not available on Android/Termux, skipping"; return; }
[[ "$OS" != "linux" ]] && { warn "systemd not available on macOS, skipping"; return; }
command -v systemctl &>/dev/null || { warn "systemctl not found, skipping"; return; }
From 02d70367801c355ea3a7b62cc9ee61622620a888 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Thu, 30 Jul 2026 23:14:09 +0300
Subject: [PATCH 16/22] fix: enable FreeBSD compilation
- Fix Bavail type mismatch in storage_unix.go (int64 on FreeBSD)
- Split notification behind build tags: beeep on !freebsd,
log-based fallback on freebsd (godbus/dbus requires CGo on FreeBSD)
- freebsd/amd64 and freebsd/arm64 both compile with CGO_ENABLED=0
---
pkg/cli/notify.go | 12 ++----------
pkg/cli/notify_beeep.go | 9 +++++++++
pkg/cli/notify_freebsd.go | 9 +++++++++
pkg/storage/storage_unix.go | 2 +-
4 files changed, 21 insertions(+), 11 deletions(-)
create mode 100644 pkg/cli/notify_beeep.go
create mode 100644 pkg/cli/notify_freebsd.go
diff --git a/pkg/cli/notify.go b/pkg/cli/notify.go
index f5b8be4..424a021 100644
--- a/pkg/cli/notify.go
+++ b/pkg/cli/notify.go
@@ -4,21 +4,14 @@ import (
"os"
"os/exec"
"strings"
-
- "github.com/gen2brain/beeep"
)
-// notificationCmd holds a user-configured custom notification command.
var notificationCmd string
-// SetNotificationCmd sets a custom notification command.
-// The command is called with the title and body as the last two arguments.
func SetNotificationCmd(cmd string) {
notificationCmd = cmd
}
-// Notify sends a native desktop notification. Icon is empty (system default).
-// No-op in container environments.
func Notify(title, body string) {
if IsContainer() {
return
@@ -27,14 +20,13 @@ func Notify(title, body string) {
parts := strings.Fields(notificationCmd)
if len(parts) > 0 {
c := exec.Command(parts[0], append(parts[1:], title, body)...)
- c.Run() // best-effort
+ c.Run()
}
return
}
- beeep.Notify(title, body, "")
+ notifyPlatform(title, body)
}
-// IsContainer returns true if LocalGo is running inside a Docker/Podman container.
func IsContainer() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
diff --git a/pkg/cli/notify_beeep.go b/pkg/cli/notify_beeep.go
new file mode 100644
index 0000000..f892e6b
--- /dev/null
+++ b/pkg/cli/notify_beeep.go
@@ -0,0 +1,9 @@
+//go:build !freebsd
+
+package cli
+
+import "github.com/gen2brain/beeep"
+
+func notifyPlatform(title, body string) {
+ beeep.Notify(title, body, "")
+}
diff --git a/pkg/cli/notify_freebsd.go b/pkg/cli/notify_freebsd.go
new file mode 100644
index 0000000..9cca140
--- /dev/null
+++ b/pkg/cli/notify_freebsd.go
@@ -0,0 +1,9 @@
+//go:build freebsd
+
+package cli
+
+import "log"
+
+func notifyPlatform(title, body string) {
+ log.Printf("[notification] %s: %s", title, body)
+}
diff --git a/pkg/storage/storage_unix.go b/pkg/storage/storage_unix.go
index 7455ff4..8eae4e6 100644
--- a/pkg/storage/storage_unix.go
+++ b/pkg/storage/storage_unix.go
@@ -9,5 +9,5 @@ func getAvailableBytes(path string) (uint64, error) {
if err := unix.Statfs(path, &stat); err != nil {
return 0, err
}
- return stat.Bavail * uint64(stat.Bsize), nil
+ return uint64(stat.Bavail) * uint64(stat.Bsize), nil
}
From b806792dab9d9a607d25590c3ea73b0981da20a1 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Thu, 30 Jul 2026 23:41:46 +0300
Subject: [PATCH 17/22] feat: add Termux clipboard support and installer paths
- Add clipboard_android.go: auto-detect termux-clipboard-set/get
- Fix clipboard_unix.go build tag: exclude android (Go sets both
android and linux tags when GOOS=android)
- Update online-install.sh: use /bin on Termux
---
pkg/clipboard/clipboard_android.go | 20 ++++++++++++++++++++
pkg/clipboard/clipboard_unix.go | 2 +-
scripts/online-install.sh | 6 ++++++
3 files changed, 27 insertions(+), 1 deletion(-)
create mode 100644 pkg/clipboard/clipboard_android.go
diff --git a/pkg/clipboard/clipboard_android.go b/pkg/clipboard/clipboard_android.go
new file mode 100644
index 0000000..ae9eddd
--- /dev/null
+++ b/pkg/clipboard/clipboard_android.go
@@ -0,0 +1,20 @@
+//go:build android
+
+package clipboard
+
+import "os/exec"
+
+func detect() *clipProvider {
+ if lookPath("termux-clipboard-set") && lookPath("termux-clipboard-get") {
+ return &clipProvider{
+ cmd: "termux-clipboard-set",
+ readCmd: "termux-clipboard-get",
+ }
+ }
+ return nil
+}
+
+func lookPath(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
diff --git a/pkg/clipboard/clipboard_unix.go b/pkg/clipboard/clipboard_unix.go
index 28c012d..35f6956 100644
--- a/pkg/clipboard/clipboard_unix.go
+++ b/pkg/clipboard/clipboard_unix.go
@@ -1,4 +1,4 @@
-//go:build linux || freebsd
+//go:build (linux && !android) || freebsd
package clipboard
diff --git a/scripts/online-install.sh b/scripts/online-install.sh
index 3ba72b4..27dfccb 100644
--- a/scripts/online-install.sh
+++ b/scripts/online-install.sh
@@ -498,6 +498,12 @@ main() {
&& die "Invalid mode: $INSTALL_MODE (use user or system)"
detect_platform
+ # Termux: use $PREFIX paths instead of ~/.local
+ if [[ "$OS" == "android" && -n "${PREFIX:-}" ]]; then
+ USER_BIN_DIR="$PREFIX/bin"
+ USER_CONFIG_DIR="$PREFIX/etc/localgo"
+ info "Termux environment detected, installing to $PREFIX"
+ fi
resolve_version
print_plan
From 47c88db6bd06e63ebcaf6685354604db70d61d44 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 31 Jul 2026 06:18:19 +0300
Subject: [PATCH 18/22] build: add -trimpath and strip to release build flags
---
.goreleaser.yaml | 2 ++
Makefile | 9 +++++----
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 7912099..b5c2438 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -21,6 +21,8 @@ builds:
binary: localgo
env:
- CGO_ENABLED=0
+ flags:
+ - -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
diff --git a/Makefile b/Makefile
index 6a1effb..7410614 100644
--- a/Makefile
+++ b/Makefile
@@ -17,8 +17,9 @@ GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknow
BUILD_DATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LD_BASE := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildDate=$(BUILD_DATE)
-LDFLAGS := -ldflags "$(LD_BASE)"
-LDFLAGS_STRIP := -ldflags "-s -w $(LD_BASE)"
+LDFLAGS := -ldflags "-s -w $(LD_BASE)"
+LDFLAGS_DEBUG := -ldflags "$(LD_BASE)"
+TRIMFLAGS := -trimpath
# Colour helpers — silently degrade when not a tty
ifeq ($(TERM),)
@@ -53,9 +54,9 @@ all: fmt vet build ## Format, vet, and build
##@ Build
.PHONY: build
-build: ## Build the binary for the current platform
+build: ## Build the binary for the current platform (stripped, release-style)
$(call log,Building $(BINARY_NAME) $(VERSION))
- $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_NAME) $(BUILD_DIR)
+ $(GO) build $(GOFLAGS) $(TRIMFLAGS) $(LDFLAGS) -o $(BINARY_NAME) $(BUILD_DIR)
@printf '%s binary: ./%s%s\n' '$(_GREEN)' '$(BINARY_NAME)' '$(_RESET)'
.PHONY: build-fast
From 9e35831313e6fca42cb08fb0c63c1b7f9be08772 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 31 Jul 2026 06:21:18 +0300
Subject: [PATCH 19/22] refactor: replace gorilla/mux and beeep with stdlib
equivalents
- Swap gorilla/mux Router for net/http.ServeMux with Go 1.22 method patterns
(securityMiddleware now wraps the mux as the server Handler)
- Replace beeep desktop notifications with exec-based fallbacks:
notify-send (unix), osascript (darwin), PowerShell balloon (windows)
- Delete notify_beeep.go and notify_freebsd.go; drop beeep, godbus/dbus,
esiqveland/notify, gorilla/mux and their transitive deps from go.mod
---
go.mod | 11 -----------
go.sum | 23 -----------------------
pkg/cli/notify_beeep.go | 9 ---------
pkg/cli/notify_darwin.go | 25 +++++++++++++++++++++++++
pkg/cli/notify_freebsd.go | 9 ---------
pkg/cli/notify_unix.go | 19 +++++++++++++++++++
pkg/cli/notify_windows.go | 29 +++++++++++++++++++++++++++++
pkg/server/server.go | 34 +++++++++++++++-------------------
8 files changed, 88 insertions(+), 71 deletions(-)
delete mode 100644 pkg/cli/notify_beeep.go
create mode 100644 pkg/cli/notify_darwin.go
delete mode 100644 pkg/cli/notify_freebsd.go
create mode 100644 pkg/cli/notify_unix.go
create mode 100644 pkg/cli/notify_windows.go
diff --git a/go.mod b/go.mod
index 592b69b..7401c23 100644
--- a/go.mod
+++ b/go.mod
@@ -9,9 +9,7 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b
github.com/charmbracelet/lipgloss v1.1.0
- github.com/gen2brain/beeep v0.11.2
github.com/google/uuid v1.6.0
- github.com/gorilla/mux v1.8.1
github.com/jackpal/gateway v1.2.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/spf13/cobra v1.9.1
@@ -24,7 +22,6 @@ require (
)
require (
- git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -40,13 +37,9 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
- github.com/esiqveland/notify v0.13.3 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -57,20 +50,16 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
- github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/sergeymakinen/go-bmp v1.0.0 // indirect
- github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
diff --git a/go.sum b/go.sum
index 89a9f3d..7dc06c3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,3 @@
-git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
-git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
@@ -61,30 +59,18 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
-github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
-github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
-github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
-github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
-github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
-github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
-github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
-github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
github.com/jackpal/gateway v1.2.0 h1:euPRe4t7JfTaqC5Lr78HXl2wSHo54XndTtiAcIxkb5g=
github.com/jackpal/gateway v1.2.0/go.mod h1:/jchvRi4HukAqV24da70iaBMFcSrX3rNWdR5K9VHd0A=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -114,8 +100,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -131,10 +115,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
-github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
-github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
-github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
-github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -161,8 +141,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
-github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/vbauerster/mpb/v7 v7.5.3 h1:BkGfmb6nMrrBQDFECR/Q7RkKCw7ylMetCb4079CGs4w=
github.com/vbauerster/mpb/v7 v7.5.3/go.mod h1:i+h4QY6lmLvBNK2ah1fSreiw3ajskRlBp9AhY/PnuOE=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -179,7 +157,6 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/pkg/cli/notify_beeep.go b/pkg/cli/notify_beeep.go
deleted file mode 100644
index f892e6b..0000000
--- a/pkg/cli/notify_beeep.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !freebsd
-
-package cli
-
-import "github.com/gen2brain/beeep"
-
-func notifyPlatform(title, body string) {
- beeep.Notify(title, body, "")
-}
diff --git a/pkg/cli/notify_darwin.go b/pkg/cli/notify_darwin.go
new file mode 100644
index 0000000..3277d7c
--- /dev/null
+++ b/pkg/cli/notify_darwin.go
@@ -0,0 +1,25 @@
+//go:build darwin
+
+package cli
+
+import (
+ "log"
+ "os/exec"
+ "strings"
+)
+
+// notifyPlatform dispatches notifications via osascript.
+func notifyPlatform(title, body string) {
+ if _, err := exec.LookPath("osascript"); err != nil {
+ log.Printf("[notification] %s: %s", title, body)
+ return
+ }
+ script := "display notification " + shellQuote(body) + " with title " + shellQuote(title)
+ cmd := exec.Command("osascript", "-e", script)
+ cmd.Run()
+}
+
+func shellQuote(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
+}
diff --git a/pkg/cli/notify_freebsd.go b/pkg/cli/notify_freebsd.go
deleted file mode 100644
index 9cca140..0000000
--- a/pkg/cli/notify_freebsd.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build freebsd
-
-package cli
-
-import "log"
-
-func notifyPlatform(title, body string) {
- log.Printf("[notification] %s: %s", title, body)
-}
diff --git a/pkg/cli/notify_unix.go b/pkg/cli/notify_unix.go
new file mode 100644
index 0000000..4fd8181
--- /dev/null
+++ b/pkg/cli/notify_unix.go
@@ -0,0 +1,19 @@
+//go:build !windows && !darwin
+
+package cli
+
+import (
+ "log"
+ "os/exec"
+)
+
+// notifyPlatform dispatches desktop notifications via notify-send.
+// Falls back to logging when no notifier is available.
+func notifyPlatform(title, body string) {
+ if _, err := exec.LookPath("notify-send"); err != nil {
+ log.Printf("[notification] %s: %s", title, body)
+ return
+ }
+ cmd := exec.Command("notify-send", "-a", "localgo", title, body)
+ cmd.Run()
+}
diff --git a/pkg/cli/notify_windows.go b/pkg/cli/notify_windows.go
new file mode 100644
index 0000000..391121c
--- /dev/null
+++ b/pkg/cli/notify_windows.go
@@ -0,0 +1,29 @@
+//go:build windows
+
+package cli
+
+import (
+ "log"
+ "os/exec"
+ "strings"
+)
+
+// notifyPlatform dispatches notifications via a PowerShell balloon tip.
+func notifyPlatform(title, body string) {
+ script := `[reflection.assembly]::loadwithpartialname('System.Windows.Forms')|Out-Null;`
+ script += `$n=New-Object Windows.Forms.NotifyIcon;`
+ script += `$n.Icon=[Drawing.SystemIcons]::Information;`
+ script += `$n.BalloonTipTitle='` + escapePS(title) + `';`
+ script += `$n.BalloonTipText='` + escapePS(body) + `';`
+ script += `$n.Visible=$true;`
+ script += `$n.ShowBalloonTip(5000)`
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script)
+ if err := cmd.Run(); err != nil {
+ log.Printf("[notification] %s: %s", title, body)
+ }
+}
+
+func escapePS(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ return strings.ReplaceAll(s, `'`, `''`)
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index cf4e52f..8835e35 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -20,7 +20,6 @@ import (
"github.com/bethropolis/localgo/pkg/httputil"
"github.com/bethropolis/localgo/pkg/server/handlers"
"github.com/bethropolis/localgo/pkg/server/services"
- "github.com/gorilla/mux"
"go.uber.org/zap"
)
@@ -28,7 +27,7 @@ import (
type Server struct {
config *config.Config
httpServer *http.Server
- muxRouter *mux.Router
+ router *http.ServeMux
receiveService *services.ReceiveService
sendService *services.SendService
registryService *services.RegistryService
@@ -41,14 +40,14 @@ type Server struct {
// NewServer creates a new Server instance.
func NewServer(cfg *config.Config, logger *zap.SugaredLogger) *Server {
httputil.SetLogger(logger)
- router := mux.NewRouter()
+ router := http.NewServeMux()
receiveService := services.NewReceiveService()
sendService := services.NewSendService()
registryService := services.NewRegistryService()
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
return &Server{
config: cfg,
- muxRouter: router,
+ router: router,
receiveService: receiveService,
sendService: sendService,
registryService: registryService,
@@ -86,15 +85,12 @@ func securityMiddleware(next http.Handler) http.Handler {
// configureRoutes sets up the API routes.
func (s *Server) configureRoutes() {
- s.muxRouter.Use(securityMiddleware)
- apiRouter := s.muxRouter.PathPrefix("/api/localsend").Subrouter()
-
// Discovery Handlers (Phase 1)
discoveryHandler := handlers.NewDiscoveryHandler(s.config, s.registryService, s.sendService, s.logger)
- apiRouter.HandleFunc("/v1/info", discoveryHandler.InfoHandler).Methods("GET")
- apiRouter.HandleFunc("/v2/info", discoveryHandler.InfoHandler).Methods("GET")
- apiRouter.HandleFunc("/v1/register", discoveryHandler.RegisterHandler).Methods("POST")
- apiRouter.HandleFunc("/v2/register", discoveryHandler.RegisterHandler).Methods("POST")
+ s.router.HandleFunc("GET /api/localsend/v1/info", discoveryHandler.InfoHandler)
+ s.router.HandleFunc("GET /api/localsend/v2/info", discoveryHandler.InfoHandler)
+ s.router.HandleFunc("POST /api/localsend/v1/register", discoveryHandler.RegisterHandler)
+ s.router.HandleFunc("POST /api/localsend/v2/register", discoveryHandler.RegisterHandler)
// Receive Handlers (Phase 2)
path := s.config.HistoryFile
@@ -112,10 +108,10 @@ func (s *Server) configureRoutes() {
}
receiveHandler := handlers.NewReceiveHandler(s.config, s.receiveService, s.historyLog, s.shutdownCtx, s.logger)
- apiRouter.HandleFunc("/v1/prepare-upload", receiveHandler.PrepareUploadHandlerV1).Methods("POST")
- apiRouter.HandleFunc("/v2/prepare-upload", receiveHandler.PrepareUploadHandlerV2).Methods("POST")
- apiRouter.HandleFunc("/v2/upload", receiveHandler.UploadHandlerV2).Methods("POST")
- apiRouter.HandleFunc("/v2/cancel", receiveHandler.CancelHandler).Methods("POST")
+ s.router.HandleFunc("POST /api/localsend/v1/prepare-upload", receiveHandler.PrepareUploadHandlerV1)
+ s.router.HandleFunc("POST /api/localsend/v2/prepare-upload", receiveHandler.PrepareUploadHandlerV2)
+ s.router.HandleFunc("POST /api/localsend/v2/upload", receiveHandler.UploadHandlerV2)
+ s.router.HandleFunc("POST /api/localsend/v2/cancel", receiveHandler.CancelHandler)
// Download Handlers
downloadHandler := handlers.NewDownloadHandler(s.config, s.sendService, s.logger)
@@ -127,11 +123,11 @@ func (s *Server) configureRoutes() {
}
}()
})
- apiRouter.HandleFunc("/v2/prepare-download", downloadHandler.PrepareDownloadHandler).Methods("POST")
- apiRouter.HandleFunc("/v2/download", downloadHandler.DownloadHandler).Methods("GET")
+ s.router.HandleFunc("POST /api/localsend/v2/prepare-download", downloadHandler.PrepareDownloadHandler)
+ s.router.HandleFunc("GET /api/localsend/v2/download", downloadHandler.DownloadHandler)
// Root web landing page for browser access (fixes 404 on http://IP:PORT)
- s.muxRouter.HandleFunc("/", downloadHandler.WebShareHandler).Methods("GET")
+ s.router.HandleFunc("GET /", downloadHandler.WebShareHandler)
s.logger.Info("Configured API routes.")
}
@@ -148,7 +144,7 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error {
addr := fmt.Sprintf("%s:%d", bindHost, s.config.Port)
s.httpServer = &http.Server{
Addr: addr,
- Handler: s.muxRouter,
+ Handler: securityMiddleware(s.router),
ReadTimeout: 0, // body timeout handled by MaxBytesReader / LimitReader
WriteTimeout: 300 * time.Second,
ReadHeaderTimeout: 30 * time.Second,
From 555c2b193acfba5ffd362b698ecab12739db6ec7 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 31 Jul 2026 06:24:56 +0300
Subject: [PATCH 20/22] refactor: replace zap with log/slog via logging.Logger
wrapper
- Rewrite pkg/logging on log/slog with a Logger type mirroring the
zap SugaredLogger surface (Infof/Warnf/Errorf/Debugf/Info/Warn/Error/
Debug/Infow/Warnw/Errorw/Debugw) so call sites only need type renames
- Add multiHandler fan-out (file + colored console) and a compact
consoleHandler rendering "15:04:05 LEVEL message"
- Mechanical rename across 31 files: *zap.SugaredLogger -> *logging.Logger,
zap.S() -> logging.Global(), zap.NewNop().Sugar() -> logging.NewQuiet()
- httputil/response.go: zap.L()/zap.Error() fallback -> logging.Global()
- Drop go.uber.org/zap and go.uber.org/multierr from go.mod
---
cmd/localgo/cmd/discover.go | 16 +-
cmd/localgo/cmd/root.go | 3 +-
cmd/localgo/cmd/scan.go | 8 +-
cmd/localgo/cmd/send.go | 12 +-
cmd/localgo/cmd/serve.go | 26 +-
cmd/localgo/cmd/share.go | 12 +-
pkg/config/config.go | 18 +-
pkg/config/config_test.go | 4 +-
pkg/crypto/crypto.go | 8 +-
pkg/crypto/crypto_test.go | 4 +-
pkg/discovery/http_discovery.go | 8 +-
pkg/discovery/multicast.go | 8 +-
pkg/discovery/multicast_test.go | 4 +-
pkg/discovery/peercache.go | 10 +-
pkg/discovery/peercache_test.go | 14 +-
pkg/discovery/service.go | 8 +-
pkg/discovery/service_test.go | 4 +-
pkg/httputil/response.go | 10 +-
pkg/logging/logging.go | 255 ++++++++++++------
pkg/send/send.go | 10 +-
pkg/send/send_error_test.go | 4 +-
pkg/send/send_test.go | 4 +-
pkg/send/upload.go | 10 +-
pkg/server/handlers/discovery_handlers.go | 6 +-
.../handlers/discovery_handlers_test.go | 4 +-
pkg/server/handlers/download_handlers.go | 6 +-
pkg/server/handlers/download_handlers_test.go | 4 +-
pkg/server/handlers/receive_handlers.go | 6 +-
pkg/server/handlers/receive_handlers_test.go | 4 +-
pkg/server/server.go | 6 +-
pkg/storage/storage.go | 4 +-
pkg/storage/storage_test.go | 4 +-
32 files changed, 292 insertions(+), 212 deletions(-)
diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go
index 39724f0..cab20f9 100644
--- a/cmd/localgo/cmd/discover.go
+++ b/cmd/localgo/cmd/discover.go
@@ -14,7 +14,7 @@ import (
"github.com/bethropolis/localgo/pkg/network"
"github.com/charmbracelet/huh/spinner"
"github.com/spf13/cobra"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
var (
@@ -48,12 +48,12 @@ var discoverCmd = &cobra.Command{
discoverySvcConfig.MulticastConfig.InterfaceName = Cfg.MulticastInterface
multicastDto := Cfg.ToMulticastDto(false)
- multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, zap.S())
+ multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, logging.Global())
- peerCache := discovery.NewPeerCache(zap.S())
+ peerCache := discovery.NewPeerCache(logging.Global())
multicast.SetPeerCache(peerCache)
- discoverySvc := discovery.NewService(discoverySvcConfig, multicast, zap.S())
+ discoverySvc := discovery.NewService(discoverySvcConfig, multicast, logging.Global())
discoverySvc.SetPeerCache(peerCache)
discoverySvc.AddDeviceHandler(func(device *model.Device) {
@@ -62,7 +62,7 @@ var discoverCmd = &cobra.Command{
if Cfg.Private {
alias = cli.AnonymizedAlias(device)
}
- zap.S().Infof("Found: %s (%s) [%s] Port: %d", alias, device.IP, device.Protocol, device.Port)
+ logging.Global().Infof("Found: %s (%s) [%s] Port: %d", alias, device.IP, device.Protocol, device.Port)
cli.PrintSuccess("Found: %s (%s) [%s] Port: %d", alias, device.IP, device.Protocol, device.Port)
}
})
@@ -86,7 +86,7 @@ var discoverCmd = &cobra.Command{
}
if discErr != nil && !discoverquiet {
- zap.S().Warnf("Discovery completed with warnings: %v", discErr)
+ logging.Global().Warnf("Discovery completed with warnings: %v", discErr)
cli.PrintWarning("Discovery completed with warnings: %v", discErr)
}
@@ -105,7 +105,7 @@ var discoverCmd = &cobra.Command{
}
}
registerDto := Cfg.ToRegisterDto()
- httpDiscoverer := discovery.NewHTTPDiscovery(nil, registerDto, nil, zap.S())
+ httpDiscoverer := discovery.NewHTTPDiscovery(nil, registerDto, nil, logging.Global())
scanCtx, scanCancel := context.WithTimeout(context.Background(), time.Duration(discovertimeout)*time.Second)
defer scanCancel()
@@ -134,7 +134,7 @@ var discoverCmd = &cobra.Command{
}
if !discoverquiet && len(foundDevices) == 0 {
- zap.S().Warnf("No devices discovered")
+ logging.Global().Warnf("No devices discovered")
cli.PrintWarning("No devices discovered. Check your firewall or network.")
}
diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go
index 2921bf8..571efbe 100644
--- a/cmd/localgo/cmd/root.go
+++ b/cmd/localgo/cmd/root.go
@@ -11,7 +11,6 @@ import (
"github.com/bethropolis/localgo/pkg/logging"
"github.com/spf13/cobra"
"github.com/spf13/viper"
- "go.uber.org/zap"
)
var (
@@ -54,7 +53,7 @@ var rootCmd = &cobra.Command{
if cfgFile != "" {
ViperCfg.SetConfigFile(cfgFile)
if err := ViperCfg.ReadInConfig(); err != nil {
- zap.S().Warnf("Failed to read config file: %v", err)
+ logging.Global().Warnf("Failed to read config file: %v", err)
}
}
diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go
index adcbd76..9e5a1b1 100644
--- a/cmd/localgo/cmd/scan.go
+++ b/cmd/localgo/cmd/scan.go
@@ -10,11 +10,11 @@ import (
"github.com/bethropolis/localgo/pkg/cli"
"github.com/bethropolis/localgo/pkg/discovery"
"github.com/bethropolis/localgo/pkg/help"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/network"
"github.com/charmbracelet/huh/spinner"
"github.com/spf13/cobra"
- "go.uber.org/zap"
)
var (
@@ -80,7 +80,7 @@ var scanCmd = &cobra.Command{
}
// Initialize HTTP discovery
- httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S())
+ httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, logging.Global())
// Perform scan
scanCtx, cancel := context.WithTimeout(context.Background(), time.Duration(scantimeout)*time.Second)
@@ -101,7 +101,7 @@ var scanCmd = &cobra.Command{
}
if scanErr != nil && !scanquiet {
- zap.S().Warnf("Scan completed with warnings: %v", scanErr)
+ logging.Global().Warnf("Scan completed with warnings: %v", scanErr)
cli.PrintWarning("Scan completed with warnings: %v", scanErr)
}
@@ -117,7 +117,7 @@ var scanCmd = &cobra.Command{
})
if !scanquiet && len(foundDevices) == 0 {
- zap.S().Warnf("No devices found during scan")
+ logging.Global().Warnf("No devices found during scan")
cli.PrintWarning("No devices found during scan. Check your firewall or network.")
}
diff --git a/cmd/localgo/cmd/send.go b/cmd/localgo/cmd/send.go
index cc296cf..d236c00 100644
--- a/cmd/localgo/cmd/send.go
+++ b/cmd/localgo/cmd/send.go
@@ -20,7 +20,7 @@ import (
"github.com/bethropolis/localgo/pkg/send"
"github.com/charmbracelet/huh/spinner"
"github.com/spf13/cobra"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
var (
@@ -162,19 +162,19 @@ var sendCmd = &cobra.Command{
// TOFU check: verify cached fingerprint matches before connecting
if device.Fingerprint != "" {
- pc := discovery.NewPeerCache(zap.S())
+ pc := discovery.NewPeerCache(logging.Global())
if err := send.VerifyDeviceFingerprint(pc, device); err != nil {
return err
}
}
- if err := send.SendToDevice(ctx, Cfg, device, files, zap.S(), sendOpts...); err != nil {
+ if err := send.SendToDevice(ctx, Cfg, device, files, logging.Global(), sendOpts...); err != nil {
return fmt.Errorf("failed to send files: %w", err)
}
// Save fingerprint for TOFU on subsequent connections
if device.Fingerprint != "" {
- pc := discovery.NewPeerCache(zap.S())
+ pc := discovery.NewPeerCache(logging.Global())
pc.Save(device)
}
@@ -294,11 +294,11 @@ var sendCmd = &cobra.Command{
if selectedDevice != nil {
cli.PrintInfo("To: %s (%s:%d)", selectedDevice.Alias, selectedDevice.IP, selectedDevice.Port)
cli.PrintInfo("From: %s", fromAlias)
- err = send.SendToDevice(ctx, Cfg, selectedDevice, files, zap.S(), sendOpts...)
+ err = send.SendToDevice(ctx, Cfg, selectedDevice, files, logging.Global(), sendOpts...)
} else {
cli.PrintInfo("To: %s", target)
cli.PrintInfo("From: %s", fromAlias)
- err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S(), sendOpts...)
+ err = send.SendFiles(ctx, Cfg, files, target, sendport, logging.Global(), sendOpts...)
}
if err != nil {
return fmt.Errorf("failed to send files: %w", err)
diff --git a/cmd/localgo/cmd/serve.go b/cmd/localgo/cmd/serve.go
index a3f3560..b62622b 100644
--- a/cmd/localgo/cmd/serve.go
+++ b/cmd/localgo/cmd/serve.go
@@ -15,7 +15,7 @@ import (
"github.com/bethropolis/localgo/pkg/network"
"github.com/bethropolis/localgo/pkg/server"
"github.com/spf13/cobra"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
var (
@@ -112,9 +112,9 @@ var serveCmd = &cobra.Command{
displayAlias = "Anonymous"
}
- zap.S().Infof("Starting LocalGo server")
- zap.S().Infof("Alias: %s", displayAlias)
- zap.S().Infof("Protocol: %s", protocol)
+ logging.Global().Infof("Starting LocalGo server")
+ logging.Global().Infof("Alias: %s", displayAlias)
+ logging.Global().Infof("Protocol: %s", protocol)
if !servequiet {
cli.PrintHeader("Starting LocalGo server")
@@ -133,7 +133,7 @@ var serveCmd = &cobra.Command{
defer stop()
// Start server first to determine the actual port
- srv := server.NewServer(Cfg, zap.S())
+ srv := server.NewServer(Cfg, logging.Global())
serverErrChan := make(chan error, 1)
serverReadyChan := make(chan struct{}, 1)
@@ -159,16 +159,16 @@ var serveCmd = &cobra.Command{
discoverySvcConfig.AnnounceInterval = time.Duration(serveinterval) * time.Second
}
- multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, Cfg.ToMulticastDto(false), zap.S())
+ multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, Cfg.ToMulticastDto(false), logging.Global())
// Create HTTPDiscoverer for backchannel (HTTP response to multicast)
- httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S())
+ httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, logging.Global())
multicast.SetHTTPDiscoverer(httpDiscoverer)
- peerCache := discovery.NewPeerCache(zap.S())
+ peerCache := discovery.NewPeerCache(logging.Global())
multicast.SetPeerCache(peerCache)
- discoverySvc := discovery.NewService(discoverySvcConfig, multicast, zap.S())
+ discoverySvc := discovery.NewService(discoverySvcConfig, multicast, logging.Global())
discoverySvc.SetPeerCache(peerCache)
discoverySvc.AddDeviceHandler(func(device *model.Device) {
@@ -177,7 +177,7 @@ var serveCmd = &cobra.Command{
if Cfg.Private {
alias = cli.AnonymizedAlias(device)
}
- zap.S().Infof("Device discovered: %s (%s)", alias, device.IP)
+ logging.Global().Infof("Device discovered: %s (%s)", alias, device.IP)
cli.PrintSuccess("Device discovered: %s (%s)", alias, device.IP)
}
})
@@ -189,7 +189,7 @@ var serveCmd = &cobra.Command{
}
if !servequiet {
- zap.S().Infof("Server ready! Waiting for files...")
+ logging.Global().Infof("Server ready! Waiting for files...")
cli.PrintSuccess("Server ready! Waiting for files...")
localIPs, err := network.GetLocalIPAddresses()
@@ -215,9 +215,9 @@ var serveCmd = &cobra.Command{
discoverySvc.Stop()
if servequiet {
- zap.S().Infof("Server stopped")
+ logging.Global().Infof("Server stopped")
} else {
- zap.S().Infof("Server stopped")
+ logging.Global().Infof("Server stopped")
cli.PrintInfo("Server stopped")
}
return nil
diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go
index f1b5936..61d2aee 100644
--- a/cmd/localgo/cmd/share.go
+++ b/cmd/localgo/cmd/share.go
@@ -20,7 +20,7 @@ import (
"github.com/google/uuid"
"github.com/mdp/qrterminal/v3"
"github.com/spf13/cobra"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
var (
@@ -192,7 +192,7 @@ var shareCmd = &cobra.Command{
}()
// Create server
- srv := server.NewServer(Cfg, zap.S())
+ srv := server.NewServer(Cfg, logging.Global())
sendService := srv.GetSendService()
// Register files in session
@@ -225,14 +225,14 @@ var shareCmd = &cobra.Command{
discoverySvcConfig.MulticastConfig.InterfaceName = Cfg.MulticastInterface
multicastDto := Cfg.ToMulticastDto(true)
- multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, zap.S())
- httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S())
+ multicast := discovery.NewMulticastDiscovery(discoverySvcConfig.MulticastConfig, multicastDto, logging.Global())
+ httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, logging.Global())
multicast.SetHTTPDiscoverer(httpDiscoverer)
- peerCache := discovery.NewPeerCache(zap.S())
+ peerCache := discovery.NewPeerCache(logging.Global())
multicast.SetPeerCache(peerCache)
- discoverySvc := discovery.NewService(discoverySvcConfig, multicast, zap.S())
+ discoverySvc := discovery.NewService(discoverySvcConfig, multicast, logging.Global())
discoverySvc.SetPeerCache(peerCache)
// Start discovery AFTER server is ready
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 7650d73..70287f8 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -12,7 +12,7 @@ import (
"github.com/bethropolis/localgo/pkg/crypto"
"github.com/bethropolis/localgo/pkg/model"
"github.com/spf13/viper"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
const (
@@ -70,13 +70,13 @@ func (c *Config) SetCustomFingerprint(fp string) {
// getSecurityDir determines the best location for the security directory
func getSecurityDir(v *viper.Viper) string {
if envDir := v.GetString("security_dir"); envDir != "" {
- zap.S().Infof("Using security directory: %s", envDir)
+ logging.Global().Infof("Using security directory: %s", envDir)
return envDir
}
configDir, err := os.UserConfigDir()
if err != nil {
- zap.S().Warnf("Could not determine config directory: %v; falling back to current directory", err)
+ logging.Global().Warnf("Could not determine config directory: %v; falling back to current directory", err)
return DefaultSecurityDir
}
@@ -110,7 +110,7 @@ func testDirWritable(dir string) bool {
return true
}
-func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
+func LoadConfig(v *viper.Viper, logger *logging.Logger) (*Config, error) {
if v == nil {
v = InitViper()
}
@@ -149,7 +149,7 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
if size, err := strconv.ParseInt(maxBodySizeStr, 10, 64); err == nil {
maxBodySize = size
} else {
- zap.S().Warnf("Invalid LOCALSEND_MAX_BODY_SIZE value: %s, using default", maxBodySizeStr)
+ logging.Global().Warnf("Invalid LOCALSEND_MAX_BODY_SIZE value: %s, using default", maxBodySizeStr)
}
}
@@ -162,16 +162,16 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
securityContext, err := crypto.LoadSecurityContext(securityFilePath, logger)
if err != nil {
if os.IsNotExist(err) {
- zap.S().Infof("Security context not found at %s, generating new one...", securityFilePath)
+ logging.Global().Infof("Security context not found at %s, generating new one...", securityFilePath)
securityContext, err = crypto.GenerateSecurityContext(alias, logger)
if err != nil {
return nil, fmt.Errorf("failed to generate security context: %w", err)
}
if err := os.MkdirAll(securityDirPath, 0700); err != nil {
- zap.S().Warnf("Could not create security directory '%s': %v", securityDirPath, err)
+ logging.Global().Warnf("Could not create security directory '%s': %v", securityDirPath, err)
}
if err := crypto.SaveSecurityContext(securityContext, securityFilePath, logger); err != nil {
- zap.S().Warnf("failed to save newly generated security context to '%s': %v", securityFilePath, err)
+ logging.Global().Warnf("failed to save newly generated security context to '%s': %v", securityFilePath, err)
}
} else {
return nil, fmt.Errorf("failed to load security context from '%s': %w", securityFilePath, err)
@@ -257,7 +257,7 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*Config, error) {
func generateDefaultAlias() string {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
- zap.S().Infow("Could not get hostname, generating random alias suffix.")
+ logging.Global().Infow("Could not get hostname, generating random alias suffix.")
hostname = "LocalGo"
}
return hostname
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 1495262..5877faa 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -4,12 +4,12 @@ import (
"os"
"testing"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/spf13/viper"
- "go.uber.org/zap"
)
-var testLogger = zap.NewNop().Sugar()
+var testLogger = logging.NewQuiet()
func TestLoadConfig_WithEnvVars(t *testing.T) {
origEnv := saveEnv()
diff --git a/pkg/crypto/crypto.go b/pkg/crypto/crypto.go
index d955f04..a2488ef 100644
--- a/pkg/crypto/crypto.go
+++ b/pkg/crypto/crypto.go
@@ -14,7 +14,7 @@ import (
"os"
"time"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// StoredSecurityContext holds PEM-encoded cert/key for config/server
@@ -76,7 +76,7 @@ func calculateCertificateHash(certBytes []byte) string {
}
// GenerateSecurityContext creates a new security context with keys and a self-signed certificate.
-func GenerateSecurityContext(alias string, logger *zap.SugaredLogger) (*StoredSecurityContext, error) {
+func GenerateSecurityContext(alias string, logger *logging.Logger) (*StoredSecurityContext, error) {
privKey, err := generateKeys()
if err != nil {
return nil, fmt.Errorf("failed to generate RSA keys: %w", err)
@@ -98,7 +98,7 @@ func GenerateSecurityContext(alias string, logger *zap.SugaredLogger) (*StoredSe
}
// SaveSecurityContext saves the context as JSON to the specified path.
-func SaveSecurityContext(ctx *StoredSecurityContext, path string, logger *zap.SugaredLogger) error {
+func SaveSecurityContext(ctx *StoredSecurityContext, path string, logger *logging.Logger) error {
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create security context file '%s': %w", path, err)
@@ -116,7 +116,7 @@ func SaveSecurityContext(ctx *StoredSecurityContext, path string, logger *zap.Su
}
// LoadSecurityContext loads the context from JSON from the specified path.
-func LoadSecurityContext(path string, logger *zap.SugaredLogger) (*StoredSecurityContext, error) {
+func LoadSecurityContext(path string, logger *logging.Logger) (*StoredSecurityContext, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
diff --git a/pkg/crypto/crypto_test.go b/pkg/crypto/crypto_test.go
index f17f93e..019b303 100644
--- a/pkg/crypto/crypto_test.go
+++ b/pkg/crypto/crypto_test.go
@@ -6,10 +6,10 @@ import (
"path/filepath"
"testing"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
-var testLogger = zap.NewNop().Sugar()
+var testLogger = logging.NewQuiet()
func TestGenerateSecurityContext(t *testing.T) {
ctx, err := GenerateSecurityContext("test-device", testLogger)
diff --git a/pkg/discovery/http_discovery.go b/pkg/discovery/http_discovery.go
index 76077db..27c3130 100644
--- a/pkg/discovery/http_discovery.go
+++ b/pkg/discovery/http_discovery.go
@@ -14,9 +14,9 @@ import (
"sync"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/network"
- "go.uber.org/zap"
)
type HTTPDiscoveryConfig struct {
@@ -34,15 +34,15 @@ type HTTPDiscovery struct {
dto model.RegisterDto
client *http.Client
deviceHandler func(*model.Device)
- logger *zap.SugaredLogger
+ logger *logging.Logger
}
-func NewHTTPDiscovery(config *HTTPDiscoveryConfig, dto model.RegisterDto, handler func(*model.Device), logger *zap.SugaredLogger) *HTTPDiscovery {
+func NewHTTPDiscovery(config *HTTPDiscoveryConfig, dto model.RegisterDto, handler func(*model.Device), logger *logging.Logger) *HTTPDiscovery {
if config == nil {
config = DefaultHTTPDiscoveryConfig()
}
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
client := &http.Client{
diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go
index e91df8a..8f572f2 100644
--- a/pkg/discovery/multicast.go
+++ b/pkg/discovery/multicast.go
@@ -8,8 +8,8 @@ import (
"sync"
"sync/atomic"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
// MulticastDiscovery implements UDP multicast-based device discovery
@@ -25,16 +25,16 @@ type MulticastDiscovery struct {
closed atomic.Bool
httpDiscoverer *HTTPDiscovery
peerCache *PeerCache
- logger *zap.SugaredLogger
+ logger *logging.Logger
}
// NewMulticastDiscovery creates a new multicast discovery instance
-func NewMulticastDiscovery(config *MulticastConfig, dto model.MulticastDto, logger *zap.SugaredLogger) *MulticastDiscovery {
+func NewMulticastDiscovery(config *MulticastConfig, dto model.MulticastDto, logger *logging.Logger) *MulticastDiscovery {
if config == nil {
config = DefaultMulticastConfig()
}
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
return &MulticastDiscovery{
diff --git a/pkg/discovery/multicast_test.go b/pkg/discovery/multicast_test.go
index 76e0788..9fb2706 100644
--- a/pkg/discovery/multicast_test.go
+++ b/pkg/discovery/multicast_test.go
@@ -8,11 +8,11 @@ import (
"testing"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
-var testLoggerMulticast = zap.NewNop().Sugar()
+var testLoggerMulticast = logging.NewQuiet()
// We use a different multicast address for testing to avoid conflicting with actual apps
const testMulticastAddr = "224.0.0.254:53318"
diff --git a/pkg/discovery/peercache.go b/pkg/discovery/peercache.go
index 228430b..b4ad7f6 100644
--- a/pkg/discovery/peercache.go
+++ b/pkg/discovery/peercache.go
@@ -13,7 +13,7 @@ import (
"time"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// MaxCachedPeers is the maximum number of peers to keep in cache.
@@ -28,13 +28,13 @@ type PeerCache struct {
filePath string
peers map[string]*model.Device
order []string // LRU order (most recent at end)
- logger *zap.SugaredLogger
+ logger *logging.Logger
}
// NewPeerCache creates or loads a peer cache from the XDG cache directory.
-func NewPeerCache(logger *zap.SugaredLogger) *PeerCache {
+func NewPeerCache(logger *logging.Logger) *PeerCache {
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
cacheDir, err := os.UserCacheDir()
@@ -226,7 +226,7 @@ func (pc *PeerCache) persist() error {
// ProbeCached pings each cached peer with GET /api/localsend/v2/info
// and calls onFound for every peer that responds.
-func ProbeCached(ctx context.Context, cache *PeerCache, onFound func(*model.Device), logger *zap.SugaredLogger) {
+func ProbeCached(ctx context.Context, cache *PeerCache, onFound func(*model.Device), logger *logging.Logger) {
if cache == nil {
return
}
diff --git a/pkg/discovery/peercache_test.go b/pkg/discovery/peercache_test.go
index 077dd96..72e0468 100644
--- a/pkg/discovery/peercache_test.go
+++ b/pkg/discovery/peercache_test.go
@@ -6,9 +6,9 @@ import (
"testing"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/stretchr/testify/assert"
- "go.uber.org/zap"
)
func TestPeerCache_SaveAndGetPeers(t *testing.T) {
@@ -18,7 +18,7 @@ func TestPeerCache_SaveAndGetPeers(t *testing.T) {
pc := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
device := &model.Device{
@@ -36,7 +36,7 @@ func TestPeerCache_SaveAndGetPeers(t *testing.T) {
pc2 := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
pc2.load()
@@ -55,7 +55,7 @@ func TestPeerCache_UpdateExisting(t *testing.T) {
pc := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
device := &model.Device{
@@ -93,7 +93,7 @@ func TestPeerCache_LoadCorruptedFile(t *testing.T) {
pc := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
pc.load()
@@ -107,7 +107,7 @@ func TestPeerCache_LoadMissingFile(t *testing.T) {
pc := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
// Should not panic or error
@@ -122,7 +122,7 @@ func TestPeerCache_ConcurrentSave(t *testing.T) {
pc := &PeerCache{
filePath: cachePath,
peers: make(map[string]*model.Device),
- logger: zap.NewNop().Sugar(),
+ logger: logging.NewQuiet(),
}
done := make(chan struct{})
diff --git a/pkg/discovery/service.go b/pkg/discovery/service.go
index 976c210..63f8fcc 100644
--- a/pkg/discovery/service.go
+++ b/pkg/discovery/service.go
@@ -7,8 +7,8 @@ import (
"sync"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
// Service coordinates different discovery mechanisms
@@ -23,7 +23,7 @@ type Service struct {
peerCache *PeerCache
stopCh chan struct{}
stopOnce sync.Once
- logger *zap.SugaredLogger
+ logger *logging.Logger
}
// ServiceConfig contains settings for the discovery service
@@ -45,12 +45,12 @@ func DefaultServiceConfig() *ServiceConfig {
}
// NewService creates a new discovery service
-func NewService(config *ServiceConfig, multicast MulticastDiscoverer, logger *zap.SugaredLogger) *Service {
+func NewService(config *ServiceConfig, multicast MulticastDiscoverer, logger *logging.Logger) *Service {
if config == nil {
config = DefaultServiceConfig()
}
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
s := &Service{
diff --git a/pkg/discovery/service_test.go b/pkg/discovery/service_test.go
index 376a18e..61065e5 100644
--- a/pkg/discovery/service_test.go
+++ b/pkg/discovery/service_test.go
@@ -5,12 +5,12 @@ import (
"testing"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/stretchr/testify/assert"
- "go.uber.org/zap"
)
-var testLoggerService = zap.NewNop().Sugar()
+var testLoggerService = logging.NewQuiet()
// MockMulticastDiscovery is a mock implementation of the MulticastDiscovery for testing.
diff --git a/pkg/httputil/response.go b/pkg/httputil/response.go
index fdb4e29..ff9c4e0 100644
--- a/pkg/httputil/response.go
+++ b/pkg/httputil/response.go
@@ -5,16 +5,16 @@ import (
"encoding/json"
"net/http"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// logger is an optional package-level logger set via SetLogger.
-// Falls back to the global zap logger when nil.
-var logger *zap.SugaredLogger
+// Falls back to the global logging logger when nil.
+var logger *logging.Logger
// SetLogger configures the package-level logger used by httputil helpers.
// Call this once at server startup with the same logger used by handlers.
-func SetLogger(l *zap.SugaredLogger) {
+func SetLogger(l *logging.Logger) {
logger = l
}
@@ -22,7 +22,7 @@ func logError(msg string, err error) {
if logger != nil {
logger.Errorw(msg, "error", err)
} else {
- zap.L().Error(msg, zap.Error(err))
+ logging.Global().Errorf("%s: %v", msg, err)
}
}
diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go
index 449a145..2ff5b50 100644
--- a/pkg/logging/logging.go
+++ b/pkg/logging/logging.go
@@ -1,16 +1,21 @@
+// Package logging provides LocalGo's structured logging wrapper around
+// log/slog. It exposes the same printf-style surface as the previous zap
+// sugared logger so call sites only depend on this package.
package logging
import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
"os"
"path/filepath"
-
- "go.uber.org/zap"
- "go.uber.org/zap/zapcore"
+ "strings"
)
var (
- globalLogger *zap.Logger
- globalSugar *zap.SugaredLogger
+ globalLogger *Logger
+ globalSugar *Logger
)
// ANSI colour codes
@@ -19,41 +24,23 @@ const (
colourRed = "\033[31m"
colourYellow = "\033[33m"
colourCyan = "\033[36m"
- colourWhite = "\033[37m"
- colourBold = "\033[1m"
colourGrey = "\033[90m"
)
-func colourLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
- switch l {
- case zapcore.DebugLevel:
- enc.AppendString(colourGrey + "DBG" + colourReset)
- case zapcore.InfoLevel:
- enc.AppendString(colourCyan + "INF" + colourReset)
- case zapcore.WarnLevel:
- enc.AppendString(colourYellow + "WRN" + colourReset)
- case zapcore.ErrorLevel:
- enc.AppendString(colourRed + "ERR" + colourReset)
- case zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
- enc.AppendString(colourBold + colourRed + "FTL" + colourReset)
- default:
- enc.AppendString(l.CapitalString())
- }
+// Logger wraps *slog.Logger and mirrors the zap SugaredLogger method surface.
+type Logger struct {
+ l *slog.Logger
}
-func timeEncoder(t zapcore.TimeEncoder) zapcore.TimeEncoder {
- return t
-}
-
-// Init initialises the global zap logger.
+// Init initialises the global slog logger.
//
-// - verbose: enable debug-level output
+// - verbose: enable debug-level output and also log to stdout
// - jsonFmt: output newline-delimited JSON instead of human-readable text
// - noColor: disable ANSI color escape sequences in log output
-func Init(verbose, jsonFmt, noColor bool) *zap.SugaredLogger {
- level := zapcore.InfoLevel
+func Init(verbose, jsonFmt, noColor bool) *Logger {
+ level := slog.LevelInfo
if verbose {
- level = zapcore.DebugLevel
+ level = slog.LevelDebug
}
stateDir := ""
@@ -63,86 +50,180 @@ func Init(verbose, jsonFmt, noColor bool) *zap.SugaredLogger {
stateDir = filepath.Join(home, ".local", "state", "localgo")
}
- var fileWs zapcore.WriteSyncer
+ var fileWs io.Writer
if stateDir != "" {
os.MkdirAll(stateDir, 0700)
logPath := filepath.Join(stateDir, "app.log")
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
- fileWs = zapcore.Lock(f)
+ fileWs = f
}
}
if fileWs == nil {
- fileWs = zapcore.AddSync(os.Stderr)
+ fileWs = os.Stderr
}
- var fileEnc zapcore.Encoder
+ opts := &slog.HandlerOptions{Level: level}
if jsonFmt {
- encCfg := zap.NewProductionEncoderConfig()
- encCfg.TimeKey = "time"
- encCfg.EncodeTime = zapcore.ISO8601TimeEncoder
- encCfg.EncodeLevel = zapcore.LowercaseLevelEncoder
- fileEnc = zapcore.NewJSONEncoder(encCfg)
- } else {
- encCfg := zap.NewProductionEncoderConfig()
- encCfg.EncodeTime = zapcore.ISO8601TimeEncoder
- fileEnc = zapcore.NewConsoleEncoder(encCfg)
- }
-
- fileCore := zapcore.NewCore(fileEnc, fileWs, level)
-
- var core zapcore.Core
- if verbose {
- // Also log to stdout
- levelEnc := zapcore.LevelEncoder(colourLevelEncoder)
- if noColor {
- levelEnc = zapcore.CapitalLevelEncoder
+ opts.ReplaceAttr = func(_ []string, a slog.Attr) slog.Attr {
+ if a.Key == slog.LevelKey {
+ if l, ok := a.Value.Any().(slog.Level); ok {
+ a.Value = slog.StringValue(strings.ToLower(l.String()))
+ }
+ }
+ return a
}
- stdoutEncCfg := zapcore.EncoderConfig{
- TimeKey: "T",
- LevelKey: "L",
- NameKey: "N",
- CallerKey: "C",
- MessageKey: "M",
- StacktraceKey: "S",
- LineEnding: zapcore.DefaultLineEnding,
- EncodeLevel: levelEnc,
- EncodeTime: zapcore.TimeEncoderOfLayout("15:04:05"),
- EncodeDuration: zapcore.StringDurationEncoder,
- EncodeCaller: zapcore.ShortCallerEncoder,
- ConsoleSeparator: " ",
+ fileHandler := slog.NewJSONHandler(fileWs, opts)
+ if verbose {
+ consoleHandler := newConsoleHandler(level, noColor)
+ return setGlobal(slog.New(multiHandler{handlers: []slog.Handler{fileHandler, consoleHandler}}))
}
- stdoutEnc := zapcore.NewConsoleEncoder(stdoutEncCfg)
- stdoutCore := zapcore.NewCore(stdoutEnc, zapcore.Lock(os.Stdout), level)
- core = zapcore.NewTee(fileCore, stdoutCore)
- } else {
- core = fileCore
+ return setGlobal(slog.New(fileHandler))
}
- opts := []zap.Option{zap.AddCaller(), zap.AddCallerSkip(0)}
+ fileHandler := slog.NewTextHandler(fileWs, opts)
if verbose {
- opts = append(opts, zap.AddStacktrace(zapcore.ErrorLevel))
- } else {
- opts = []zap.Option{} // Minimal options for non-verbose
+ consoleHandler := newConsoleHandler(level, noColor)
+ return setGlobal(slog.New(multiHandler{handlers: []slog.Handler{fileHandler, consoleHandler}}))
}
+ return setGlobal(slog.New(fileHandler))
+}
- logger := zap.New(core, opts...)
-
- globalLogger = logger
- globalSugar = logger.Sugar()
- zap.ReplaceGlobals(logger)
-
+func setGlobal(l *slog.Logger) *Logger {
+ globalLogger = &Logger{l: l}
+ globalSugar = globalLogger
return globalSugar
}
// NewQuiet returns a no-op logger that discards all output.
-func NewQuiet() *zap.SugaredLogger {
- return zap.NewNop().Sugar()
+func NewQuiet() *Logger {
+ return &Logger{l: slog.New(slog.DiscardHandler)}
}
-// Global returns the global sugared logger, or a no-op if Init has not been called.
-func Global() *zap.SugaredLogger {
+// Global returns the global logger, or a no-op if Init has not been called.
+func Global() *Logger {
if globalSugar != nil {
return globalSugar
}
- return zap.NewNop().Sugar()
+ return NewQuiet()
+}
+
+func (g *Logger) Infof(format string, a ...any) { g.l.Info(fmt.Sprintf(format, a...)) }
+func (g *Logger) Warnf(format string, a ...any) { g.l.Warn(fmt.Sprintf(format, a...)) }
+func (g *Logger) Errorf(format string, a ...any) { g.l.Error(fmt.Sprintf(format, a...)) }
+func (g *Logger) Debugf(format string, a ...any) { g.l.Debug(fmt.Sprintf(format, a...)) }
+
+func (g *Logger) Info(m string) { g.l.Info(m) }
+func (g *Logger) Warn(m string) { g.l.Warn(m) }
+func (g *Logger) Error(m string) { g.l.Error(m) }
+func (g *Logger) Debug(m string) { g.l.Debug(m) }
+
+func (g *Logger) Infow(m string, kv ...any) { g.l.Info(m, kv...) }
+func (g *Logger) Warnw(m string, kv ...any) { g.l.Warn(m, kv...) }
+func (g *Logger) Errorw(m string, kv ...any) { g.l.Error(m, kv...) }
+func (g *Logger) Debugw(m string, kv ...any) { g.l.Debug(m, kv...) }
+
+// multiHandler fans records out to several slog handlers.
+type multiHandler struct {
+ handlers []slog.Handler
+}
+
+func (m multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ for _, h := range m.handlers {
+ if h.Enabled(ctx, level) {
+ return true
+ }
+ }
+ return false
+}
+
+func (m multiHandler) Handle(ctx context.Context, r slog.Record) error {
+ for _, h := range m.handlers {
+ if err := h.Handle(ctx, r.Clone()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (m multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ hs := make([]slog.Handler, len(m.handlers))
+ for i, h := range m.handlers {
+ hs[i] = h.WithAttrs(attrs)
+ }
+ return multiHandler{handlers: hs}
+}
+
+func (m multiHandler) WithGroup(name string) slog.Handler {
+ hs := make([]slog.Handler, len(m.handlers))
+ for i, h := range m.handlers {
+ hs[i] = h.WithGroup(name)
+ }
+ return multiHandler{handlers: hs}
+}
+
+// consoleHandler renders human-readable lines to stdout, e.g.
+//
+// 15:04:05 INF server started
+type consoleHandler struct {
+ level slog.Level
+ noColor bool
+ w io.Writer
+}
+
+func newConsoleHandler(level slog.Level, noColor bool) *consoleHandler {
+ return &consoleHandler{level: level, noColor: noColor, w: os.Stdout}
+}
+
+func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
+ return level >= h.level
+}
+
+func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
+ buf := make([]byte, 0, 128)
+ buf = r.Time.AppendFormat(buf, "15:04:05")
+ buf = append(buf, ' ', ' ')
+
+ levelStr := r.Level.String()
+ if !h.noColor {
+ levelStr = colourLevel(r.Level) + levelStr + colourReset
+ }
+ buf = append(buf, levelStr...)
+ buf = append(buf, ' ', ' ')
+ buf = append(buf, r.Message...)
+
+ if r.NumAttrs() > 0 {
+ buf = append(buf, ' ')
+ r.Attrs(func(a slog.Attr) bool {
+ buf = append(buf, a.Key...)
+ buf = append(buf, '=', '\'')
+ buf = append(buf, fmt.Sprint(a.Value.Any())...)
+ buf = append(buf, '\'', ' ')
+ return true
+ })
+ }
+
+ buf = append(buf, '\n')
+ _, err := h.w.Write(buf)
+ return err
+}
+
+func (h *consoleHandler) WithAttrs(_ []slog.Attr) slog.Handler {
+ return h
+}
+
+func (h *consoleHandler) WithGroup(_ string) slog.Handler {
+ return h
+}
+
+func colourLevel(l slog.Level) string {
+ switch {
+ case l >= slog.LevelError:
+ return colourRed
+ case l >= slog.LevelWarn:
+ return colourYellow
+ case l >= slog.LevelInfo:
+ return colourCyan
+ default:
+ return colourGrey
+ }
}
diff --git a/pkg/send/send.go b/pkg/send/send.go
index b467de6..68deb0a 100644
--- a/pkg/send/send.go
+++ b/pkg/send/send.go
@@ -24,7 +24,7 @@ import (
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/network"
"github.com/google/uuid"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// SendOption configures the send pipeline.
@@ -47,9 +47,9 @@ func WithInMemoryFile(name string, content []byte) SendOption {
}
// SendFiles sends files or directories to a recipient.
-func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *zap.SugaredLogger, opts ...SendOption) error {
+func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, recipientAlias string, recipientPort int, logger *logging.Logger, opts ...SendOption) error {
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
logger.Infof("Searching for recipient '%s'...", recipientAlias)
@@ -219,9 +219,9 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci
return SendToDevice(ctx, cfg, targetDevice, filePaths, logger, opts...)
}
-func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger, opts ...SendOption) error {
+func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *logging.Logger, opts ...SendOption) error {
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
client := &http.Client{}
diff --git a/pkg/send/send_error_test.go b/pkg/send/send_error_test.go
index 32e5f25..08887a7 100644
--- a/pkg/send/send_error_test.go
+++ b/pkg/send/send_error_test.go
@@ -14,11 +14,11 @@ import (
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/crypto"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
-var testLoggerSendErrors = zap.NewNop().Sugar()
+var testLoggerSendErrors = logging.NewQuiet()
func TestSendFiles_UploadRejection(t *testing.T) {
tempDir := t.TempDir()
diff --git a/pkg/send/send_test.go b/pkg/send/send_test.go
index 39248a0..9474d9f 100644
--- a/pkg/send/send_test.go
+++ b/pkg/send/send_test.go
@@ -15,11 +15,11 @@ import (
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/crypto"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
-var testLoggerSend = zap.NewNop().Sugar()
+var testLoggerSend = logging.NewQuiet()
func TestSendFiles_HappyPath(t *testing.T) {
tempDir := t.TempDir()
diff --git a/pkg/send/upload.go b/pkg/send/upload.go
index f9465cc..1b84ef0 100644
--- a/pkg/send/upload.go
+++ b/pkg/send/upload.go
@@ -12,8 +12,8 @@ import (
"strconv"
"time"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "go.uber.org/zap"
)
// memReadSeekCloser wraps a *bytes.Reader to implement io.ReadSeekCloser.
@@ -29,9 +29,9 @@ type fileReader interface {
io.Closer
}
-func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *zap.SugaredLogger) error {
+func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *logging.Logger) error {
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
file, err := os.Open(filePath)
@@ -48,9 +48,9 @@ func uploadFile(ctx context.Context, client *http.Client, device *model.Device,
return uploadStream(ctx, client, device, file, stat.Size(), fileID, sessionID, token, scheme, pin, trackProgress, logger)
}
-func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *zap.SugaredLogger) error {
+func uploadStream(ctx context.Context, client *http.Client, device *model.Device, r fileReader, size int64, fileID, sessionID, token, scheme, pin string, trackProgress func(int64), logger *logging.Logger) error {
if logger == nil {
- logger = zap.NewNop().Sugar()
+ logger = logging.NewQuiet()
}
url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token)
diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go
index 4867797..c1e54cf 100644
--- a/pkg/server/handlers/discovery_handlers.go
+++ b/pkg/server/handlers/discovery_handlers.go
@@ -9,9 +9,9 @@ import (
"github.com/bethropolis/localgo/pkg/cli"
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/httputil"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
)
// DiscoveryHandler handles /info and /register requests.
@@ -19,11 +19,11 @@ type DiscoveryHandler struct {
config *config.Config
registryService *services.RegistryService
sendService *services.SendService
- logger *zap.SugaredLogger
+ logger *logging.Logger
}
// NewDiscoveryHandler creates a new DiscoveryHandler.
-func NewDiscoveryHandler(cfg *config.Config, registryService *services.RegistryService, sendService *services.SendService, logger *zap.SugaredLogger) *DiscoveryHandler {
+func NewDiscoveryHandler(cfg *config.Config, registryService *services.RegistryService, sendService *services.SendService, logger *logging.Logger) *DiscoveryHandler {
return &DiscoveryHandler{
config: cfg,
registryService: registryService,
diff --git a/pkg/server/handlers/discovery_handlers_test.go b/pkg/server/handlers/discovery_handlers_test.go
index 16a8fdc..3649b9d 100644
--- a/pkg/server/handlers/discovery_handlers_test.go
+++ b/pkg/server/handlers/discovery_handlers_test.go
@@ -9,12 +9,12 @@ import (
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/crypto"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
)
-var testLogger = zap.NewNop().Sugar()
+var testLogger = logging.NewQuiet()
func TestDiscoveryHandler_InfoHandler(t *testing.T) {
secCtx := &crypto.StoredSecurityContext{
diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go
index 7d1bd15..6e432ba 100644
--- a/pkg/server/handlers/download_handlers.go
+++ b/pkg/server/handlers/download_handlers.go
@@ -13,14 +13,14 @@ import (
"github.com/bethropolis/localgo/pkg/httputil"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// DownloadHandler handles file downloading requests.
type DownloadHandler struct {
config *config.Config
sendService *services.SendService
- logger *zap.SugaredLogger
+ logger *logging.Logger
shutdownFn func() // optional; set by Server for --once support
}
@@ -31,7 +31,7 @@ func (h *DownloadHandler) SetShutdownFn(fn func()) {
// NewDownloadHandler creates a new DownloadHandler.
-func NewDownloadHandler(cfg *config.Config, sendService *services.SendService, logger *zap.SugaredLogger) *DownloadHandler {
+func NewDownloadHandler(cfg *config.Config, sendService *services.SendService, logger *logging.Logger) *DownloadHandler {
return &DownloadHandler{
config: cfg,
sendService: sendService,
diff --git a/pkg/server/handlers/download_handlers_test.go b/pkg/server/handlers/download_handlers_test.go
index f7b5dc7..4faab03 100644
--- a/pkg/server/handlers/download_handlers_test.go
+++ b/pkg/server/handlers/download_handlers_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"github.com/bethropolis/localgo/pkg/config"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/handlers"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
)
-var testLoggerDownload = zap.NewNop().Sugar()
+var testLoggerDownload = logging.NewQuiet()
func setupDownloadHandler(t *testing.T, cfg *config.Config) (*handlers.DownloadHandler, *services.SendService, string) {
tempDir := t.TempDir()
diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go
index 4f1f3f7..b762b48 100644
--- a/pkg/server/handlers/receive_handlers.go
+++ b/pkg/server/handlers/receive_handlers.go
@@ -19,10 +19,10 @@ import (
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/history"
"github.com/bethropolis/localgo/pkg/httputil"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/services"
"github.com/bethropolis/localgo/pkg/storage"
- "go.uber.org/zap"
)
// maxTextSize is the maximum bytes read from a text/plain body before
@@ -33,14 +33,14 @@ const maxTextSize = 1 * 1024 * 1024 // 1 MB
type ReceiveHandler struct {
config *config.Config
receiveService *services.ReceiveService
- logger *zap.SugaredLogger
+ logger *logging.Logger
historyLog *history.Logger
promptMutex sync.Mutex
shutdownCtx context.Context
}
// NewReceiveHandler creates a new ReceiveHandler.
-func NewReceiveHandler(cfg *config.Config, receiveService *services.ReceiveService, historyLog *history.Logger, shutdownCtx context.Context, logger *zap.SugaredLogger) *ReceiveHandler {
+func NewReceiveHandler(cfg *config.Config, receiveService *services.ReceiveService, historyLog *history.Logger, shutdownCtx context.Context, logger *logging.Logger) *ReceiveHandler {
return &ReceiveHandler{
config: cfg,
receiveService: receiveService,
diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go
index fcf52ae..9a1d2ff 100644
--- a/pkg/server/handlers/receive_handlers_test.go
+++ b/pkg/server/handlers/receive_handlers_test.go
@@ -13,13 +13,13 @@ import (
"testing"
"github.com/bethropolis/localgo/pkg/config"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
"github.com/bethropolis/localgo/pkg/server/handlers"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
)
-var testLogger = zap.NewNop().Sugar()
+var testLogger = logging.NewQuiet()
func setupReceiveHandler(t *testing.T, cfg *config.Config) (*handlers.ReceiveHandler, *services.ReceiveService, string) {
tempDir := t.TempDir()
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 8835e35..dc844dd 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -18,9 +18,9 @@ import (
"github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/history"
"github.com/bethropolis/localgo/pkg/httputil"
+ "github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/server/handlers"
"github.com/bethropolis/localgo/pkg/server/services"
- "go.uber.org/zap"
)
// Server manages the HTTP/S server lifecycle.
@@ -31,14 +31,14 @@ type Server struct {
receiveService *services.ReceiveService
sendService *services.SendService
registryService *services.RegistryService
- logger *zap.SugaredLogger
+ logger *logging.Logger
historyLog *history.Logger // closed in Shutdown()
shutdownCtx context.Context
shutdownCancel context.CancelFunc
}
// NewServer creates a new Server instance.
-func NewServer(cfg *config.Config, logger *zap.SugaredLogger) *Server {
+func NewServer(cfg *config.Config, logger *logging.Logger) *Server {
httputil.SetLogger(logger)
router := http.NewServeMux()
receiveService := services.NewReceiveService()
diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go
index c660b77..c2b982e 100644
--- a/pkg/storage/storage.go
+++ b/pkg/storage/storage.go
@@ -13,7 +13,7 @@ import (
"sync"
"time"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
// Thread-safe pool of 32KB buffers for small files.
@@ -60,7 +60,7 @@ func SaveStreamToFile(stream io.Reader, filePath string, onProgress func(bytesWr
// SaveStreamToFileWithMetadata saves an io.Reader stream and restores optional timestamps.
// If expectedSha256 is provided, the stream is verified against it after the copy succeeds.
// fileSize is used to select an optimal copy buffer size.
-func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize int64, modified *string, accessed *string, expectedSha256 *string, onProgress func(bytesWritten int64), logger *zap.SugaredLogger) error {
+func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize int64, modified *string, accessed *string, expectedSha256 *string, onProgress func(bytesWritten int64), logger *logging.Logger) error {
dir := filepath.Dir(filePath)
if err := EnsureDirExists(dir); err != nil {
return err
diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go
index 94e5c83..0371452 100644
--- a/pkg/storage/storage_test.go
+++ b/pkg/storage/storage_test.go
@@ -7,10 +7,10 @@ import (
"testing"
"time"
- "go.uber.org/zap"
+ "github.com/bethropolis/localgo/pkg/logging"
)
-var testLogger = zap.NewNop().Sugar()
+var testLogger = logging.NewQuiet()
func TestEnsureDirExists(t *testing.T) {
tmpDir := t.TempDir()
From 7db46536a602f92a604d0f1814c404d7860ee9f6 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Fri, 31 Jul 2026 06:31:11 +0300
Subject: [PATCH 21/22] refactor: replace viper with hand-rolled yaml+env
config source
- Add pkg/config/source.go: Source resolves values with precedence
overrides > env (LOCALSEND_*) > file > defaults, using gopkg.in/yaml.v3
- Rewrite LoadConfig/getSecurityDir to take *Source; delete viper.go
- root.go: ViperCfg is now *config.Source; --config uses LoadSourceFile
- Rewrite config subcommands (get/set/add/remove/list/unset/open/path)
against Source; 'config set' now writes only explicitly-set keys
- config_test.go injection seam -> NewSourceFromMap
- Drop viper and its transitive deps (hcl, go-toml, ini, afero, cast,
mapstructure, gotenv, fsnotify, conc, locafero, slog-shim); promote
gopkg.in/yaml.v3 to a direct dependency
---
cmd/localgo/cmd/config.go | 153 ++++++++-------------
cmd/localgo/cmd/root.go | 10 +-
go.mod | 20 +--
go.sum | 50 +------
pkg/config/config.go | 67 +++++-----
pkg/config/config_test.go | 36 +----
pkg/config/source.go | 273 ++++++++++++++++++++++++++++++++++++++
pkg/config/viper.go | 31 -----
8 files changed, 378 insertions(+), 262 deletions(-)
create mode 100644 pkg/config/source.go
delete mode 100644 pkg/config/viper.go
diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go
index 5d92ee4..c72a83a 100644
--- a/cmd/localgo/cmd/config.go
+++ b/cmd/localgo/cmd/config.go
@@ -10,9 +10,9 @@ import (
"strconv"
"strings"
+ "github.com/bethropolis/localgo/pkg/config"
"github.com/bethropolis/localgo/pkg/help"
"github.com/spf13/cobra"
- "github.com/spf13/viper"
)
// configKey describes a known config key with its type and valid values.
@@ -153,31 +153,28 @@ func validateValue(ck configKey, key, raw string) (interface{}, error) {
return raw, nil
}
-func newViperForConfig() *viper.Viper {
- v := viper.New()
- v.SetConfigName("config")
- v.SetConfigType("yaml")
- v.AddConfigPath("$HOME/.config/localgo/")
- v.AddConfigPath("$HOME/.local/etc/localgo/")
- v.SetEnvPrefix("LOCALSEND")
- v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
- v.AutomaticEnv()
-
+func newConfigSource() *config.Source {
+ src := config.LoadSource()
for key, ck := range knownConfigKeys {
if ck.defVal != nil {
- v.SetDefault(key, ck.defVal)
+ src.SetDefault(key, ck.defVal)
}
}
-
- _ = v.ReadInConfig()
- return v
+ return src
}
-func getConfigPath(v *viper.Viper) string {
- if p := v.ConfigFileUsed(); p != "" {
- return p
+// originFor reports the source of a key's value: file, env, or default.
+func originFor(src *config.Source, key string) (string, bool) {
+ if src.InFile(key) {
+ return "[file]", true
+ }
+ if _, ok := os.LookupEnv("LOCALSEND_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))); ok {
+ return "[env]", true
}
- return os.ExpandEnv("$HOME/.config/localgo/config.yaml")
+ if ck, ok := knownConfigKeys[key]; ok && ck.defVal != nil {
+ return "[default]", true
+ }
+ return "", false
}
var configCmd = &cobra.Command{
@@ -190,18 +187,18 @@ var configGetCmd = &cobra.Command{
Short: "Get a config value",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
+ src := newConfigSource()
key := strings.ToLower(args[0])
if _, err := validateKey(key); err != nil {
return err
}
- if !v.IsSet(key) {
+ if !src.IsSet(key) {
return fmt.Errorf("key %q not set", key)
}
- fmt.Println(v.GetString(key))
+ fmt.Println(src.GetString(key))
return nil
},
}
@@ -211,7 +208,7 @@ var configSetCmd = &cobra.Command{
Short: "Set a config value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
+ src := newConfigSource()
key := strings.ToLower(args[0])
ck, err := validateKey(key)
@@ -224,18 +221,13 @@ var configSetCmd = &cobra.Command{
return err
}
- v.Set(key, val)
+ src.Set(key, val)
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
+ if err := src.Save(); err != nil {
+ return err
}
- fmt.Printf("Set %s = %v in %s\n", key, val, configPath)
+ fmt.Printf("Set %s = %v in %s\n", key, val, src.FilePath())
return nil
},
}
@@ -244,33 +236,23 @@ var configListCmd = &cobra.Command{
Use: "list",
Short: "List all config values with origin",
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- settings := v.AllSettings()
-
- if len(settings) == 0 {
- fmt.Println("(no settings)")
- return nil
- }
+ src := newConfigSource()
fmt.Printf("%-28s %-10s %s\n", "KEY", "ORIGIN", "VALUE")
fmt.Println(strings.Repeat("-", 80))
- for _, key := range v.AllKeys() {
- val := v.Get(key)
- if val == nil {
+ shown := false
+ for _, key := range knownKeyNames() {
+ origin, ok := originFor(src, key)
+ if !ok {
continue
}
+ fmt.Printf("%-28s %-10s %v\n", key, origin, src.Get(key))
+ shown = true
+ }
- origin := "[env]"
- if v.InConfig(key) {
- origin = "[file]"
- } else if _, ok := knownConfigKeys[key]; ok && knownConfigKeys[key].defVal != nil && fmt.Sprint(v.Get(key)) == fmt.Sprint(knownConfigKeys[key].defVal) {
- origin = "[default]"
- } else if !v.InConfig(key) {
- origin = "[env]"
- }
-
- fmt.Printf("%-28s %-10s %v\n", key, origin, val)
+ if !shown {
+ fmt.Println("(no settings)")
}
return nil
},
@@ -281,35 +263,24 @@ var configUnsetCmd = &cobra.Command{
Short: "Remove a config key (reverts to default)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
+ src := newConfigSource()
key := strings.ToLower(args[0])
if _, err := validateKey(key); err != nil {
return err
}
- if !v.InConfig(key) {
+ if !src.InFile(key) {
return fmt.Errorf("key %q is not in config file", key)
}
- settings := v.AllSettings()
- delete(settings, key)
+ src.Unset(key)
- // Rebuild the config with the key removed
- for k, val := range settings {
- v.Set(k, val)
- }
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
+ if err := src.Save(); err != nil {
+ return err
}
- fmt.Printf("Removed %s from %s\n", key, configPath)
+ fmt.Printf("Removed %s from %s\n", key, src.FilePath())
return nil
},
}
@@ -318,8 +289,8 @@ var configOpenCmd = &cobra.Command{
Use: "open",
Short: "Open config file in system editor",
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- configPath := getConfigPath(v)
+ src := newConfigSource()
+ configPath := src.FilePath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
@@ -327,7 +298,7 @@ var configOpenCmd = &cobra.Command{
// If the file doesn't exist yet, create it
if _, err := os.Stat(configPath); os.IsNotExist(err) {
- if err := v.WriteConfigAs(configPath); err != nil {
+ if err := src.Save(); err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
}
@@ -367,27 +338,22 @@ var configAddCmd = &cobra.Command{
Short: "Append a value to a list config key",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
+ src := newConfigSource()
key := strings.ToLower(args[0])
if _, err := validateKey(key); err != nil {
return err
}
- current := v.GetStringSlice(key)
+ current := src.GetStringSlice(key)
current = append(current, args[1])
- v.Set(key, current)
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
+ src.Set(key, current)
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
+ if err := src.Save(); err != nil {
+ return err
}
- fmt.Printf("Added %q to %s in %s\n", args[1], key, configPath)
+ fmt.Printf("Added %q to %s in %s\n", args[1], key, src.FilePath())
return nil
},
}
@@ -397,14 +363,14 @@ var configRemoveCmd = &cobra.Command{
Short: "Remove a value from a list config key",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
+ src := newConfigSource()
key := strings.ToLower(args[0])
if _, err := validateKey(key); err != nil {
return err
}
- current := v.GetStringSlice(key)
+ current := src.GetStringSlice(key)
filtered := make([]string, 0, len(current))
removed := false
for _, item := range current {
@@ -419,18 +385,13 @@ var configRemoveCmd = &cobra.Command{
return fmt.Errorf("value %q not found in %s", args[1], key)
}
- v.Set(key, filtered)
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
+ src.Set(key, filtered)
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
+ if err := src.Save(); err != nil {
+ return err
}
- fmt.Printf("Removed %q from %s in %s\n", args[1], key, configPath)
+ fmt.Printf("Removed %q from %s in %s\n", args[1], key, src.FilePath())
return nil
},
}
@@ -439,8 +400,8 @@ var configPathCmd = &cobra.Command{
Use: "path",
Short: "Show config file path",
RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- path := getConfigPath(v)
+ src := newConfigSource()
+ path := src.FilePath()
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Println(path + " (file does not exist yet)")
} else {
diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go
index 571efbe..96a68c9 100644
--- a/cmd/localgo/cmd/root.go
+++ b/cmd/localgo/cmd/root.go
@@ -10,7 +10,6 @@ import (
"github.com/bethropolis/localgo/pkg/help"
"github.com/bethropolis/localgo/pkg/logging"
"github.com/spf13/cobra"
- "github.com/spf13/viper"
)
var (
@@ -24,7 +23,7 @@ var (
Verbose bool
JSONOutput bool
Cfg *config.Config
- ViperCfg *viper.Viper
+ ViperCfg *config.Source
)
var rootCmd = &cobra.Command{
@@ -49,12 +48,13 @@ var rootCmd = &cobra.Command{
logger := logging.Init(Verbose, JSONOutput, noColor)
- ViperCfg = config.InitViper()
+ ViperCfg = config.LoadSource()
if cfgFile != "" {
- ViperCfg.SetConfigFile(cfgFile)
- if err := ViperCfg.ReadInConfig(); err != nil {
+ src, err := config.LoadSourceFile(cfgFile)
+ if err != nil {
logging.Global().Warnf("Failed to read config file: %v", err)
}
+ ViperCfg = src
}
var err error
diff --git a/go.mod b/go.mod
index 7401c23..7841c80 100644
--- a/go.mod
+++ b/go.mod
@@ -13,12 +13,11 @@ require (
github.com/jackpal/gateway v1.2.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/spf13/cobra v1.9.1
- github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.11.1
github.com/vbauerster/mpb/v7 v7.5.3
- go.uber.org/zap v1.27.1
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -37,35 +36,22 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
- github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- github.com/sagikazarmark/locafero v0.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
rsc.io/qr v0.2.0 // indirect
)
diff --git a/go.sum b/go.sum
index 7dc06c3..3ce4ebe 100644
--- a/go.sum
+++ b/go.sum
@@ -49,26 +49,17 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackpal/gateway v1.2.0 h1:euPRe4t7JfTaqC5Lr78HXl2wSHo54XndTtiAcIxkb5g=
@@ -79,8 +70,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -92,17 +81,13 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -111,46 +96,18 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
-github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
-github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/vbauerster/mpb/v7 v7.5.3 h1:BkGfmb6nMrrBQDFECR/Q7RkKCw7ylMetCb4079CGs4w=
github.com/vbauerster/mpb/v7 v7.5.3/go.mod h1:i+h4QY6lmLvBNK2ah1fSreiw3ajskRlBp9AhY/PnuOE=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
-go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
-go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
@@ -167,9 +124,6 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 70287f8..5e8285b 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -10,9 +10,8 @@ import (
mathrand "math/rand/v2"
"github.com/bethropolis/localgo/pkg/crypto"
- "github.com/bethropolis/localgo/pkg/model"
- "github.com/spf13/viper"
"github.com/bethropolis/localgo/pkg/logging"
+ "github.com/bethropolis/localgo/pkg/model"
)
const (
@@ -68,8 +67,8 @@ func (c *Config) SetCustomFingerprint(fp string) {
}
// getSecurityDir determines the best location for the security directory
-func getSecurityDir(v *viper.Viper) string {
- if envDir := v.GetString("security_dir"); envDir != "" {
+func getSecurityDir(src *Source) string {
+ if envDir := src.GetString("security_dir"); envDir != "" {
logging.Global().Infof("Using security directory: %s", envDir)
return envDir
}
@@ -110,31 +109,31 @@ func testDirWritable(dir string) bool {
return true
}
-func LoadConfig(v *viper.Viper, logger *logging.Logger) (*Config, error) {
- if v == nil {
- v = InitViper()
+func LoadConfig(src *Source, logger *logging.Logger) (*Config, error) {
+ if src == nil {
+ src = NewSourceFromMap(map[string]any{})
}
- alias := v.GetString("alias")
+ alias := src.GetString("alias")
if alias == "" {
alias = generateDefaultAlias()
}
// Use the new security directory resolution
- securityDirPath := getSecurityDir(v)
+ securityDirPath := getSecurityDir(src)
securityFilePath := filepath.Join(securityDirPath, DefaultSecurityFile)
- portStr := v.GetString("port")
+ portStr := src.GetString("port")
port := DefaultPort
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
- multicastGroup := v.GetString("multicast_group")
+ multicastGroup := src.GetString("multicast_group")
if multicastGroup == "" {
multicastGroup = DefaultMulticastGroup
}
- downloadDir := v.GetString("download_dir")
+ downloadDir := src.GetString("download_dir")
if downloadDir == "" {
home, err := os.UserHomeDir()
if err != nil {
@@ -143,7 +142,7 @@ func LoadConfig(v *viper.Viper, logger *logging.Logger) (*Config, error) {
downloadDir = filepath.Join(home, "Downloads", "localgo")
}
- maxBodySizeStr := v.GetString("max_body_size")
+ maxBodySizeStr := src.GetString("max_body_size")
maxBodySize := int64(0)
if maxBodySizeStr != "" {
if size, err := strconv.ParseInt(maxBodySizeStr, 10, 64); err == nil {
@@ -153,10 +152,10 @@ func LoadConfig(v *viper.Viper, logger *logging.Logger) (*Config, error) {
}
}
- multicastInterface := v.GetString("multicast_interface")
+ multicastInterface := src.GetString("multicast_interface")
// Parse LOCALSEND_FORCE_HTTP
- forceHTTP := v.GetString("force_http") == "true" || v.GetString("force_http") == "1"
+ forceHTTP := src.GetString("force_http") == "true" || src.GetString("force_http") == "1"
HttpsEnabled := !forceHTTP
securityContext, err := crypto.LoadSecurityContext(securityFilePath, logger)
@@ -182,42 +181,42 @@ func LoadConfig(v *viper.Viper, logger *logging.Logger) (*Config, error) {
deviceType := model.DeviceTypeDesktop
// Parse LOCALSEND_DEVICE_MODEL
- if envDeviceModel := v.GetString("device_model"); envDeviceModel != "" {
+ if envDeviceModel := src.GetString("device_model"); envDeviceModel != "" {
deviceModel = envDeviceModel
}
// Parse LOCALSEND_DEVICE_TYPE
- if envDeviceType := v.GetString("device_type"); envDeviceType != "" {
+ if envDeviceType := src.GetString("device_type"); envDeviceType != "" {
deviceType = model.DeviceType(envDeviceType)
}
- autoAccept := v.GetString("auto_accept") == "true" || v.GetString("auto_accept") == "1"
- noClipboard := v.GetString("no_clipboard") == "true" || v.GetString("no_clipboard") == "1"
- quiet := v.GetString("quiet") == "true" || v.GetString("quiet") == "1"
+ autoAccept := src.GetString("auto_accept") == "true" || src.GetString("auto_accept") == "1"
+ noClipboard := src.GetString("no_clipboard") == "true" || src.GetString("no_clipboard") == "1"
+ quiet := src.GetString("quiet") == "true" || src.GetString("quiet") == "1"
- historyFile := v.GetString("history")
+ historyFile := src.GetString("history")
- execHook := v.GetString("exec")
+ execHook := src.GetString("exec")
- concurrency := v.GetInt("concurrency")
+ concurrency := src.GetInt("concurrency")
- shell := v.GetString("shell")
- clipboardWriteCmd := v.GetString("clipboard_write_cmd")
- clipboardReadCmd := v.GetString("clipboard_read_cmd")
- customTLSCertPath := v.GetString("tls_cert")
- customTLSKeyPath := v.GetString("tls_key")
- notificationCmd := v.GetString("notification_cmd")
- discoveryStrategy := v.GetString("discovery_strategy")
+ shell := src.GetString("shell")
+ clipboardWriteCmd := src.GetString("clipboard_write_cmd")
+ clipboardReadCmd := src.GetString("clipboard_read_cmd")
+ customTLSCertPath := src.GetString("tls_cert")
+ customTLSKeyPath := src.GetString("tls_key")
+ notificationCmd := src.GetString("notification_cmd")
+ discoveryStrategy := src.GetString("discovery_strategy")
if discoveryStrategy == "" {
discoveryStrategy = "full"
}
- fileConflictResolve := v.GetString("file_conflict_resolution")
+ fileConflictResolve := src.GetString("file_conflict_resolution")
if fileConflictResolve == "" {
fileConflictResolve = "rename"
}
- bindAddress := v.GetString("bind_address")
- staticPeers := v.GetStringSlice("static_peers")
- trustedFingerprints := v.GetStringSlice("trusted_fingerprints")
+ bindAddress := src.GetString("bind_address")
+ staticPeers := src.GetStringSlice("static_peers")
+ trustedFingerprints := src.GetStringSlice("trusted_fingerprints")
cfg := &Config{
Alias: alias,
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 5877faa..8c8b33e 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -6,7 +6,6 @@ import (
"github.com/bethropolis/localgo/pkg/logging"
"github.com/bethropolis/localgo/pkg/model"
- "github.com/spf13/viper"
)
var testLogger = logging.NewQuiet()
@@ -27,12 +26,7 @@ func TestLoadConfig_WithEnvVars(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("LOCALSEND_SECURITY_DIR", tmpDir)
- cfg, err := LoadConfig(func() *viper.Viper {
- v := viper.New()
- v.SetEnvPrefix("LOCALSEND")
- v.AutomaticEnv()
- return v
- }(), testLogger)
+ cfg, err := LoadConfig(NewSourceFromMap(map[string]any{}), testLogger)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
@@ -79,12 +73,7 @@ func TestLoadConfig_Defaults(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("LOCALSEND_SECURITY_DIR", tmpDir)
- cfg, err := LoadConfig(func() *viper.Viper {
- v := viper.New()
- v.SetEnvPrefix("LOCALSEND")
- v.AutomaticEnv()
- return v
- }(), testLogger)
+ cfg, err := LoadConfig(NewSourceFromMap(map[string]any{}), testLogger)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
@@ -117,12 +106,7 @@ func TestToRegisterDto(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("LOCALSEND_SECURITY_DIR", tmpDir)
- cfg, err := LoadConfig(func() *viper.Viper {
- v := viper.New()
- v.SetEnvPrefix("LOCALSEND")
- v.AutomaticEnv()
- return v
- }(), testLogger)
+ cfg, err := LoadConfig(NewSourceFromMap(map[string]any{}), testLogger)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
@@ -161,12 +145,7 @@ func TestToInfoDto(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("LOCALSEND_SECURITY_DIR", tmpDir)
- cfg, err := LoadConfig(func() *viper.Viper {
- v := viper.New()
- v.SetEnvPrefix("LOCALSEND")
- v.AutomaticEnv()
- return v
- }(), testLogger)
+ cfg, err := LoadConfig(NewSourceFromMap(map[string]any{}), testLogger)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
@@ -195,12 +174,7 @@ func TestGetSecurityDir_EnvOverride(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("LOCALSEND_SECURITY_DIR", tmpDir)
- dir := getSecurityDir(func() *viper.Viper {
- v := viper.New()
- v.SetEnvPrefix("LOCALSEND")
- v.AutomaticEnv()
- return v
- }())
+ dir := getSecurityDir(NewSourceFromMap(map[string]any{}))
if dir != tmpDir {
t.Errorf("Expected security dir '%s', got '%s'", tmpDir, dir)
}
diff --git a/pkg/config/source.go b/pkg/config/source.go
new file mode 100644
index 0000000..c554df6
--- /dev/null
+++ b/pkg/config/source.go
@@ -0,0 +1,273 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Source is a lightweight replacement for viper. It resolves config values
+// with the precedence: programmatic overrides > environment > file > defaults.
+//
+// Environment variables are named LOCALSEND_ with '-' replaced by '_'.
+type Source struct {
+ filePath string
+ file map[string]any
+ overrides map[string]any
+ defaults map[string]any
+}
+
+var sourceDefaults = map[string]any{
+ "port": DefaultPort,
+ "multicast_group": DefaultMulticastGroup,
+ "concurrency": 4,
+}
+
+func newSource() *Source {
+ return &Source{
+ file: make(map[string]any),
+ overrides: make(map[string]any),
+ defaults: sourceDefaults,
+ }
+}
+
+// LoadSource searches the standard config directories for a config file and
+// returns a Source bound to it. Returns an empty Source if no file is found.
+func LoadSource() *Source {
+ s := newSource()
+ for _, dir := range []string{
+ os.ExpandEnv("$HOME/.config/localgo"),
+ os.ExpandEnv("$HOME/.local/etc/localgo"),
+ ".",
+ } {
+ for _, name := range []string{"config.yaml", "config.yml"} {
+ p := filepath.Join(dir, name)
+ if _, err := os.Stat(p); err == nil {
+ s.filePath = p
+ s.readFile()
+ return s
+ }
+ }
+ }
+ return s
+}
+
+// LoadSourceFile returns a Source bound to an explicitly selected config file.
+func LoadSourceFile(path string) (*Source, error) {
+ s := newSource()
+ s.filePath = path
+ if _, err := os.Stat(path); err != nil {
+ return s, err
+ }
+ s.readFile()
+ return s, nil
+}
+
+// NewSourceFromMap returns a Source initialised with the given programmatic
+// overrides (used for tests and callers that supply values directly).
+func NewSourceFromMap(m map[string]any) *Source {
+ s := newSource()
+ for k, v := range m {
+ s.overrides[k] = v
+ }
+ return s
+}
+
+// SetConfigFile overrides the config file path used by FilePath and Save.
+func (s *Source) SetConfigFile(path string) {
+ s.filePath = path
+}
+
+func (s *Source) readFile() {
+ data, err := os.ReadFile(s.filePath)
+ if err != nil {
+ return
+ }
+ if err := yaml.Unmarshal(data, &s.file); err != nil {
+ s.file = make(map[string]any)
+ }
+ if s.file == nil {
+ s.file = make(map[string]any)
+ }
+}
+
+func envName(key string) string {
+ return "LOCALSEND_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
+}
+
+// raw resolves a key across all sources without any type coercion.
+func (s *Source) raw(key string) (any, bool) {
+ if v, ok := s.overrides[key]; ok {
+ return v, true
+ }
+ if v, ok := os.LookupEnv(envName(key)); ok {
+ return v, true
+ }
+ if v, ok := s.file[key]; ok {
+ return v, true
+ }
+ if v, ok := s.defaults[key]; ok {
+ return v, true
+ }
+ return nil, false
+}
+
+// IsSet reports whether the key has a value from any source.
+func (s *Source) IsSet(key string) bool {
+ _, ok := s.raw(key)
+ return ok
+}
+
+// InFile reports whether the key is present in the config file.
+func (s *Source) InFile(key string) bool {
+ _, ok := s.file[key]
+ return ok
+}
+
+// FilePath returns the resolved config file path, or the default location
+// when no file has been found or selected.
+func (s *Source) FilePath() string {
+ if s.filePath != "" {
+ return s.filePath
+ }
+ return os.ExpandEnv("$HOME/.config/localgo/config.yaml")
+}
+
+// Set stores a programmatic override for the key.
+func (s *Source) Set(key string, val any) {
+ s.overrides[key] = val
+}
+
+// SetDefault registers a fallback value used when no other source has the key.
+func (s *Source) SetDefault(key string, val any) {
+ s.defaults[key] = val
+}
+
+// Unset removes the key from the file and overrides.
+func (s *Source) Unset(key string) {
+ delete(s.file, key)
+ delete(s.overrides, key)
+}
+
+// Save writes the file entries merged with programmatic overrides to the
+// resolved config path, creating parent directories as needed.
+func (s *Source) Save() error {
+ for k, v := range s.overrides {
+ s.file[k] = v
+ }
+ s.overrides = make(map[string]any)
+
+ path := s.FilePath()
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ data, err := yaml.Marshal(s.file)
+ if err != nil {
+ return fmt.Errorf("failed to marshal config: %w", err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ return fmt.Errorf("failed to write config: %w", err)
+ }
+ return nil
+}
+
+// GetString returns the string form of the resolved value.
+func (s *Source) GetString(key string) string {
+ v, ok := s.raw(key)
+ if !ok {
+ return ""
+ }
+ return toString(v)
+}
+
+// GetInt returns the int form of the resolved value.
+func (s *Source) GetInt(key string) int {
+ v, ok := s.raw(key)
+ if !ok {
+ return 0
+ }
+ switch t := v.(type) {
+ case int:
+ return t
+ case int64:
+ return int(t)
+ case float64:
+ return int(t)
+ case string:
+ if n, err := strconv.Atoi(t); err == nil {
+ return n
+ }
+ case bool:
+ if t {
+ return 1
+ }
+ }
+ return 0
+}
+
+// GetStringSlice returns the resolved value as a list of strings. A
+// comma-separated environment variable or a YAML scalar yields a single item.
+func (s *Source) GetStringSlice(key string) []string {
+ v, ok := s.raw(key)
+ if !ok {
+ return nil
+ }
+ switch t := v.(type) {
+ case []string:
+ return t
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, item := range t {
+ out = append(out, toString(item))
+ }
+ return out
+ case string:
+ if t == "" {
+ return nil
+ }
+ return []string{t}
+ default:
+ return []string{toString(t)}
+ }
+}
+
+// Get returns the resolved value as-is.
+func (s *Source) Get(key string) any {
+ v, ok := s.raw(key)
+ if !ok {
+ return nil
+ }
+ return v
+}
+
+func toString(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case bool:
+ return strconv.FormatBool(t)
+ case int:
+ return strconv.Itoa(t)
+ case int64:
+ return strconv.FormatInt(t, 10)
+ case float64:
+ return strconv.FormatFloat(t, 'f', -1, 64)
+ case []string:
+ return strings.Join(t, ",")
+ case []any:
+ parts := make([]string, 0, len(t))
+ for _, item := range t {
+ parts = append(parts, toString(item))
+ }
+ return strings.Join(parts, ",")
+ case nil:
+ return ""
+ default:
+ return fmt.Sprint(t)
+ }
+}
diff --git a/pkg/config/viper.go b/pkg/config/viper.go
deleted file mode 100644
index f374427..0000000
--- a/pkg/config/viper.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package config
-
-import (
- "strings"
-
- "github.com/spf13/viper"
-)
-
-func InitViper() *viper.Viper {
- v := viper.New()
-
- v.SetConfigName("config")
- v.SetConfigType("yaml")
- v.AddConfigPath("$HOME/.config/localgo/")
- v.AddConfigPath("$HOME/.local/etc/localgo/")
- v.AddConfigPath(".")
-
- v.SetEnvPrefix("LOCALSEND")
- v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
- v.AutomaticEnv()
-
- // Set defaults
- v.SetDefault("port", DefaultPort)
- v.SetDefault("multicast_group", DefaultMulticastGroup)
- v.SetDefault("concurrency", 4)
- // We'll handle DownloadDir default in LoadConfig since it depends on os.UserHomeDir
-
- _ = v.ReadInConfig() // ignore error if config file doesn't exist
-
- return v
-}
From 4c7c8012ef7dd76c241fc30d8f8ce634faf018e1 Mon Sep 17 00:00:00 2001
From: bethropolis <66518866+bethropolis@users.noreply.github.com>
Date: Sat, 8 Aug 2026 23:22:53 +0300
Subject: [PATCH 22/22] chore: drop changelog, restore installer box alignment
(release prep v0.6.6)
---
CHANGELOG.md | 243 --------------------------------------
cmd/localgo/cmd/config.go | 159 -------------------------
pkg/server/server.go | 3 -
3 files changed, 405 deletions(-)
delete mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 116cc05..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,243 +0,0 @@
-# Changelog
-
-All notable changes to this project are documented in this file.
-
-## v0.6.0 - 2026-06-24
-
-### Highlights
-- **Protocol audit**: full spec compliance pass — `ProtocolVersion` 2.1→2.0, session blocking (409), `POST /register`, constant-time PIN, correct fingerprint selection, DTO field cleanup, `Port`/`Protocol` in InfoDto, and more
-- **Modularisation**: 6 files exceeding 300 LOC split into 19 single-responsibility units for maintainability
-- **FreeBSD support**: rc.d init script and clipboard integration (`clipboard_unix.go` with `linux||freebsd` build tag)
-- **`--no-color` flag** and automatic `NO_COLOR` env var detection in logging
-- **Direct send & CIDR scan**: `localgo send --ip ` and `localgo scan --range ` flags
-- **TUI file picker**: `localgo share` now opens an interactive file picker via `huh.FilePicker`
-- **Gateway-based subnet prioritization**: smarter LAN discovery and scanning
-- **GitHub Pages docs site** and online one-liner installer (`get-localgo.sh`)
-- **Scratch Docker image hardened**: CMD args fixed, env vars set for writable peer cache
-
-### Added
-- `--no-color`/`--no-colour` global flag, `NO_COLOR` env support (`pkg/logging`)
-- FreeBSD rc.d init script for `localgo serve` as a service
-- FreeBSD clipboard support via `clipboard_unix.go` (`linux || freebsd`)
-- `send --ip ` flag for direct IP-based send (skips discovery)
-- `scan --range ` flag for CIDR-based subnet scanning
-- `ParseCIDRRange()` exported from `pkg/network/interfaces.go`
-- `SendToDevice()` exported from `pkg/send/send.go` for programmatic use
-- Gateway-based LAN subnet prioritization for scan and send
-- Interactive TUI file picker in `share` command (extracted shared picker to `pkg/cli`)
-- GitHub Pages docs site (`gh-pages` branch) and online installer
-- `XDG_CACHE_HOME` env var for writable peer cache in scratch Docker
-- `LOCALSEND_AUTO_ACCEPT=true` env var for scratch Docker image
-- Homebrew cask support via goreleaser `homebrew_casks`
-
-### Fixed (Protocol Audit)
-- `ProtocolVersion` correctly set to `"2.0"` (was `"2.1"`) to match the LocalSend spec
-- Session blocking: return 409 Conflict for concurrent sessions on same device
-- Validate `?sessionId` in `PrepareDownloadHandler`
-- Use `POST /register` instead of deprecated `GET /info` for HTTP subnet scan
-- Constant-time PIN comparison in `DownloadHandler`
-- Correct fingerprint selection in HTTP mode (random string, not certificate hash)
-- Add `Port`/`Protocol` to prepare-upload `InfoDto` per spec section 4.1
-- Use valid `deviceType "headless"` in private mode
-- Remove spec-noncompliant extra fields from DTO structs
-- Return no body on upload/cancel responses
-- Force HTTP for `share` command (browser download API compatibility)
-- Verify TLS certificate fingerprint during file transfer (MitM prevention)
-
-### Fixed (Other)
-- Case-insensitive TLS fingerprint comparison
-- Remove duplicate `-p` shorthand in `devices` command
-- Clipboard prompt removed from `send`; filepicker is the default TUI fallback
-- HTTP subnet scan fallback when multicast returns 0 devices
-- Filter local machine out of HTTP scan results
-- Send multicast response via multicast address instead of unicast
-- Check `xdg-open` availability before opening download directory
-- Scratch Docker: CMD args pass-through (no double `"localgo"`), `LOCALSEND_DOWNLOAD_DIR` and `LOCALSEND_SECURITY_DIR` env vars
-- `DiscoverDevices` private mode bypass in `cmd/send.go`
-- Device mutex for `LastSeen`/`Available`, `ReceiveService` ticker goroutine leak
-- Config set parsing, scan/discover timeouts, share port order, CIDR range, RNG fallback
-- PIN constant-time compare, server timeouts, private mode DTO bypass, JPEG bounds strip
-- Progress bar scrollback erasure fix, bounds-safe `FormatBytes` (no panic on >EB sizes)
-- Storage: atomic file writes via `.tmp` rename pattern; Windows: lazy DLL loading (`NewLazyDLL`)
-
-### Refactored
-- 6 files exceeding 300 LOC split into 19 smaller single-responsibility units
-- Shared TUI file picker extracted to `pkg/cli`
-- Code quality: `SortFunc`, mutex-safe anonymize, `saveTextAsFile`, interface extraction, tests
-
-### Commits (v0.5.10..v0.6.0)
-- `814b5fd` refactor: split 6 large files into 19 single-responsibility units
-- `0348ddb` chore: stable release prep — bugs, atomic writes, safety
-- `b43e423` feat: add GitHub Pages docs site and online installer
-- `16da01b` fix: stability fixes and enhancements
-- `51de7a2` fix: remove duplicate -p shorthand in devices command
-- `53ffe3d` feat(share): add TUI file picker, extract shared picker to pkg/cli
-- `3d9c9bb` fix: bug fix
-- `c0edea8` fix: case-insensitive TLS fingerprint comparison
-- `0f2c8ce` chore: final state after protocol audit fixes
-- `68d35a9` fix: improve TLS error diag, always prompt device picker, silence usage on errors
-- `d1af3c1` fix(protocol): force HTTP for share command (browser download API)
-- `221bfda` fix(security): verify TLS certificate fingerprint during file transfer
-- `52f39a8` fix(protocol): add port/protocol to prepare-upload info block
-- `4825c46` refactor(dto): remove spec-noncompliant extra fields from DTO structs
-- `0c4ea80` fix(protocol): use valid deviceType 'headless' in private mode, return no body on upload/cancel
-- `fd65357` fix(protocol): validate ?sessionId in PrepareDownloadHandler
-- `261b904` fix(protocol): implement session blocking, return 409 for concurrent sessions
-- `beb3629` fix(discovery): use POST /register instead of deprecated GET /info for HTTP subnet scan
-- `a08245a` fix(security): use constant-time PIN comparison in DownloadHandler
-- `01be941` fix(protocol): select correct fingerprint in HTTP mode (random string, not cert hash)
-- `c5b3a8d` fix(protocol): change ProtocolVersion from '2.1' to '2.0' to match spec
-- `2f47675` fix(send): remove interactive clipboard prompt, filepicker is the default TUI fallback
-- `cf37d46` fix(discover): fall back to HTTP subnet scan when multicast returns nothing
-- `de481d0` fix(scan): filter local machine out of HTTP scan results
-- `8d35b6c` fix(discovery): send multicast response via multicast addr instead of unicast back
-- `32a628d` feat(network): add gateway-based LAN subnet prioritization for scan and send
-- `47f61e2` fix: check xdg-open availability before opening download directory
-- `3599891` feat(freebsd): add rc.d init script for localgo service
-- `5f13a84` feat(freebsd): enable clipboard support via clipboard_unix.go (linux||freebsd)
-- `7aaf291` feat(cli): add --no-color flag, respect NO_COLOR env in logging Init
-- `97a0c4a` docs(help): add completion cmd, missing flags for serve/share/send, --private/--config options
-- `138952b` fix(help): correct discover --timeout default from 5 to 10
-- `8bfafe2` fix(security): bypass DiscoverDevices private mode in cmd/send.go
-- `413bcd1` refactor(code quality): SortFunc, mutex-safe anonymize, saveTextAsFile, interfaces, tests
-- `ad832f9` fix(concurrency): Device mutex for LastSeen/Available, ReceiveService ticker goroutine leak
-- `64be12d` fix(logic): config set parsing, scan/discover timeouts, share port order, CIDR range, RNG fallback
-- `9144f42` fix(security): PIN constant-time compare, server timeouts, private mode DTO bypass, strip JPEG bounds
-- `2a8a00b` fix(scratch): add XDG_CACHE_HOME so peer cache is writable
-- `f6ed6a5` fix(scratch): add LOCALSEND_AUTO_ACCEPT=true env var
-- `b013c88` fix: create discovery DTOs after server binds port
-- `37be6e8` fix(scratch): set LOCALSEND_DOWNLOAD_DIR and LOCALSEND_SECURITY_DIR env vars
-- `c01ef58` fix: docker-start passes CMD args correctly (no double localgo)
-- `be29c69` feat: add send --ip, scan --range flags, ParseCIDRRange, export SendToDevice
-- `6f8a9cc` feat: add private mode, progress bar fixes, metadata stripping, and core improvements
-
-## v0.4.0 - 2026-05-11
-
-### Highlights
-- **Nerd Font icons**: replaced emoji (✅ ❌ ⏳ ⚠️ ℹ️) with Nerd Font glyphs for a consistent monospace terminal look (`pkg/cli/icons.go`)
-- **Systemd service fix**: removed `ConfigurationDirectory` (caused systemd to own `~/.config/localgo` as root), added explicit XDG env vars, fixed `EnvironmentFile` path; service now starts correctly under `systemd --user`
-- **Fixed env template**: `localgo.env.example` changed from hardcoded `/home/user` to `$HOME/Downloads/localgo`
-- **Go 1.24 → 1.26**: updated Dockerfiles, go.mod, README badge, install script minimum version check, and all CI workflows
-- **Reproducible container builds**: all Dockerfiles now use `-mod=vendor` with vendored source, bypassing module proxy entirely
-- **Removed sqweek/dialog**: native file picker removed; use `--file` flag for sending (CGO-free, smaller binaries, simpler CI)
-
-### Fixed
-- Container health check now uses HTTPS with `--no-check-certificate` (HTTP returns 400 Bad Request)
-- `localgo health` exit code (was 400, now 0)
-- `podman-compose up` healthcheck syntax fixed (`CMD-SHELL` required in compose format)
-
-### Added
-- `localgo info` uses new Nerd Font icon styles
-- All CLI output functions (`PrintSuccess`, `PrintError`, `PrintWarning`, `PrintInfo`, `WriteProgress`, `WriteSuccess`, `WriteWarning`) now use Nerd Font icons
-
-### Refactored
-- `PickFiles()` removed; `localgo send` requires `--file` flag explicitly
-
-## v0.3.6 - 2026-05-04
-
-### Refactored
-- Extracted DTO factory methods to `pkg/config/dto.go`
-- Moved `resolveDuplicateFilename` to `pkg/storage`
-- Added progress bar helper in `pkg/cli/progress.go`
-- Reduced boilerplate across major CLI commands
-
-## v0.3.5 - 2026-03-04
-
-### Highlights
-- Binary renamed from `localgo-cli` to `localgo` — cleaner, simpler invocation
-- Clipboard integration: incoming `text/plain` transfers are now copied to the system clipboard automatically
-- Android arm build targets added to the release pipeline
-- Systemd service hardening with resource limits
-- Help system and all documentation fully audited and updated to match the actual CLI
-
-### Added
-- **Clipboard support**: incoming `text/plain` file transfers are now automatically copied to the system clipboard when a display server is available. Falls back to saving as a `.txt` file on headless systems (`pkg/clipboard`)
-- **`--no-clipboard` flag** on `serve` and `share`: opt out of clipboard behaviour and always save text transfers to disk instead
-- **`LOCALSEND_NO_CLIPBOARD` env var**: persistent alternative to `--no-clipboard`
-- **Android armv7 and armv8 build targets** in the Makefile release pipeline (`GOOS=linux GOARCH=arm GOARM=7` / `GOARM=8`)
-- `localgo help share` and `localgo help devices` now work (both commands were silently missing from `GetCommandHelp`)
-- Global `--verbose` and `--json` flags now documented in `localgo help` output
-- Full env var list (`LOCALSEND_NO_CLIPBOARD`, `LOCALSEND_DEVICE_MODEL`, `LOCALSEND_AUTO_ACCEPT`, `LOCALSEND_LOG_LEVEL`, etc.) shown in `localgo help`
-
-### Changed
-- **Binary renamed**: `localgo-cli` → `localgo` across the entire codebase — directory (`cmd/localgo`), Makefile, install script, systemd units, completions, Docker, CI, and all documentation
-- **`go install` path** updated to `github.com/bethropolis/localgo/cmd/localgo@latest`
-- `help.go` `ShowMainUsage()` COMMANDS list now includes `share` and `devices`
-- `serve` help entry now documents `--interval`, `--auto-accept`, and `--no-clipboard`
-- `send --file` description corrected: "File or directory to send (can be specified multiple times)"
-- Release Makefile target refactored from complex `$(eval …)` macros to a clean shell `for` loop
-- CI release workflow simplified to a single job using `make release`
-- Systemd units tightened: `MemoryMax=128M`, `TasksMax=64`, `CPUSchedulingPolicy=idle`, `IOSchedulingClass=idle`, `Nice=15`, `LimitNOFILE=4096`, `StandardOutput=null`
-
-### Fixed
-- Fixed `localgo help share` and `localgo help devices` incorrectly printing "Unknown command"
-
-### Documentation
-- **CLI Reference** (`docs/CLI_REFERENCE.md`): Fully rewritten. Added complete flag tables for all commands (`serve`, `send`, `discover`, `scan`) and removed phantom flags that didn't exist in the code. Added a Global Flags section.
-- **Configuration** (`docs/CONFIGURATION.md`): Fully updated. Ensured all command flags (like `send --port` and `share --no-clipboard`) are documented. Corrected the default `LOCALSEND_DEVICE_TYPE` to `"desktop"`.
-- **Getting Started** (`docs/GETTING_STARTED.md`): Expanded guides to cover `share`, `devices`, and `info` commands. Added guidance for headless setups, `--no-clipboard` usage, auto-accept scenarios, and a JSON scripting example.
-- **Readme** (`README.md`): Added Clipboard Integration to the features list and documented new environment variables (`LOCALSEND_DEVICE_MODEL`, `LOCALSEND_AUTO_ACCEPT`, `LOCALSEND_NO_CLIPBOARD`, `LOCALSEND_LOG_LEVEL`).
-
-### Commits (v0.3.2..v0.3.5)
-- `6b7a69f` feat: add clipboard copy support for incoming text transfers
-- `2facd93` chore(release): refactor release target and fix android armv7 build
-- `61b041c` feat(release): add android armv7 and armv8 build targets
-- `4358f23` chore: optimize service resources, simplify CI, and tune quiet logging
-- `1ca64b6` fix(scripts): user service by default, fix BUILD_TMP scope, add --mode to uninstall, improve completion and verification
-
----
-
-## v0.3.2 - 2026-03-02
-
-### Highlights
-- Fix multicast test timeout on CI by skipping when multicast delivery is unavailable
-- Improve developer experience: coloured console logging, Air dev workflow, and a reworked Makefile
-- CI hardening: remove flaky Trivy step and pin/update GitHub Actions; bump Go to 1.24
-- Lots of tests added and several bug fixes across discovery, server, and send codepaths
-
-### Added
-- Add many unit tests for CLI, crypto, httputil, server handlers, model mapping, network helpers
-
-### Changed / Improved
-- Coloured human-readable console logging by default and `--json` flag for JSON output
-- Replace logrus with zap for logging internals
-- Rework Makefile with helpful targets: build, release, test variants, dev, and more
-
-### Fixed
-- Skip multicast tests in CI/sandbox when sockets bind but multicast delivery fails; skip on send failure
-- Fix various server issues: data races, path traversal, pin validation, session leaks, and wait for HTTP bind before announcing discovery
-- Fix send/handler issues: proper config passing, upload contexts, and duplicate filename handling
-- Fix model defaults and file metadata mapping
-
-### CI
-- Remove flaky Trivy scanning step from Docker workflow; pin and upgrade Trivy action versions where appropriate
-- Upgrade `docker/build-push-action` to v6 and adjust `codeql-action` version
-- Bump GitHub Actions Go runner to 1.24
-
-### Commits (v0.2.0..v0.3.2)
-- `9a33cfb` fix(discovery): skip instead of fail on multicast delivery timeout in CI
-- `d181cf2` ci(docker): remove Trivy scan
-- `47c9d2a` ci(trivy): switch to stable trivy-action@0.33.1 due to recent installer issues
-- `b2bbf84` ci(docker): restore Trivy scanning step
-- `1c18650` ci(docker): remove Trivy scanning step (flaky external binary install)
-- `806f207` ci(trivy): use tag format without leading 'v' (0.34.1) for aquasecurity/trivy-action
-- `32a276d` fix(ci): bump trivy-action to v0.34.1
-- `8d0ce24` fix(test): skip on send failure in TestMulticastDiscovery_ReceiveAnnouncement
-- `7cb1572` fix(ci): pin trivy-action to 0.30.0, fix codeql action version, upgrade build-push to v6
-- `9dcdab4` fix(ci): skip multicast tests when socket unavailable, bump Go to 1.24 in CI
-- `ca319b8` feat(dx): coloured console logging, improved Makefile, add air + golangci-lint config
-- `ea107c7` refactor(logging): replace logrus with zap
-- `bb3c69e` test: add unit tests for pkg/cli, pkg/crypto, and pkg/httputil
-- `950bbcc` fix(handlers): unify logging with logrus and add duplicate filename handling
-- `f2f3487` fix(server): replace sleep-based ready signal with net.Listen port binding
-- `ee0b69a` test(model): add tests for file DTO mapping and file type detection
-- `11a313b` fix(model): correct default port to 53317, update file metadata mapping, and support MaxBodySize configuration
-- `4359dde` test(network): add tests for local IP parsing and subnet calculation
-- `17ce65f` test(discovery): add tests for multicast UDP discovery
-- `0bcae8a` fix(discovery): fix multicast data races and use proper protocol scheme for http registration
-- `09943f1` test(send): add unit tests for sending files including errors
-- `4a317f4` fix(send): properly pass configuration and handle upload contexts and errors
-- `2a4e4eb` test(server): add comprehensive tests for server handlers
-- `ce1d4f1` fix(server): fix data races, path traversal, pin validation, and session leaks
-- `fe906c1` fix(server): wait for HTTP server to bind before announcing discovery
-- `9bd9125` Add tests, docs and serve auto-accept flag
diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go
index 0e78d07..c72a83a 100644
--- a/cmd/localgo/cmd/config.go
+++ b/cmd/localgo/cmd/config.go
@@ -396,165 +396,6 @@ var configRemoveCmd = &cobra.Command{
},
}
-var configUnsetCmd = &cobra.Command{
- Use: "unset ",
- Short: "Remove a config key (reverts to default)",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- key := strings.ToLower(args[0])
-
- if _, err := validateKey(key); err != nil {
- return err
- }
-
- if !v.InConfig(key) {
- return fmt.Errorf("key %q is not in config file", key)
- }
-
- settings := v.AllSettings()
- delete(settings, key)
-
- // Rebuild the config with the key removed
- for k, val := range settings {
- v.Set(k, val)
- }
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
- }
-
- fmt.Printf("Removed %s from %s\n", key, configPath)
- return nil
- },
-}
-
-var configOpenCmd = &cobra.Command{
- Use: "open",
- Short: "Open config file in system editor",
- RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- configPath := getConfigPath(v)
-
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- // If the file doesn't exist yet, create it
- if _, err := os.Stat(configPath); os.IsNotExist(err) {
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to create config file: %w", err)
- }
- }
-
- editor := os.Getenv("EDITOR")
- if editor == "" {
- switch runtime.GOOS {
- case "windows":
- editor = "notepad"
- case "darwin":
- editor = "nano"
- default:
- editor = "nano"
- for _, e := range []string{"nvim", "vim", "micro", "vi", "nano"} {
- if _, err := exec.LookPath(e); err == nil {
- editor = e
- break
- }
- }
- }
- }
-
- editorCmd := exec.Command(editor, configPath)
- editorCmd.Stdin = os.Stdin
- editorCmd.Stdout = os.Stdout
- editorCmd.Stderr = os.Stderr
-
- if err := editorCmd.Run(); err != nil {
- return fmt.Errorf("editor %q failed: %w", editor, err)
- }
- return nil
- },
-}
-
-var configAddCmd = &cobra.Command{
- Use: "add ",
- Short: "Append a value to a list config key",
- Args: cobra.ExactArgs(2),
- RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- key := strings.ToLower(args[0])
-
- if _, err := validateKey(key); err != nil {
- return err
- }
-
- current := v.GetStringSlice(key)
- current = append(current, args[1])
- v.Set(key, current)
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
- }
-
- fmt.Printf("Added %q to %s in %s\n", args[1], key, configPath)
- return nil
- },
-}
-
-var configRemoveCmd = &cobra.Command{
- Use: "remove ",
- Short: "Remove a value from a list config key",
- Args: cobra.ExactArgs(2),
- RunE: func(cmd *cobra.Command, args []string) error {
- v := newViperForConfig()
- key := strings.ToLower(args[0])
-
- if _, err := validateKey(key); err != nil {
- return err
- }
-
- current := v.GetStringSlice(key)
- filtered := make([]string, 0, len(current))
- removed := false
- for _, item := range current {
- if item == args[1] {
- removed = true
- } else {
- filtered = append(filtered, item)
- }
- }
-
- if !removed {
- return fmt.Errorf("value %q not found in %s", args[1], key)
- }
-
- v.Set(key, filtered)
-
- configPath := getConfigPath(v)
- if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
-
- if err := v.WriteConfigAs(configPath); err != nil {
- return fmt.Errorf("failed to write config: %w", err)
- }
-
- fmt.Printf("Removed %q from %s in %s\n", args[1], key, configPath)
- return nil
- },
-}
-
var configPathCmd = &cobra.Command{
Use: "path",
Short: "Show config file path",
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 413696d..dc844dd 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -129,9 +129,6 @@ func (s *Server) configureRoutes() {
// Root web landing page for browser access (fixes 404 on http://IP:PORT)
s.router.HandleFunc("GET /", downloadHandler.WebShareHandler)
- // Root web landing page for browser access (fixes 404 on http://IP:PORT)
- s.muxRouter.HandleFunc("/", downloadHandler.WebShareHandler).Methods("GET")
-
s.logger.Info("Configured API routes.")
}