diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index d67b978..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}}
@@ -30,6 +32,7 @@ builds:
- linux
- darwin
- windows
+ - android
goarch:
- amd64
- arm64
@@ -37,6 +40,8 @@ builds:
ignore:
- goos: windows
goarch: arm64
+ - goos: android
+ goarch: amd64
# ---------------------------------------------------------------------------
# Archives
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/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
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/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..96a68c9 100644
--- a/cmd/localgo/cmd/root.go
+++ b/cmd/localgo/cmd/root.go
@@ -10,8 +10,6 @@ import (
"github.com/bethropolis/localgo/pkg/help"
"github.com/bethropolis/localgo/pkg/logging"
"github.com/spf13/cobra"
- "github.com/spf13/viper"
- "go.uber.org/zap"
)
var (
@@ -25,7 +23,7 @@ var (
Verbose bool
JSONOutput bool
Cfg *config.Config
- ViperCfg *viper.Viper
+ ViperCfg *config.Source
)
var rootCmd = &cobra.Command{
@@ -50,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 {
- zap.S().Warnf("Failed to read config file: %v", err)
+ 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/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/go.mod b/go.mod
index 592b69b..7841c80 100644
--- a/go.mod
+++ b/go.mod
@@ -9,22 +9,18 @@ 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
- 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 (
- 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,43 +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/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/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/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
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 89a9f3d..3ce4ebe 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=
@@ -51,40 +49,19 @@ 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/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=
@@ -93,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=
@@ -106,19 +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/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=
+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=
@@ -127,59 +96,24 @@ 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/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=
-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/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=
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=
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=
@@ -190,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/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_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_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/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/pkg/config/config.go b/pkg/config/config.go
index 7650d73..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/logging"
"github.com/bethropolis/localgo/pkg/model"
- "github.com/spf13/viper"
- "go.uber.org/zap"
)
const (
@@ -68,15 +67,15 @@ 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)
+func getSecurityDir(src *Source) string {
+ if envDir := src.GetString("security_dir"); 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,31 +109,31 @@ func testDirWritable(dir string) bool {
return true
}
-func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*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,35 +142,35 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*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 {
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)
}
}
- 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)
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)
@@ -182,42 +181,42 @@ func LoadConfig(v *viper.Viper, logger *zap.SugaredLogger) (*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,
@@ -257,7 +256,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..8c8b33e 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -4,12 +4,11 @@ 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()
@@ -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
-}
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 cf4e52f..dc844dd 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -18,37 +18,36 @@ 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"
- "github.com/gorilla/mux"
- "go.uber.org/zap"
)
// Server manages the HTTP/S server lifecycle.
type Server struct {
config *config.Config
httpServer *http.Server
- muxRouter *mux.Router
+ router *http.ServeMux
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 := 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,
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()
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
}
diff --git a/scripts/online-install.sh b/scripts/online-install.sh
index 36ee881..3f9b4b1 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; }
@@ -491,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