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