diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..5e0a6c5 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,43 @@ +name: Deploy Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Prepare docs directory + run: | + cp README.md docs/ + # Fix relative links for Pages (docs/X.md → X.md at root) + sed -i 's|(docs/|(|g' docs/README.md + cp scripts/online-install.sh docs/install.sh + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e5852..116cc05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,115 @@ 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 diff --git a/README.md b/README.md index 41a371a..82df4d3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ A Go implementation of the LocalSend v2.1 protocol for secure, cross-platform fi ### Installation +#### Online (macOS, Linux) +```bash +curl -fsSL https://bethropolis.github.io/localgo/install.sh | bash +``` + #### User installation (recommended) ```bash # clone repo diff --git a/cmd/localgo/cmd/config.go b/cmd/localgo/cmd/config.go index 3973a77..7b75406 100644 --- a/cmd/localgo/cmd/config.go +++ b/cmd/localgo/cmd/config.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -63,11 +64,23 @@ var configSetCmd = &cobra.Command{ existingVal := v.Get(key) switch existingVal.(type) { case int, int64: - v.Set(key, v.GetInt(key)) + val, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid integer value %q: %w", args[1], err) + } + v.Set(key, val) case bool: - v.Set(key, v.GetBool(key)) + val, err := strconv.ParseBool(args[1]) + if err != nil { + return fmt.Errorf("invalid boolean value %q: %w", args[1], err) + } + v.Set(key, val) case float64: - v.Set(key, v.GetFloat64(key)) + val, err := strconv.ParseFloat(args[1], 64) + if err != nil { + return fmt.Errorf("invalid float value %q: %w", args[1], err) + } + v.Set(key, val) default: v.Set(key, args[1]) } diff --git a/cmd/localgo/cmd/devices.go b/cmd/localgo/cmd/devices.go index 20ebb62..44b7b06 100644 --- a/cmd/localgo/cmd/devices.go +++ b/cmd/localgo/cmd/devices.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "slices" "strings" "time" @@ -54,13 +55,9 @@ var devicesCmd = &cobra.Command{ peers = anonymizeDeviceSlice(peers) } - for i := 0; i < len(peers); i++ { - for j := i + 1; j < len(peers); j++ { - if peers[i].LastSeen.Before(peers[j].LastSeen) { - peers[i], peers[j] = peers[j], peers[i] - } - } - } + slices.SortFunc(peers, func(a, b *model.Device) int { + return b.GetLastSeen().Compare(a.GetLastSeen()) + }) titleStyle := cli.HeaderStyle.Padding(0, 1).MarginBottom(1) headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) @@ -83,8 +80,8 @@ var devicesCmd = &cobra.Command{ now := time.Now() for _, d := range peers { lastSeenStr := "Unknown" - if !d.LastSeen.IsZero() { - diff := now.Sub(d.LastSeen) + if !d.GetLastSeen().IsZero() { + diff := now.Sub(d.GetLastSeen()) if diff < 1*time.Minute { lastSeenStr = cli.SuccessStyle.Render("Online") } else if diff < 1*time.Hour { @@ -117,7 +114,7 @@ var devicesCmd = &cobra.Command{ func init() { rootCmd.AddCommand(devicesCmd) devicesCmd.Flags().BoolVar(&devicesjsonOutput, "json", false, "Output in JSON format") - devicesCmd.Flags().BoolVarP(&devicesProbe, "probe", "p", false, "Probe cached devices to verify if they are currently online") + devicesCmd.Flags().BoolVar(&devicesProbe, "probe", false, "Probe cached devices to verify if they are currently online") devicesCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { if h := help.GetCommandHelp("devices"); h != nil { diff --git a/cmd/localgo/cmd/discover.go b/cmd/localgo/cmd/discover.go index aa41b37..3f0e647 100644 --- a/cmd/localgo/cmd/discover.go +++ b/cmd/localgo/cmd/discover.go @@ -3,12 +3,15 @@ package cmd import ( "context" "fmt" + "net" + "slices" "time" + "github.com/bethropolis/localgo/pkg/cli" "github.com/bethropolis/localgo/pkg/discovery" "github.com/bethropolis/localgo/pkg/help" - "github.com/bethropolis/localgo/pkg/cli" "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" @@ -25,15 +28,9 @@ var discoverCmd = &cobra.Command{ Short: "Discover LocalGo devices on the network using multicast", RunE: func(cmd *cobra.Command, args []string) error { - // Increase default timeout for better reliability - discoverTimeout := discovertimeout - if discoverTimeout < 10 { - discoverTimeout = 10 - } - if !discoverquiet { cli.PrintHeader("Discovering devices") - cli.PrintInfo("Timeout: %ds", discoverTimeout) + cli.PrintInfo("Timeout: %ds", discovertimeout) cli.PrintInfo("Multicast group: %s", Cfg.MulticastGroup) cli.PrintInfo("Port: %d", Cfg.Port) cli.PrintInfo("Protocol: %s", func() string { @@ -71,7 +68,7 @@ var discoverCmd = &cobra.Command{ }) // Perform discovery - discoverCtx, cancel := context.WithTimeout(context.Background(), time.Duration(discoverTimeout)*time.Second) + discoverCtx, cancel := context.WithTimeout(context.Background(), time.Duration(discovertimeout)*time.Second) defer cancel() var foundDevices []*model.Device @@ -81,11 +78,11 @@ var discoverCmd = &cobra.Command{ _ = spinner.New(). Title(fmt.Sprintf("Searching for devices on multicast group %s...", Cfg.MulticastGroup)). Action(func() { - foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled, false) + foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.ToMulticastDto(false)) }). Run() } else { - foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled, false) + foundDevices, discErr = discoverySvc.Discover(discoverCtx, Cfg.ToMulticastDto(false)) } if discErr != nil && !discoverquiet { @@ -93,6 +90,46 @@ var discoverCmd = &cobra.Command{ cli.PrintWarning("Discovery completed with warnings: %v", discErr) } + // Fallback: if multicast finds nothing, try HTTP subnet scan + if len(foundDevices) == 0 { + if !discoverquiet { + cli.PrintInfo("Multicast returned no devices. Falling back to HTTP subnet scan...") + } + localIPs, ipErr := network.GetLocalIPAddresses() + if ipErr == nil && len(localIPs) > 0 { + var scanIps []net.IP + for _, ip := range localIPs { + scanIps = append(scanIps, network.GetSubnetIPs(ip)...) + } + registerDto := Cfg.ToRegisterDto() + httpDiscoverer := discovery.NewHTTPDiscovery(nil, registerDto, nil, zap.S()) + + scanCtx, scanCancel := context.WithTimeout(context.Background(), time.Duration(discovertimeout)*time.Second) + defer scanCancel() + + var scanDevices []*model.Device + _ = spinner.New(). + Title(fmt.Sprintf("Scanning %d IP addresses on port %d...", len(scanIps), Cfg.Port)). + Action(func() { + scanDevices, _ = httpDiscoverer.ScanNetwork(scanCtx, scanIps, Cfg.Port) + }). + Run() + + foundDevices = scanDevices + } + + // Filter out the local machine from results + localIPs, _ = network.GetLocalIPAddresses() + foundDevices = slices.DeleteFunc(foundDevices, func(d *model.Device) bool { + for _, local := range localIPs { + if d.IP == local.String() { + return true + } + } + return false + }) + } + if !discoverquiet && len(foundDevices) == 0 { zap.S().Warnf("No devices discovered") cli.PrintWarning("No devices discovered. Check your firewall or network.") @@ -104,7 +141,7 @@ var discoverCmd = &cobra.Command{ func init() { rootCmd.AddCommand(discoverCmd) - discoverCmd.Flags().IntVar(&discovertimeout, "timeout", 5, "Discovery timeout in seconds") + discoverCmd.Flags().IntVar(&discovertimeout, "timeout", 10, "Discovery timeout in seconds") discoverCmd.Flags().BoolVar(&discoverjsonOutput, "json", false, "Output in JSON format") discoverCmd.Flags().BoolVar(&discoverquiet, "quiet", false, "Quiet mode") diff --git a/cmd/localgo/cmd/history.go b/cmd/localgo/cmd/history.go index 3b2772e..f9828b7 100644 --- a/cmd/localgo/cmd/history.go +++ b/cmd/localgo/cmd/history.go @@ -98,7 +98,7 @@ var historyCmd = &cobra.Command{ for _, entry := range displayEntries { senderAlias := entry.SenderAlias - if Cfg.Private { + if Cfg.Private && senderAlias != "Anonymous" { senderAlias = cli.AnonymizeString(entry.SenderAlias) } tStr := entry.Timestamp.Local().Format("01-02 15:04") diff --git a/cmd/localgo/cmd/root.go b/cmd/localgo/cmd/root.go index 745fa1f..2fa9b44 100644 --- a/cmd/localgo/cmd/root.go +++ b/cmd/localgo/cmd/root.go @@ -15,6 +15,7 @@ import ( var ( versionFlag bool privateMode bool + noColor bool ) var ( @@ -34,7 +35,11 @@ var rootCmd = &cobra.Command{ os.Exit(0) } - logger := logging.Init(Verbose, JSONOutput) + if noColor || os.Getenv("NO_COLOR") != "" { + noColor = true + } + + logger := logging.Init(Verbose, JSONOutput, noColor) ViperCfg = config.InitViper() if cfgFile != "" { @@ -73,6 +78,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/localgo/config.yaml)") rootCmd.PersistentFlags().BoolVar(&Verbose, "verbose", false, "Enable debug logging") rootCmd.PersistentFlags().BoolVar(&JSONOutput, "json", false, "Enable JSON log output") + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output") rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { help.ShowMainUsage() diff --git a/cmd/localgo/cmd/scan.go b/cmd/localgo/cmd/scan.go index 637bc43..4e10911 100644 --- a/cmd/localgo/cmd/scan.go +++ b/cmd/localgo/cmd/scan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "slices" "time" "github.com/bethropolis/localgo/pkg/cli" @@ -29,12 +30,6 @@ var scanCmd = &cobra.Command{ Short: "Scan the network for LocalGo devices using HTTP", RunE: func(cmd *cobra.Command, args []string) error { - // Increase default timeout for better reliability - scanTimeout := scantimeout - if scanTimeout < 15 { - scanTimeout = 15 - } - scanPort := Cfg.Port if scanport > 0 { scanPort = scanport @@ -49,7 +44,7 @@ var scanCmd = &cobra.Command{ } ips = parsedIPs if !scanquiet { - cli.PrintHeader(fmt.Sprintf("Scanning CIDR range %s on port %d (timeout: %ds)...", scanrange, scanPort, scanTimeout)) + cli.PrintHeader(fmt.Sprintf("Scanning CIDR range %s on port %d (timeout: %ds)...", scanrange, scanPort, scantimeout)) cli.PrintInfo("Scanning %d IP addresses...", len(ips)) cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") } @@ -60,13 +55,23 @@ var scanCmd = &cobra.Command{ return fmt.Errorf("failed to get local network IPs: %w", err) } + // Prioritize the subnet connected to the default gateway + if gwIP, err := network.PrimaryLANIP(); err == nil { + for i, ip := range localIPs { + if ip.Equal(gwIP) && i > 0 { + localIPs[0], localIPs[i] = localIPs[i], localIPs[0] + break + } + } + } + for _, ip := range localIPs { subnetIPs := network.GetSubnetIPs(ip) ips = append(ips, subnetIPs...) } if !scanquiet { - cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scanTimeout)) + cli.PrintHeader(fmt.Sprintf("Scanning network on port %d (timeout: %ds)...", scanPort, scantimeout)) cli.PrintInfo("Scanning %d IP addresses (derived from %d local interfaces)...", len(ips), len(localIPs)) cli.PrintInfo("Protocols: HTTPS first, then HTTP fallback") } @@ -76,7 +81,7 @@ var scanCmd = &cobra.Command{ httpDiscoverer := discovery.NewHTTPDiscovery(nil, Cfg.ToRegisterDto(), nil, zap.S()) // Perform scan - scanCtx, cancel := context.WithTimeout(context.Background(), time.Duration(scanTimeout)*time.Second) + scanCtx, cancel := context.WithTimeout(context.Background(), time.Duration(scantimeout)*time.Second) defer cancel() var foundDevices []*model.Device @@ -98,6 +103,17 @@ var scanCmd = &cobra.Command{ cli.PrintWarning("Scan completed with warnings: %v", scanErr) } + // Filter out the local machine from results + localIPs, _ := network.GetLocalIPAddresses() + foundDevices = slices.DeleteFunc(foundDevices, func(d *model.Device) bool { + for _, local := range localIPs { + if d.IP == local.String() { + return true + } + } + return false + }) + if !scanquiet && len(foundDevices) == 0 { zap.S().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 2bf4204..e6ad8fe 100644 --- a/cmd/localgo/cmd/send.go +++ b/cmd/localgo/cmd/send.go @@ -17,7 +17,6 @@ import ( "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" "github.com/bethropolis/localgo/pkg/send" - "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/spinner" "github.com/spf13/cobra" "go.uber.org/zap" @@ -36,38 +35,12 @@ var ( ) var sendCmd = &cobra.Command{ - Use: "send", - Short: "Send a file to another LocalGo device", + Use: "send", + Short: "Send a file to another LocalGo device", + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { files := sendfiles - // Interactive fallback: if no files specified, check clipboard for text content - if len(files) == 0 && !sendclipboard { - if clipboard.Available() { - text, err := clipboard.Read() - if err == nil && strings.TrimSpace(text) != "" { - preview := strings.ReplaceAll(text, "\n", " ") - if len(preview) > 50 { - preview = preview[:50] + "…" - } - - var useClip bool = true - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("No files specified. Send clipboard content instead?"). - Description(fmt.Sprintf("Current clipboard: %q", preview)). - Value(&useClip), - ), - ).WithTheme(huh.ThemeCharm()) - - if err := form.Run(); err == nil && useClip { - sendclipboard = true - } - } - } - } - if sendclipboard { text, err := clipboard.Read() if err != nil { @@ -92,7 +65,14 @@ var sendCmd = &cobra.Command{ } if len(files) == 0 { - return fmt.Errorf("no file specified: use positional args, --file flag, or --clipboard") + selected, err := cli.LaunchFilePicker() + if err == nil && selected != "" { + files = []string{selected} + } + } + + if len(files) == 0 { + return fmt.Errorf("no file specified: use --file flag, --clipboard, or select from the file browser") } for _, file := range files { @@ -165,6 +145,8 @@ var sendCmd = &cobra.Command{ } target := sendto + var selectedDevice *model.Device + if target == "" { sendConfig := discovery.DefaultServiceConfig() sendConfig.MulticastConfig.InterfaceName = Cfg.MulticastInterface @@ -178,8 +160,8 @@ var sendCmd = &cobra.Command{ devices, discErr = discovery.DiscoverDevices( context.Background(), sendConfig, - Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, - Cfg.DeviceModel, Cfg.HttpsEnabled, + Cfg, + Cfg.HttpsEnabled, ) }). Run() @@ -192,6 +174,16 @@ var sendCmd = &cobra.Command{ if len(devices) == 0 { localIPs, ipErr := network.GetLocalIPAddresses() if ipErr == nil && len(localIPs) > 0 { + // Prioritize the subnet connected to the default gateway + if gwIP, err := network.PrimaryLANIP(); err == nil { + for i, ip := range localIPs { + if ip.Equal(gwIP) && i > 0 { + localIPs[0], localIPs[i] = localIPs[i], localIPs[0] + break + } + } + } + _ = spinner.New(). Title("No devices via multicast. Scanning local subnets..."). Action(func() { @@ -223,6 +215,7 @@ var sendCmd = &cobra.Command{ } target = selected.Alias sendport = selected.Port + selectedDevice = selected } if sendalias != "" { @@ -242,17 +235,24 @@ var sendCmd = &cobra.Command{ cli.PrintInfo("- %s (%s)", filepath.Base(file), cli.FormatBytes(fileInfo.Size())) } } - cli.PrintInfo("To: %s", target) fromAlias := Cfg.Alias if Cfg.Private { fromAlias = "Anonymous" } - cli.PrintInfo("From: %s", fromAlias) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(sendtimeout)*time.Second) defer cancel() - err := send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + var err error + 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()) + } else { + cli.PrintInfo("To: %s", target) + cli.PrintInfo("From: %s", fromAlias) + err = send.SendFiles(ctx, Cfg, files, target, sendport, zap.S()) + } 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 a162fe4..77ec048 100644 --- a/cmd/localgo/cmd/serve.go +++ b/cmd/localgo/cmd/serve.go @@ -163,7 +163,7 @@ var serveCmd = &cobra.Command{ }) // Start discovery - err := discoverySvc.Start(ctx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled) + err := discoverySvc.Start(ctx, Cfg.ToMulticastDto(false)) if err != nil { return fmt.Errorf("discovery service failed: %w", err) } diff --git a/cmd/localgo/cmd/share.go b/cmd/localgo/cmd/share.go index 3f31a2c..37f361f 100644 --- a/cmd/localgo/cmd/share.go +++ b/cmd/localgo/cmd/share.go @@ -1,10 +1,8 @@ package cmd import ( - "archive/zip" "context" "fmt" - "io" "net/http" "os" "os/signal" @@ -28,6 +26,7 @@ var ( sharefiles []string shareport int shareuseHTTP bool + shareuseHTTPS bool sharepin string sharealias string shareautoAccept bool @@ -47,15 +46,28 @@ var shareCmd = &cobra.Command{ files := sharefiles if len(files) == 0 { - return fmt.Errorf("file parameter is required (use --file)") + selected, err := cli.LaunchFilePicker() + if err != nil { + return fmt.Errorf("file picker failed: %w", err) + } + if selected == "" { + return fmt.Errorf("no file selected") + } + files = []string{selected} + // Auto-enable zipping if the user selected a directory without --zip + if info, statErr := os.Stat(selected); statErr == nil && info.IsDir() && !sharezip { + sharezip = true + } } // Apply overrides if shareport > 0 { Cfg.Port = shareport } - if shareuseHTTP { - Cfg.HttpsEnabled = false + // Browser download API must use HTTP (browsers reject self-signed certs) + Cfg.HttpsEnabled = false + if shareuseHTTPS { + Cfg.HttpsEnabled = true } if sharepin != "" { Cfg.PIN = sharepin @@ -144,7 +156,7 @@ var shareCmd = &cobra.Command{ displayName := filepath.Base(file) if sharezip && strings.HasSuffix(file, ".zip") { - displayName = filepath.Base(file[:len(file)-4]) + ".zip" + displayName = strings.TrimSuffix(filepath.Base(file), ".zip") + ".zip" } fileDto := model.FileDto{ @@ -183,7 +195,21 @@ var shareCmd = &cobra.Command{ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - // Initialize discovery service with Download: true + // Start server first to determine the actual port + serverErrChan := make(chan error, 1) + serverReadyChan := make(chan struct{}, 1) + go func() { + serverErrChan <- srv.Start(ctx, serverReadyChan) + }() + + // Wait for server to be ready + select { + case err := <-serverErrChan: + return fmt.Errorf("server failed: %w", err) + case <-serverReadyChan: + } + + // Initialize discovery service AFTER server is ready (Cfg.Port may have changed if port was busy) discoverySvcConfig := discovery.DefaultServiceConfig() discoverySvcConfig.MulticastConfig.Port = Cfg.Port discoverySvcConfig.MulticastConfig.MulticastAddr = fmt.Sprintf("%s:%d", Cfg.MulticastGroup, Cfg.Port) @@ -200,22 +226,8 @@ var shareCmd = &cobra.Command{ discoverySvc := discovery.NewService(discoverySvcConfig, multicast, zap.S()) discoverySvc.SetPeerCache(peerCache) - // Start server first - serverErrChan := make(chan error, 1) - serverReadyChan := make(chan struct{}, 1) - go func() { - serverErrChan <- srv.Start(ctx, serverReadyChan) - }() - - // Wait for server to be ready - select { - case err := <-serverErrChan: - return fmt.Errorf("server failed: %w", err) - case <-serverReadyChan: - } - // Start discovery AFTER server is ready - err = discoverySvc.Start(ctx, Cfg.Alias, Cfg.Port, Cfg.SecurityContext.CertificateHash, Cfg.DeviceType, Cfg.DeviceModel, Cfg.HttpsEnabled) + err = discoverySvc.Start(ctx, Cfg.ToMulticastDto(true)) if err != nil { return fmt.Errorf("discovery service failed: %w", err) } @@ -257,7 +269,8 @@ func init() { rootCmd.AddCommand(shareCmd) shareCmd.Flags().StringSliceVar(&sharefiles, "file", []string{}, "File or directory to share") shareCmd.Flags().IntVar(&shareport, "port", 0, "Port to run the server on") - shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Use HTTP instead of HTTPS") + shareCmd.Flags().BoolVar(&shareuseHTTP, "http", false, "Deprecated (HTTP is now default for share)") + shareCmd.Flags().BoolVar(&shareuseHTTPS, "https", false, "Use HTTPS (browsers will reject self-signed certs)") shareCmd.Flags().StringVar(&sharepin, "pin", "", "PIN for authentication") shareCmd.Flags().StringVar(&sharealias, "alias", "", "Device alias") shareCmd.Flags().BoolVar(&shareautoAccept, "auto-accept", false, "Auto-accept incoming files") @@ -276,67 +289,4 @@ func init() { }) } -func zipDirToTemp(dir string) (string, error) { - baseName := filepath.Base(dir) - if baseName == "." || baseName == "/" { - baseName = "archive" - } - zipPath, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") - if err != nil { - return "", fmt.Errorf("failed to create temp zip: %w", err) - } - zipPath.Close() - zipPathName := zipPath.Name() - - fZ, err := os.OpenFile(zipPathName, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) - if err != nil { - os.Remove(zipPathName) - return "", fmt.Errorf("failed to reopen temp zip: %w", err) - } - zipWriter := zip.NewWriter(fZ) - - err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - rel, err := filepath.Rel(dir, path) - if err != nil { - rel = info.Name() - } - rel = filepath.ToSlash(rel) - - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Name = rel - header.Method = zip.Deflate - w, err := zipWriter.CreateHeader(header) - if err != nil { - return err - } - f, err := os.Open(path) - if err != nil { - return err - } - _, err = io.Copy(w, f) - f.Close() - return err - }) - if err != nil { - zipWriter.Close() - os.Remove(zipPathName) - return "", err - } - - if err := zipWriter.Close(); err != nil { - os.Remove(zipPathName) - return "", err - } - - return zipPathName, nil -} diff --git a/cmd/localgo/cmd/share_zip.go b/cmd/localgo/cmd/share_zip.go new file mode 100644 index 0000000..0eaaac5 --- /dev/null +++ b/cmd/localgo/cmd/share_zip.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" +) + +func zipDirToTemp(dir string) (string, error) { + baseName := filepath.Base(dir) + if baseName == "." || baseName == "/" { + baseName = "archive" + } + zipFile, err := os.CreateTemp("", "localgo-"+baseName+"-*.zip") + if err != nil { + return "", fmt.Errorf("failed to create temp zip: %w", err) + } + zipPathName := zipFile.Name() + zipWriter := zip.NewWriter(zipFile) + + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + rel = info.Name() + } + rel = filepath.ToSlash(rel) + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = rel + header.Method = zip.Deflate + + w, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + err = func() error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }() + return err + }) + if err != nil { + zipWriter.Close() + zipFile.Close() + os.Remove(zipPathName) + return "", err + } + + if err := zipWriter.Close(); err != nil { + zipFile.Close() + os.Remove(zipPathName) + return "", err + } + + if err := zipFile.Close(); err != nil { + os.Remove(zipPathName) + return "", err + } + + return zipPathName, nil +} diff --git a/cmd/localgo/cmd/utils.go b/cmd/localgo/cmd/utils.go index 353aa58..9388136 100644 --- a/cmd/localgo/cmd/utils.go +++ b/cmd/localgo/cmd/utils.go @@ -22,9 +22,17 @@ func padRight(str string, length int) string { func anonymizeDeviceSlice(devices []*model.Device) []*model.Device { out := make([]*model.Device, len(devices)) for i, d := range devices { - copy := *d - copy.Alias = cli.AnonymizedAlias(d) - out[i] = © + out[i] = &model.Device{ + IP: d.IP, + Version: d.Version, + Port: d.Port, + Alias: cli.AnonymizedAlias(d), + Protocol: d.Protocol, + Fingerprint: d.Fingerprint, + DeviceModel: d.DeviceModel, + DeviceType: d.DeviceType, + Download: d.Download, + } } return out } diff --git a/cmd/localgo/main_test.go b/cmd/localgo/main_test.go index 06ab7d0..7ae2c49 100644 --- a/cmd/localgo/main_test.go +++ b/cmd/localgo/main_test.go @@ -1 +1,20 @@ package main + +import ( + "testing" +) + +func TestVersionVars(t *testing.T) { + if Version == "" { + t.Error("Version should not be empty") + } +} + +func TestBuildVars(t *testing.T) { + if GitCommit == "" { + t.Error("GitCommit should not be empty") + } + if BuildDate == "" { + t.Error("BuildDate should not be empty") + } +} diff --git a/docs/_coverpage.md b/docs/_coverpage.md new file mode 100644 index 0000000..8ada183 --- /dev/null +++ b/docs/_coverpage.md @@ -0,0 +1,11 @@ +# LocalGo + +**LocalSend v2.1 Protocol — LAN file transfer CLI** + +One-line installation: + +```bash +curl -fsSL https://bethropolis.github.io/localgo/install.sh | bash +``` + +[Get Started](README.md) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..98d4e91 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,31 @@ + + + + + + + LocalGo — LocalSend v2.1 CLI + + + +
+ + + + + diff --git a/go.mod b/go.mod index f2af99d..7b4b15e 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,24 @@ module github.com/bethropolis/localgo -go 1.26 +go 1.26.2 require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 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/spf13/cobra v1.9.1 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.10.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.38.0 + golang.org/x/sys v0.41.0 ) require ( @@ -24,8 +27,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -71,7 +72,8 @@ require ( 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/text v0.23.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7388c2f..59f9582 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 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= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -147,12 +149,14 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.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= @@ -169,14 +173,16 @@ 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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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= diff --git a/pkg/cli/filepicker.go b/pkg/cli/filepicker.go new file mode 100644 index 0000000..18481d2 --- /dev/null +++ b/pkg/cli/filepicker.go @@ -0,0 +1,62 @@ +package cli + +import ( + "github.com/charmbracelet/bubbles/filepicker" + tea "github.com/charmbracelet/bubbletea" +) + +// FilePickerModel wraps bubbles/filepicker.Model as a tea.Model for TUI file selection. +type FilePickerModel struct { + fp filepicker.Model + File string + quit bool +} + +func (m FilePickerModel) Init() tea.Cmd { + return m.fp.Init() +} + +func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quit = true + return m, tea.Quit + } + } + var cmd tea.Cmd + m.fp, cmd = m.fp.Update(msg) + if m.fp.Path != "" { + m.File = m.fp.Path + return m, tea.Quit + } + return m, cmd +} + +func (m FilePickerModel) View() string { + if m.quit { + return "" + } + return m.fp.View() +} + +// LaunchFilePicker opens an interactive TUI file browser and returns the selected file path. +// Returns empty string if the user cancelled. +func LaunchFilePicker() (string, error) { + fp := filepicker.New() + fp.DirAllowed = true + fp.FileAllowed = true + fp.ShowHidden = false + + m := FilePickerModel{fp: fp} + p := tea.NewProgram(m) + result, err := p.Run() + if err != nil { + return "", err + } + if picked, ok := result.(FilePickerModel); ok { + return picked.File, nil + } + return "", nil +} diff --git a/pkg/cli/format.go b/pkg/cli/format.go new file mode 100644 index 0000000..b12e336 --- /dev/null +++ b/pkg/cli/format.go @@ -0,0 +1,64 @@ +package cli + +import ( + "crypto/sha256" + "fmt" + "time" + + "github.com/bethropolis/localgo/pkg/model" +) + +// AnonymizedAlias returns a stable "Device #XXXXXXXX" identifier from a device's fingerprint. +func AnonymizedAlias(device *model.Device) string { + if device == nil || device.Fingerprint == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(device.Fingerprint)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + +// AnonymizeString returns a stable "Device #XXXXXXXX" identifier from any string. +func AnonymizeString(s string) string { + if s == "" { + return "Device #00000000" + } + h := sha256.Sum256([]byte(s)) + return fmt.Sprintf("Device #%08x", h[:4]) +} + +// TruncateString truncates a string to maxLen characters +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// FormatBytes formats bytes in human readable format +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"} + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit && exp < len(suffixes)-1; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), suffixes[exp]) +} + +// FormatDuration formats duration in human readable format +func FormatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%.1fm", d.Minutes()) + } + return fmt.Sprintf("%.1fh", d.Hours()) +} diff --git a/pkg/cli/notify.go b/pkg/cli/notify.go new file mode 100644 index 0000000..d088607 --- /dev/null +++ b/pkg/cli/notify.go @@ -0,0 +1,27 @@ +package cli + +import ( + "os" + + "github.com/gen2brain/beeep" +) + +// Notify sends a native desktop notification. Icon is empty (system default). +// No-op in container environments. +func Notify(title, body string) { + if IsContainer() { + return + } + beeep.Notify(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 + } + if os.Getenv("container") != "" { + return true + } + return false +} diff --git a/pkg/cli/output.go b/pkg/cli/output.go index eff89af..f59d22f 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -12,7 +12,6 @@ import ( "github.com/bethropolis/localgo/pkg/model" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/gen2brain/beeep" ) // OutputFormat represents the output format type @@ -210,149 +209,6 @@ func (ow *OutputWriter) writeJSON(data interface{}) error { return encoder.Encode(data) } -// Helper functions - -// AnonymizedAlias returns a stable "Device #XXXXXXXX" identifier from a device's fingerprint. -func AnonymizedAlias(device *model.Device) string { - if device == nil || device.Fingerprint == "" { - return "Device #00000000" - } - h := sha256.Sum256([]byte(device.Fingerprint)) - return fmt.Sprintf("Device #%08x", h[:4]) -} - -// AnonymizeString returns a stable "Device #XXXXXXXX" identifier from any string. -func AnonymizeString(s string) string { - if s == "" { - return "Device #00000000" - } - h := sha256.Sum256([]byte(s)) - return fmt.Sprintf("Device #%08x", h[:4]) -} - -// TruncateString truncates a string to maxLen characters -func TruncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} - -// FormatBytes formats bytes in human readable format -func FormatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -// FormatDuration formats duration in human readable format -func FormatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } - if d < time.Hour { - return fmt.Sprintf("%.1fm", d.Minutes()) - } - return fmt.Sprintf("%.1fh", d.Hours()) -} - -// Notify sends a native desktop notification. Icon is empty (system default). -// No-op in container environments. -func Notify(title, body string) { - if IsContainer() { - return - } - beeep.Notify(title, body, "") -} - -// ProgressBar represents a simple progress bar -type ProgressBar struct { - total int64 - current int64 - width int - prefix string -} - -// NewProgressBar creates a new progress bar -func NewProgressBar(total int64, prefix string) *ProgressBar { - return &ProgressBar{ - total: total, - width: 50, - prefix: prefix, - } -} - -// Update updates the progress bar -func (pb *ProgressBar) Update(current int64) { - pb.current = current - pb.render() -} - -// Finish completes the progress bar -func (pb *ProgressBar) Finish() { - pb.current = pb.total - pb.render() - fmt.Println() -} - -// render renders the progress bar -func (pb *ProgressBar) render() { - percent := float64(pb.current) / float64(pb.total) - filled := int(percent * float64(pb.width)) - - bar := strings.Repeat("█", filled) + strings.Repeat("░", pb.width-filled) - - fmt.Printf("\r%s [%s] %.1f%% (%s/%s)", - pb.prefix, - bar, - percent*100, - FormatBytes(pb.current), - FormatBytes(pb.total)) -} - -// Standalone Print helpers - -func PrintSuccess(format string, a ...any) { - fmt.Println(SuccessStyle.Render(IconCheck + " " + fmt.Sprintf(format, a...))) -} - -func PrintError(format string, a ...any) { - fmt.Println(ErrorStyle.Render(IconCross + " " + fmt.Sprintf(format, a...))) -} - -func PrintWarning(format string, a ...any) { - fmt.Println(WarningStyle.Render(IconWarning + " " + fmt.Sprintf(format, a...))) -} - -func PrintInfo(format string, a ...any) { - fmt.Println(InfoStyle.Render(IconInfo + " " + fmt.Sprintf(format, a...))) -} - -func PrintHeader(text string) { - fmt.Println(HeaderStyle.Render(text)) -} - -// IsContainer returns true if LocalGo is running inside a Docker/Podman container. -func IsContainer() bool { - if _, err := os.Stat("/.dockerenv"); err == nil { - return true - } - if os.Getenv("container") != "" { - return true - } - return false -} - // PickDevice presents an interactive TUI to select a device. Returns the selected device or nil if canceled. // When private is true, device aliases are anonymized in the selection list. func PickDevice(devices []*model.Device, private bool) *model.Device { @@ -362,9 +218,6 @@ func PickDevice(devices []*model.Device, private bool) *model.Device { if len(devices) == 0 { return nil } - if len(devices) == 1 { - return devices[0] - } var selected *model.Device options := make([]huh.Option[*model.Device], len(devices)) diff --git a/pkg/cli/print.go b/pkg/cli/print.go new file mode 100644 index 0000000..ace48f2 --- /dev/null +++ b/pkg/cli/print.go @@ -0,0 +1,23 @@ +package cli + +import "fmt" + +func PrintSuccess(format string, a ...any) { + fmt.Println(SuccessStyle.Render(IconCheck + " " + fmt.Sprintf(format, a...))) +} + +func PrintError(format string, a ...any) { + fmt.Println(ErrorStyle.Render(IconCross + " " + fmt.Sprintf(format, a...))) +} + +func PrintWarning(format string, a ...any) { + fmt.Println(WarningStyle.Render(IconWarning + " " + fmt.Sprintf(format, a...))) +} + +func PrintInfo(format string, a ...any) { + fmt.Println(InfoStyle.Render(IconInfo + " " + fmt.Sprintf(format, a...))) +} + +func PrintHeader(text string) { + fmt.Println(HeaderStyle.Render(text)) +} diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index 7c1d982..b195261 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -10,18 +10,16 @@ import ( ) type MultiProgress struct { - pool *mpb.Progress - barCount int64 - bars []*mpb.Bar - mu sync.Mutex + pool *mpb.Progress + bars []*mpb.Bar + mu sync.Mutex } -func NewMultiProgress(totalFiles int64) *MultiProgress { +func NewMultiProgress(_ int64) *MultiProgress { return &MultiProgress{ pool: mpb.New( mpb.WithOutput(os.Stderr), ), - barCount: totalFiles, } } @@ -60,8 +58,13 @@ func (mp *MultiProgress) ForceComplete() { func (mp *MultiProgress) Wait() { mp.pool.Wait() - // Clear progress bar lines from scrollback - for i := int64(0); i < mp.barCount; i++ { + + mp.mu.Lock() + barsRendered := len(mp.bars) + mp.mu.Unlock() + + // Clear only the lines with actual rendered progress bars + for i := 0; i < barsRendered; i++ { fmt.Fprintf(os.Stderr, "\033[F\033[K") } fmt.Fprintf(os.Stderr, "%s Files transferred successfully\n", IconCheck) diff --git a/pkg/clipboard/clipboard_other.go b/pkg/clipboard/clipboard_other.go index c3c92bd..b8bced5 100644 --- a/pkg/clipboard/clipboard_other.go +++ b/pkg/clipboard/clipboard_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !darwin && !windows +//go:build !linux && !darwin && !windows && !freebsd package clipboard diff --git a/pkg/clipboard/clipboard_linux.go b/pkg/clipboard/clipboard_unix.go similarity index 89% rename from pkg/clipboard/clipboard_linux.go rename to pkg/clipboard/clipboard_unix.go index 830bf2f..b42e4e0 100644 --- a/pkg/clipboard/clipboard_linux.go +++ b/pkg/clipboard/clipboard_unix.go @@ -1,10 +1,10 @@ -//go:build linux +//go:build linux || freebsd package clipboard import "os/exec" -// detect probes for available clipboard tools on Linux. +// detect probes for available clipboard tools on Linux and FreeBSD. // Prefers Wayland (wl-copy) when WAYLAND_DISPLAY is set, then X11 tools. func detect() *clipProvider { // Wayland diff --git a/pkg/config/config.go b/pkg/config/config.go index 20fb6c7..ff262f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,23 +1,24 @@ package config import ( - "github.com/spf13/viper" "crypto/rand" "fmt" "os" "path/filepath" "strconv" - "time" + + mathrand "math/rand/v2" "github.com/bethropolis/localgo/pkg/crypto" "github.com/bethropolis/localgo/pkg/model" + "github.com/spf13/viper" "go.uber.org/zap" ) const ( DefaultPort = 53317 DefaultMulticastGroup = "224.0.0.167" - ProtocolVersion = "2.1" + ProtocolVersion = "2.0" DefaultSecurityDir = ".localgo_security" DefaultSecurityFile = "context.json" ) @@ -217,12 +218,12 @@ func generateRandomID(length int) string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" result := make([]byte, length) if _, err := rand.Read(result); err != nil { - timeBasedID := strconv.FormatInt(time.Now().UnixNano(), 10) - if len(timeBasedID) >= length { - return timeBasedID[:length] - } - for i := 0; i < length; i++ { - result[i] = chars[time.Now().UnixNano()%int64(len(chars))] + // If crypto/rand fails, use math/rand/v2 seeded from crypto/rand + var seed [32]byte + rand.Read(seed[:]) // best-effort seed + r := newRandFromSeed(seed) + for i := range result { + result[i] = chars[r.IntN(len(chars))] } return string(result) } @@ -232,6 +233,14 @@ func generateRandomID(length int) string { return string(result) } +func newRandFromSeed(seed [32]byte) *mathrand.Rand { + var rngSeed uint64 + for i := 0; i < 8 && i < len(seed); i++ { + rngSeed |= uint64(seed[i]) << (i * 8) + } + return mathrand.New(mathrand.NewPCG(rngSeed, uint64(seed[0]))) +} + // ToRegisterDto converts Config to model.RegisterDto for discovery requests func (c *Config) ToRegisterDto() model.RegisterDto { protocol := model.ProtocolTypeHTTP @@ -246,7 +255,7 @@ func (c *Config) ToRegisterDto() model.RegisterDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.RegisterDto{ Alias: alias, @@ -272,7 +281,7 @@ func (c *Config) ToInfoDto() model.InfoDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.InfoDto{ Alias: alias, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 70ced83..5e4c428 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -266,8 +266,8 @@ func TestConfig_Constants(t *testing.T) { t.Errorf("Expected DefaultMulticastGroup '224.0.0.167', got '%s'", DefaultMulticastGroup) } - if ProtocolVersion != "2.1" { - t.Errorf("Expected ProtocolVersion '2.1', got '%s'", ProtocolVersion) + if ProtocolVersion != "2.0" { + t.Errorf("Expected ProtocolVersion '2.0', got '%s'", ProtocolVersion) } } diff --git a/pkg/config/dto.go b/pkg/config/dto.go index 2a2b9e4..a9fe8d2 100644 --- a/pkg/config/dto.go +++ b/pkg/config/dto.go @@ -26,7 +26,7 @@ func (c *Config) ToMulticastDto(download bool) model.MulticastDto { if c.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } return model.MulticastDto{ Alias: alias, diff --git a/pkg/discovery/announce.go b/pkg/discovery/announce.go new file mode 100644 index 0000000..7621f03 --- /dev/null +++ b/pkg/discovery/announce.go @@ -0,0 +1,173 @@ +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "net" + "strings" + "time" + + "github.com/bethropolis/localgo/pkg/model" +) + +// SendDiscoveryAnnouncement sends a multicast announcement +func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { + announcementDto := md.dto + announcementDto.Announce = true + + data, err := json.Marshal(announcementDto) + if err != nil { + return fmt.Errorf("failed to marshal announcement: %w", err) + } + + addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) + if err != nil { + return fmt.Errorf("failed to resolve multicast address: %w", err) + } + + var localAddr *net.UDPAddr + if md.config.InterfaceName != "" { + iface, err := net.InterfaceByName(md.config.InterfaceName) + if err == nil { + addrs, err := iface.Addrs() + if err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { + localAddr = &net.UDPAddr{IP: ipnet.IP} + break + } + } + } + } + } + + conn, err := net.DialUDP("udp4", localAddr, addr) + if err != nil { + return fmt.Errorf("failed to create UDP connection: %w", err) + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send multicast announcement: %w", err) + } + + md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", + md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) + return nil +} + +// SendDiscoveryResponse sends a response to a specific address +func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, targetDevice *model.Device) error { + // 1. Try HTTP Response first + if md.httpDiscoverer != nil && targetDevice != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + scheme := "http" + if targetDevice.Protocol == model.ProtocolTypeHTTPS { + scheme = "https" + } + + _, err := md.httpDiscoverer.RegisterWithDevice(ctx, net.ParseIP(targetDevice.IP), targetDevice.Port, scheme) + if err == nil { + md.logger.Debugf("Sent discovery response via HTTP to %s:%d", targetDevice.IP, targetDevice.Port) + return nil + } + } + + // 2. Fallback to UDP — send via multicast so every listener sees the response + responseDto := md.dto + responseDto.Announce = false + + data, err := json.Marshal(responseDto) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + respAddr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) + if err != nil { + return fmt.Errorf("failed to resolve multicast address: %w", err) + } + + conn, err := net.DialUDP("udp4", nil, respAddr) + if err != nil { + return fmt.Errorf("failed to create UDP connection: %w", err) + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("failed to send discovery response: %w", err) + } + + md.logger.Debugf("Sent discovery response via multicast to %s", md.config.MulticastAddr) + return nil +} + +func (md *MulticastDiscovery) listenLoop(ctx context.Context, conn net.PacketConn) { + buffer := make([]byte, 2048) + + for { + select { + case <-ctx.Done(): + return + default: + } + + if md.closed.Load() { + return + } + + if err := conn.SetReadDeadline(time.Now().Add(md.config.ListenTimeout)); err != nil { + md.logger.Warnf("Failed to set read deadline: %v", err) + } + + n, addr, err := conn.ReadFrom(buffer) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + if strings.Contains(err.Error(), "use of closed network connection") { + return + } + continue + } + + if err := md.handlePacket(buffer[:n], addr); err != nil { + md.logger.Warnf("Failed to handle multicast packet: %v", err) + } + } +} + +func (md *MulticastDiscovery) handlePacket(data []byte, addr net.Addr) error { + var dto model.MulticastDto + if err := json.Unmarshal(data, &dto); err != nil { + return fmt.Errorf("failed to unmarshal packet: %w", err) + } + + if dto.Fingerprint == md.dto.Fingerprint { + return nil + } + + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return fmt.Errorf("unexpected address type: %T", addr) + } + + device := model.FromMulticastDto(dto, udpAddr.IP) + + md.logger.Debugf("Discovered raw device via multicast: %s (%s) at %s:%d", + device.Alias, getShortFingerprint(device.Fingerprint), device.IP, device.Port) + + md.updateDevice(device) + + if dto.Announce { + if err := md.SendDiscoveryResponse(udpAddr, device); err != nil { + md.logger.Warnf("Failed to send discovery response: %v", err) + } + } + + return nil +} diff --git a/pkg/discovery/config.go b/pkg/discovery/config.go new file mode 100644 index 0000000..2497766 --- /dev/null +++ b/pkg/discovery/config.go @@ -0,0 +1,22 @@ +package discovery + +import "time" + +// MulticastConfig contains settings for multicast discovery +type MulticastConfig struct { + MulticastAddr string + Port int + InterfaceName string + AnnounceTimeout time.Duration + ListenTimeout time.Duration +} + +// DefaultMulticastConfig returns a default configuration +func DefaultMulticastConfig() *MulticastConfig { + return &MulticastConfig{ + MulticastAddr: "224.0.0.167:53317", + Port: 53317, + AnnounceTimeout: 2 * time.Second, + ListenTimeout: 5 * time.Second, + } +} diff --git a/pkg/discovery/http_discovery.go b/pkg/discovery/http_discovery.go index e79a0a0..b7b68a2 100644 --- a/pkg/discovery/http_discovery.go +++ b/pkg/discovery/http_discovery.go @@ -71,55 +71,10 @@ func NewHTTPDiscovery(config *HTTPDiscoveryConfig, dto model.RegisterDto, handle } } -func (hd *HTTPDiscovery) fetchDeviceInfo(ctx context.Context, ip net.IP, port int, scheme string) (*model.Device, error) { - url := fmt.Sprintf("%s://%s/api/localsend/v2/info", scheme, net.JoinHostPort(ip.String(), strconv.Itoa(port))) - - hd.logger.Debugf("HTTPDiscovery: Fetching device info from URL: %s", url) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := hd.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var infoDto model.InfoDto - if err := json.Unmarshal(body, &infoDto); err != nil { - return nil, fmt.Errorf("failed to parse response body: %w", err) - } - - return &model.Device{ - IP: ip.String(), - Version: infoDto.Version, - Protocol: model.ProtocolType(scheme), - Port: port, - Alias: infoDto.Alias, - Fingerprint: infoDto.Fingerprint, - DeviceModel: infoDto.DeviceModel, - DeviceType: infoDto.DeviceType, - Download: infoDto.Download, - LastSeen: time.Now(), - Available: true, - }, nil -} - func (hd *HTTPDiscovery) FetchDeviceInfo(ctx context.Context, ip net.IP, port int) (*model.Device, error) { - device, err := hd.fetchDeviceInfo(ctx, ip, port, "https") + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { - device, err = hd.fetchDeviceInfo(ctx, ip, port, "http") + device, err = hd.RegisterWithDevice(ctx, ip, port, "http") } return device, err } @@ -195,9 +150,9 @@ func (hd *HTTPDiscovery) ScanNetwork(ctx context.Context, ips []net.IP, port int sem <- struct{}{} defer func() { <-sem }() - device, err := hd.fetchDeviceInfo(ctx, ip, port, "https") + device, err := hd.RegisterWithDevice(ctx, ip, port, "https") if err != nil { - device, err = hd.fetchDeviceInfo(ctx, ip, port, "http") + device, err = hd.RegisterWithDevice(ctx, ip, port, "http") if err != nil { return } diff --git a/pkg/discovery/multicast.go b/pkg/discovery/multicast.go index 6e5b9d9..e639da2 100644 --- a/pkg/discovery/multicast.go +++ b/pkg/discovery/multicast.go @@ -3,13 +3,10 @@ package discovery import ( "context" - "encoding/json" "fmt" "net" - "strings" "sync" "sync/atomic" - "time" "github.com/bethropolis/localgo/pkg/model" "go.uber.org/zap" @@ -31,25 +28,6 @@ type MulticastDiscovery struct { logger *zap.SugaredLogger } -// MulticastConfig contains settings for multicast discovery -type MulticastConfig struct { - MulticastAddr string - Port int - InterfaceName string - AnnounceTimeout time.Duration - ListenTimeout time.Duration -} - -// DefaultMulticastConfig returns a default configuration -func DefaultMulticastConfig() *MulticastConfig { - return &MulticastConfig{ - MulticastAddr: "224.0.0.167:53317", - Port: 53317, - AnnounceTimeout: 2 * time.Second, - ListenTimeout: 5 * time.Second, - } -} - // NewMulticastDiscovery creates a new multicast discovery instance func NewMulticastDiscovery(config *MulticastConfig, dto model.MulticastDto, logger *zap.SugaredLogger) *MulticastDiscovery { if config == nil { @@ -158,162 +136,6 @@ func (md *MulticastDiscovery) Stop() { md.connsMu.Unlock() } -// SendDiscoveryAnnouncement sends a multicast announcement -func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error { - announcementDto := md.dto - announcementDto.Announce = true - - data, err := json.Marshal(announcementDto) - if err != nil { - return fmt.Errorf("failed to marshal announcement: %w", err) - } - - addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr) - if err != nil { - return fmt.Errorf("failed to resolve multicast address: %w", err) - } - - var localAddr *net.UDPAddr - if md.config.InterfaceName != "" { - iface, err := net.InterfaceByName(md.config.InterfaceName) - if err == nil { - addrs, err := iface.Addrs() - if err == nil { - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { - localAddr = &net.UDPAddr{IP: ipnet.IP} - break - } - } - } - } - } - - conn, err := net.DialUDP("udp4", localAddr, addr) - if err != nil { - return fmt.Errorf("failed to create UDP connection: %w", err) - } - defer conn.Close() - - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send multicast announcement: %w", err) - } - - md.logger.Debugf("Sent multicast announcement as %s (fingerprint: %s) to %s", - md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr) - return nil -} - -// SendDiscoveryResponse sends a response to a specific address -func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, targetDevice *model.Device) error { - // 1. Try HTTP Response first - if md.httpDiscoverer != nil && targetDevice != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - scheme := "http" - if targetDevice.Protocol == model.ProtocolTypeHTTPS { - scheme = "https" - } - - _, err := md.httpDiscoverer.RegisterWithDevice(ctx, net.ParseIP(targetDevice.IP), targetDevice.Port, scheme) - if err == nil { - md.logger.Debugf("Sent discovery response via HTTP to %s:%d", targetDevice.IP, targetDevice.Port) - return nil - } - } - - // 2. Fallback to UDP - responseDto := md.dto - responseDto.Announce = false - - data, err := json.Marshal(responseDto) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - - conn, err := net.DialUDP("udp4", nil, targetAddr) - if err != nil { - return fmt.Errorf("failed to create UDP connection: %w", err) - } - defer conn.Close() - - _, err = conn.Write(data) - if err != nil { - return fmt.Errorf("failed to send discovery response: %w", err) - } - - md.logger.Debugf("Sent discovery response via UDP to %s", targetAddr) - return nil -} - -func (md *MulticastDiscovery) listenLoop(ctx context.Context, conn net.PacketConn) { - buffer := make([]byte, 2048) - - for { - select { - case <-ctx.Done(): - return - default: - } - - if md.closed.Load() { - return - } - - if err := conn.SetReadDeadline(time.Now().Add(md.config.ListenTimeout)); err != nil { - md.logger.Warnf("Failed to set read deadline: %v", err) - } - - n, addr, err := conn.ReadFrom(buffer) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue - } - if strings.Contains(err.Error(), "use of closed network connection") { - return - } - continue - } - - if err := md.handlePacket(buffer[:n], addr); err != nil { - md.logger.Warnf("Failed to handle multicast packet: %v", err) - } - } -} - -func (md *MulticastDiscovery) handlePacket(data []byte, addr net.Addr) error { - var dto model.MulticastDto - if err := json.Unmarshal(data, &dto); err != nil { - return fmt.Errorf("failed to unmarshal packet: %w", err) - } - - if dto.Fingerprint == md.dto.Fingerprint { - return nil - } - - udpAddr, ok := addr.(*net.UDPAddr) - if !ok { - return fmt.Errorf("unexpected address type: %T", addr) - } - - device := model.FromMulticastDto(dto, udpAddr.IP) - - md.logger.Debugf("Discovered raw device via multicast: %s (%s) at %s:%d", - device.Alias, getShortFingerprint(device.Fingerprint), device.IP, device.Port) - - md.updateDevice(device) - - if dto.Announce { - if err := md.SendDiscoveryResponse(udpAddr, device); err != nil { - md.logger.Warnf("Failed to send discovery response: %v", err) - } - } - - return nil -} - func (md *MulticastDiscovery) updateDevice(device *model.Device) { md.devicesMutex.Lock() key := device.Fingerprint diff --git a/pkg/discovery/peercache.go b/pkg/discovery/peercache.go index 77f0b55..2afe942 100644 --- a/pkg/discovery/peercache.go +++ b/pkg/discovery/peercache.go @@ -93,7 +93,7 @@ func (pc *PeerCache) load() { for _, d := range list { // Evict peers not seen in the last 30 days - if !d.LastSeen.IsZero() && now.Sub(d.LastSeen) > staleThreshold { + if !d.GetLastSeen().IsZero() && now.Sub(d.GetLastSeen()) > staleThreshold { evictedCount++ continue } @@ -205,11 +205,12 @@ func ProbeCached(ctx context.Context, cache *PeerCache, onFound func(*model.Devi if resp.StatusCode == http.StatusOK { now := time.Now() - d.LastSeen = now - if logger != nil { - logger.Debugf("Cached peer %s (%s:%d) responded", d.Alias, d.IP, d.Port) - } - onFound(d) + d.SetLastSeen(now) + cache.Save(d) // persist updated LastSeen to disk + if logger != nil { + logger.Debugf("Cached peer %s (%s:%d) responded", d.Alias, d.IP, d.Port) + } + onFound(d) } }(device) } diff --git a/pkg/discovery/quick.go b/pkg/discovery/quick.go index 5484234..84bc772 100644 --- a/pkg/discovery/quick.go +++ b/pkg/discovery/quick.go @@ -4,45 +4,33 @@ import ( "context" "time" + "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/model" ) -func DiscoverDevices(ctx context.Context, cfg *ServiceConfig, alias string, port int, fingerprint string, deviceModel *string, httpsEnabled bool) ([]*model.Device, error) { - if cfg == nil { - cfg = DefaultServiceConfig() +func DiscoverDevices(ctx context.Context, serviceCfg *ServiceConfig, appCfg *config.Config, httpsEnabled bool) ([]*model.Device, error) { + if serviceCfg == nil { + serviceCfg = DefaultServiceConfig() } - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - - multicastDto := model.MulticastDto{ - Alias: alias, - Version: "2.1", - DeviceModel: deviceModel, - DeviceType: model.DeviceTypeDesktop, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: false, - Announce: true, - } + multicastDto := appCfg.ToMulticastDto(false) - multicast := NewMulticastDiscovery(cfg.MulticastConfig, multicastDto, nil) + multicast := NewMulticastDiscovery(serviceCfg.MulticastConfig, multicastDto, nil) peerCache := NewPeerCache(nil) multicast.SetPeerCache(peerCache) - svc := NewService(cfg, multicast, nil) + svc := NewService(serviceCfg, multicast, nil) svc.SetPeerCache(peerCache) - if err := svc.Start(ctx, alias, port, fingerprint, model.DeviceTypeDesktop, deviceModel, httpsEnabled); err != nil { + + + if err := svc.Start(ctx, multicastDto); err != nil { return nil, err } scanCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - return svc.Discover(scanCtx, alias, port, fingerprint, model.DeviceTypeDesktop, deviceModel, httpsEnabled, false) + return svc.Discover(scanCtx, multicastDto) } diff --git a/pkg/discovery/service.go b/pkg/discovery/service.go index 5158b3a..976c210 100644 --- a/pkg/discovery/service.go +++ b/pkg/discovery/service.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/model" "go.uber.org/zap" ) @@ -22,6 +21,8 @@ type Service struct { handlersMutex sync.RWMutex announceTimer *time.Timer peerCache *PeerCache + stopCh chan struct{} + stopOnce sync.Once logger *zap.SugaredLogger } @@ -56,6 +57,7 @@ func NewService(config *ServiceConfig, multicast MulticastDiscoverer, logger *za config: config, devices: make(map[string]*model.Device), multicast: multicast, + stopCh: make(chan struct{}), logger: logger, } @@ -78,24 +80,8 @@ func (s *Service) SetPeerCache(cache *PeerCache) { } // Start initializes and starts the discovery service for listening and periodic announcements -func (s *Service) Start(ctx context.Context, alias string, port int, fingerprint string, deviceType model.DeviceType, deviceModel *string, httpsEnabled bool) error { - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - multicastDto := model.MulticastDto{ - Alias: alias, - Version: config.ProtocolVersion, - DeviceModel: deviceModel, - DeviceType: deviceType, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: true, - Announce: true, - } - - s.multicast.SetDto(multicastDto) +func (s *Service) Start(ctx context.Context, dto model.MulticastDto) error { + s.multicast.SetDto(dto) if err := s.multicast.StartListening(ctx); err != nil { return fmt.Errorf("failed to start multicast discovery: %w", err) @@ -126,6 +112,9 @@ func (s *Service) Start(ctx context.Context, alias string, port int, fingerprint // Stop stops the discovery service func (s *Service) Stop() { s.logger.Debugf("Stopping discovery service...") + s.stopOnce.Do(func() { + close(s.stopCh) + }) if s.multicast != nil { s.multicast.Stop() } @@ -137,26 +126,10 @@ func (s *Service) Stop() { } // Discover performs a discovery scan and returns found devices. -func (s *Service) Discover(ctx context.Context, alias string, port int, fingerprint string, deviceType model.DeviceType, deviceModel *string, httpsEnabled bool, isDownloadServer bool) ([]*model.Device, error) { +func (s *Service) Discover(ctx context.Context, dto model.MulticastDto) ([]*model.Device, error) { s.logger.Debugf("Performing one-off discovery scan...") - protocol := model.ProtocolTypeHTTP - if httpsEnabled { - protocol = model.ProtocolTypeHTTPS - } - multicastDto := model.MulticastDto{ - Alias: alias, - Version: config.ProtocolVersion, - DeviceModel: deviceModel, - DeviceType: deviceType, - Fingerprint: fingerprint, - Port: port, - Protocol: protocol, - Download: isDownloadServer, - Announce: true, - } - - s.multicast.SetDto(multicastDto) + s.multicast.SetDto(dto) // MUST be listening to receive multicast responses if err := s.multicast.StartListening(ctx); err != nil { @@ -254,6 +227,10 @@ func (s *Service) startAnnouncementLoop(ctx context.Context) { for { select { case <-ctx.Done(): + s.announceTimer.Stop() + return + case <-s.stopCh: + s.announceTimer.Stop() return case <-s.announceTimer.C: if err := s.multicast.SendDiscoveryAnnouncement(); err != nil { diff --git a/pkg/discovery/service_test.go b/pkg/discovery/service_test.go index 1680613..376a18e 100644 --- a/pkg/discovery/service_test.go +++ b/pkg/discovery/service_test.go @@ -49,7 +49,16 @@ func TestService_Start(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - err := service.Start(ctx, "test-alias", 12345, "test-fingerprint", model.DeviceTypeDesktop, nil, false) + dto := model.MulticastDto{ + Alias: "test-alias", + Version: "2.1", + Fingerprint: "test-fingerprint", + Port: 12345, + DeviceType: model.DeviceTypeDesktop, + Protocol: model.ProtocolTypeHTTP, + Announce: true, + } + err := service.Start(ctx, dto) assert.NoError(t, err) assert.True(t, multicast.startListeningCalled) diff --git a/pkg/help/commands.go b/pkg/help/commands.go new file mode 100644 index 0000000..c4b641d --- /dev/null +++ b/pkg/help/commands.go @@ -0,0 +1,177 @@ +package help + +// GetCommandHelp returns help information for built-in commands +func GetCommandHelp(commandName string) *CommandHelp { + commands := map[string]*CommandHelp{ + "serve": { + Name: "serve", + Description: "Start the LocalGo server to receive files", + Usage: "localgo serve [OPTIONS]", + Examples: []string{ + "localgo serve", + "localgo serve --port 8080 --http", + "localgo serve --pin 123456 --alias MyDevice", + "localgo serve --dir /tmp/downloads --verbose", + "localgo serve --auto-accept --quiet", + "localgo serve --no-clipboard", + "localgo serve --exec 'notify-send \"Got: %f\"'", + }, + Flags: []FlagHelp{ + {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, + {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, + {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, + {Name: "--dir", Type: "string", Default: "from config", Description: "Download directory"}, + {Name: "--interval", Type: "int", Default: "30", Description: "Discovery announcement interval in seconds"}, + {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, + {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--open", Type: "bool", Default: "false", Description: "Open download directory after transfer completes"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, + {Name: "--verbose", Type: "bool", Default: "false", Description: "Verbose mode - detailed output"}, + {Name: "--history", Type: "string", Default: "~/.local/share/localgo/history.jsonl", Description: "Path to transfer history JSONL file"}, + {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "share": { + Name: "share", + Description: "Share files so other devices can download them", + Usage: "localgo share --file FILE [OPTIONS]", + Examples: []string{ + "localgo share --file document.pdf", + "localgo share --file image.jpg --file text.txt", + "localgo share --file data.zip --pin 1234", + "localgo share --file data.zip --auto-accept", + "localgo share --file report.pdf --no-clipboard", + "localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'", + }, + Flags: []FlagHelp{ + {Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"}, + {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, + {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, + {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, + {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, + {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, + {Name: "--zip", Type: "bool", Default: "false", Description: "Zip directories before sharing"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, + {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, + {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "discover": { + Name: "discover", + Description: "Discover LocalGo devices on the network using multicast", + Usage: "localgo discover [OPTIONS]", + Examples: []string{ + "localgo discover", + "localgo discover --timeout 10", + "localgo discover --json", + "localgo discover --quiet", + }, + Flags: []FlagHelp{ + {Name: "--timeout", Type: "int", Default: "10", Description: "Discovery timeout in seconds"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, + }, + }, + "scan": { + Name: "scan", + Description: "Scan the network for LocalGo devices using HTTP", + Usage: "localgo scan [OPTIONS]", + Examples: []string{ + "localgo scan", + "localgo scan --port 8080 --timeout 30", + "localgo scan --json", + "localgo scan --quiet", + "localgo scan --range 192.168.1.0/24", + }, + Flags: []FlagHelp{ + {Name: "--range", Type: "string", Default: "", Description: "CIDR range to scan (e.g. 192.168.1.0/24)"}, + {Name: "--timeout", Type: "int", Default: "15", Description: "Scan timeout in seconds"}, + {Name: "--port", Type: "int", Default: "from config", Description: "Port to scan"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, + }, + }, + "send": { + Name: "send", + Description: "Send a file or clipboard text to another LocalGo device", + Usage: "localgo send [OPTIONS]", + Examples: []string{ + "localgo send --file document.pdf --to MyPhone", + "localgo send --ip 192.168.1.42 --file document.pdf", + "localgo send --ip 192.168.1.42:53317 --file document.pdf", + "localgo send --clipboard --to MyPhone", + "localgo send -c --to MyPhone", + "localgo send (starts interactive clipboard or file picker if empty)", + }, + Flags: []FlagHelp{ + {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, + {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, + {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, + {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, + {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, + {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, + {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, + {Name: "--concurrency", Type: "int", Default: "0", Description: "Max parallel uploads (0 = use default)"}, + {Name: "--iface", Type: "string", Default: "", Description: "Multicast network interface name"}, + }, + }, + "history": { + Name: "history", + Description: "Show file transfer history log", + Usage: "localgo history [OPTIONS]", + Examples: []string{ + "localgo history", + "localgo history --limit 20", + "localgo history --clear", + }, + Flags: []FlagHelp{ + {Name: "--limit", Type: "int", Default: "10", Description: "Maximum number of entries to display"}, + {Name: "--clear", Type: "bool", Default: "false", Description: "Clear all transfer history logs"}, + }, + }, + "devices": { + Name: "devices", + Description: "List recently discovered devices on the network", + Usage: "localgo devices [OPTIONS]", + Examples: []string{ + "localgo devices", + "localgo devices --probe", + "localgo devices --json", + }, + Flags: []FlagHelp{ + {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + }, + }, + "info": { + Name: "info", + Description: "Show device information and configuration", + Usage: "localgo info [OPTIONS]", + Examples: []string{ + "localgo info", + "localgo info --json", + }, + Flags: []FlagHelp{ + {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, + }, + }, + "completion": { + Name: "completion", + Description: "Generate shell completion scripts", + Usage: "localgo completion [bash|zsh|fish|powershell]", + Examples: []string{ + "localgo completion bash > /etc/bash_completion.d/localgo", + "localgo completion zsh > /usr/local/share/zsh/site-functions/_localgo", + "localgo completion fish > ~/.config/fish/completions/localgo.fish", + }, + Flags: []FlagHelp{}, + }, + } + + return commands[commandName] +} diff --git a/pkg/help/help.go b/pkg/help/help.go index 91bc3c6..2ab7e67 100644 --- a/pkg/help/help.go +++ b/pkg/help/help.go @@ -2,8 +2,10 @@ package help import ( "fmt" + "strings" "github.com/bethropolis/localgo/pkg/cli" + "github.com/charmbracelet/lipgloss" ) // CommandHelp represents help information for a command @@ -45,19 +47,49 @@ func ShowMainUsage() { {"devices", "List recently discovered devices"}, {"history", "Show file transfer history log"}, {"info", "Show device information"}, + {"completion", "Generate shell completion scripts"}, {"help", "Show help information"}, {"version", "Show version information"}, } + maxCmdWidth := 0 for _, cmd := range commands { - fmt.Printf(" %-12s %s\n", cli.SuccessStyle.Render(cmd.name), cmd.desc) + if w := lipgloss.Width(cmd.name); w > maxCmdWidth { + maxCmdWidth = w + } + } + cmdPad := maxCmdWidth + 2 + + for _, cmd := range commands { + styledName := cli.SuccessStyle.Render(cmd.name) + padding := cmdPad - lipgloss.Width(cmd.name) + fmt.Printf(" %s%s%s\n", styledName, strings.Repeat(" ", padding), cmd.desc) } fmt.Printf("\n%s\n", cli.WarningStyle.Render("OPTIONS:")) - fmt.Printf(" %s Show help\n", cli.InfoStyle.Render("-h, --help")) - fmt.Printf(" %s Show version\n", cli.InfoStyle.Render("-v, --version")) - fmt.Printf(" %s Enable debug logging\n", cli.InfoStyle.Render("--verbose")) - fmt.Printf(" %s Enable JSON log output\n\n", cli.InfoStyle.Render("--json")) + options := []struct{ flag, desc string }{ + {"-h, --help", "Show help"}, + {"-v, --version", "Show version"}, + {"--verbose", "Enable debug logging"}, + {"--json", "Enable JSON log output"}, + {"--private, -p", "Hide device identity during discovery/transfer"}, + {"--config", "Config file path"}, + } + + maxOptWidth := 0 + for _, opt := range options { + if w := lipgloss.Width(opt.flag); w > maxOptWidth { + maxOptWidth = w + } + } + optPad := maxOptWidth + 2 + + for _, opt := range options { + styledFlag := cli.InfoStyle.Render(opt.flag) + padding := optPad - lipgloss.Width(opt.flag) + fmt.Printf(" %s%s%s\n", styledFlag, strings.Repeat(" ", padding), opt.desc) + } + fmt.Println() fmt.Printf("%s\n", cli.WarningStyle.Render("EXAMPLES:")) examples := []string{ @@ -150,160 +182,4 @@ func ShowVersion(version, commit, date string) { cli.SuccessStyle.Render("LocalSend v2.1")) } -// GetCommandHelp returns help information for built-in commands -func GetCommandHelp(commandName string) *CommandHelp { - commands := map[string]*CommandHelp{ - "serve": { - Name: "serve", - Description: "Start the LocalGo server to receive files", - Usage: "localgo serve [OPTIONS]", - Examples: []string{ - "localgo serve", - "localgo serve --port 8080 --http", - "localgo serve --pin 123456 --alias MyDevice", - "localgo serve --dir /tmp/downloads --verbose", - "localgo serve --auto-accept --quiet", - "localgo serve --no-clipboard", - "localgo serve --exec 'notify-send \"Got: %f\"'", - }, - Flags: []FlagHelp{ - {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, - {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, - {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, - {Name: "--dir", Type: "string", Default: "from config", Description: "Download directory"}, - {Name: "--interval", Type: "int", Default: "30", Description: "Discovery announcement interval in seconds"}, - {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, - {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, - {Name: "--verbose", Type: "bool", Default: "false", Description: "Verbose mode - detailed output"}, - {Name: "--history", Type: "string", Default: "~/.local/share/localgo/history.jsonl", Description: "Path to transfer history JSONL file"}, - {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file (use %f, %n, %s, %a, %i)"}, - }, - }, - "share": { - Name: "share", - Description: "Share files so other devices can download them", - Usage: "localgo share --file FILE [OPTIONS]", - Examples: []string{ - "localgo share --file document.pdf", - "localgo share --file image.jpg --file text.txt", - "localgo share --file data.zip --pin 1234", - "localgo share --file data.zip --auto-accept", - "localgo share --file report.pdf --no-clipboard", - "localgo share --file doc.pdf --exec 'curl -F \"file=@%f\" https://example.com/upload'", - }, - Flags: []FlagHelp{ - {Name: "--file", Type: "string", Default: "", Description: "File or directory to share (required, can be specified multiple times)"}, - {Name: "--port", Type: "int", Default: "from config", Description: "Port to run the server on"}, - {Name: "--http", Type: "bool", Default: "false", Description: "Use HTTP instead of HTTPS"}, - {Name: "--pin", Type: "string", Default: "", Description: "PIN for authentication"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Device alias"}, - {Name: "--auto-accept", Type: "bool", Default: "false", Description: "Auto-accept incoming files without prompting"}, - {Name: "--no-clipboard", Type: "bool", Default: "false", Description: "Save incoming text as a file instead of copying to clipboard"}, - {Name: "--history", Type: "string", Default: "", Description: "Path to transfer history JSONL file"}, - {Name: "--exec", Type: "string", Default: "", Description: "Shell command to execute after each received file"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - minimal output"}, - }, - }, - "discover": { - Name: "discover", - Description: "Discover LocalGo devices on the network using multicast", - Usage: "localgo discover [OPTIONS]", - Examples: []string{ - "localgo discover", - "localgo discover --timeout 10", - "localgo discover --json", - "localgo discover --quiet", - }, - Flags: []FlagHelp{ - {Name: "--timeout", Type: "int", Default: "5", Description: "Discovery timeout in seconds"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, - }, - }, - "scan": { - Name: "scan", - Description: "Scan the network for LocalGo devices using HTTP", - Usage: "localgo scan [OPTIONS]", - Examples: []string{ - "localgo scan", - "localgo scan --port 8080 --timeout 30", - "localgo scan --json", - "localgo scan --quiet", - "localgo scan --range 192.168.1.0/24", - }, - Flags: []FlagHelp{ - {Name: "--range", Type: "string", Default: "", Description: "CIDR range to scan (e.g. 192.168.1.0/24)"}, - {Name: "--timeout", Type: "int", Default: "15", Description: "Scan timeout in seconds"}, - {Name: "--port", Type: "int", Default: "from config", Description: "Port to scan"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - {Name: "--quiet", Type: "bool", Default: "false", Description: "Quiet mode - only show results"}, - }, - }, - "send": { - Name: "send", - Description: "Send a file or clipboard text to another LocalGo device", - Usage: "localgo send [OPTIONS]", - Examples: []string{ - "localgo send --file document.pdf --to MyPhone", - "localgo send --ip 192.168.1.42 --file document.pdf", - "localgo send --ip 192.168.1.42:53317 --file document.pdf", - "localgo send --clipboard --to MyPhone", - "localgo send -c --to MyPhone", - "localgo send (starts interactive clipboard or file picker if empty)", - }, - Flags: []FlagHelp{ - {Name: "--file", Type: "string", Default: "", Description: "File or directory to send (optional, can be specified multiple times)"}, - {Name: "--ip", Type: "string", Default: "", Description: "Target device IP (with optional :port, skips discovery)"}, - {Name: "--to", Type: "string", Default: "", Description: "Target device alias (omit to pick interactively)"}, - {Name: "--clipboard, -c", Type: "bool", Default: "false", Description: "Send current system clipboard text directly"}, - {Name: "--port", Type: "int", Default: "auto-detect", Description: "Target device port"}, - {Name: "--timeout", Type: "int", Default: "30", Description: "Send timeout in seconds"}, - {Name: "--alias", Type: "string", Default: "from config", Description: "Sender alias"}, - }, - }, - "history": { - Name: "history", - Description: "Show file transfer history log", - Usage: "localgo history [OPTIONS]", - Examples: []string{ - "localgo history", - "localgo history --limit 20", - "localgo history --clear", - }, - Flags: []FlagHelp{ - {Name: "--limit", Type: "int", Default: "10", Description: "Maximum number of entries to display"}, - {Name: "--clear", Type: "bool", Default: "false", Description: "Clear all transfer history logs"}, - }, - }, - "devices": { - Name: "devices", - Description: "List recently discovered devices on the network", - Usage: "localgo devices [OPTIONS]", - Examples: []string{ - "localgo devices", - "localgo devices --probe", - "localgo devices --json", - }, - Flags: []FlagHelp{ - {Name: "--probe, -p", Type: "bool", Default: "false", Description: "Probe cached devices to verify if they are currently online"}, - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - }, - }, - "info": { - Name: "info", - Description: "Show device information and configuration", - Usage: "localgo info [OPTIONS]", - Examples: []string{ - "localgo info", - "localgo info --json", - }, - Flags: []FlagHelp{ - {Name: "--json", Type: "bool", Default: "false", Description: "Output in JSON format"}, - }, - }, - } - return commands[commandName] -} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 40ecad4..449a145 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -49,7 +49,8 @@ func timeEncoder(t zapcore.TimeEncoder) zapcore.TimeEncoder { // // - verbose: enable debug-level output // - jsonFmt: output newline-delimited JSON instead of human-readable text -func Init(verbose, jsonFmt bool) *zap.SugaredLogger { +// - noColor: disable ANSI color escape sequences in log output +func Init(verbose, jsonFmt, noColor bool) *zap.SugaredLogger { level := zapcore.InfoLevel if verbose { level = zapcore.DebugLevel @@ -92,6 +93,10 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { var core zapcore.Core if verbose { // Also log to stdout + levelEnc := zapcore.LevelEncoder(colourLevelEncoder) + if noColor { + levelEnc = zapcore.CapitalLevelEncoder + } stdoutEncCfg := zapcore.EncoderConfig{ TimeKey: "T", LevelKey: "L", @@ -100,7 +105,7 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { MessageKey: "M", StacktraceKey: "S", LineEnding: zapcore.DefaultLineEnding, - EncodeLevel: colourLevelEncoder, + EncodeLevel: levelEnc, EncodeTime: zapcore.TimeEncoderOfLayout("15:04:05"), EncodeDuration: zapcore.StringDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, @@ -117,7 +122,7 @@ func Init(verbose, jsonFmt bool) *zap.SugaredLogger { if verbose { opts = append(opts, zap.AddStacktrace(zapcore.ErrorLevel)) } else { - opts = []zap.Option{zap.AddCallerSkip(0)} // Minimal options for non-verbose + opts = []zap.Option{} // Minimal options for non-verbose } logger := zap.New(core, opts...) diff --git a/pkg/metadata/strip.go b/pkg/metadata/strip.go index 15c626e..ceade8f 100644 --- a/pkg/metadata/strip.go +++ b/pkg/metadata/strip.go @@ -31,6 +31,11 @@ func stripJPEG(path string) error { } defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("strip: stat: %w", err) + } + var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { return fmt.Errorf("strip: read: %w", err) @@ -48,7 +53,7 @@ func stripJPEG(path string) error { out.Write(data[:2]) // SOI pos := 2 - for pos < len(data) { + for pos+1 < len(data) { if data[pos] != 0xFF { break } @@ -63,6 +68,9 @@ func stripJPEG(path string) error { // Markers without length: SOI (0xD8), EOI (0xD9), TEM (0x01) if marker == 0xD9 || marker == 0x00 || marker == 0x01 { + if pos+2 > len(data) { + break + } out.Write(data[pos : pos+2]) pos += 2 if marker == 0xD9 { @@ -89,7 +97,7 @@ func stripJPEG(path string) error { pos += segLen } - return os.WriteFile(path, out.Bytes(), 0644) + return os.WriteFile(path, out.Bytes(), fi.Mode()) } // stripPNG removes tEXt, zTXt, and iTXt metadata chunks. @@ -100,6 +108,11 @@ func stripPNG(path string) error { } defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("strip: stat: %w", err) + } + var buf bytes.Buffer if _, err := io.Copy(&buf, f); err != nil { return fmt.Errorf("strip: read: %w", err) @@ -141,5 +154,5 @@ func stripPNG(path string) error { pos += 12 + chunkLen } - return os.WriteFile(path, out.Bytes(), 0644) + return os.WriteFile(path, out.Bytes(), fi.Mode()) } diff --git a/pkg/model/device.go b/pkg/model/device.go index 99cfbb6..6d9d3b1 100644 --- a/pkg/model/device.go +++ b/pkg/model/device.go @@ -3,16 +3,18 @@ package model import ( "fmt" "net" + "sync" "time" ) // Device represents a peer device. type Device struct { - IP string `json:"ip"` - Version string `json:"version"` // LocalSend protocol version - Port int `json:"port"` - Alias string `json:"alias"` - Protocol ProtocolType `json:"protocol"` + mu sync.RWMutex + IP string `json:"ip"` + Version string `json:"version"` // LocalSend protocol version + Port int `json:"port"` + Alias string `json:"alias"` + Protocol ProtocolType `json:"protocol"` Fingerprint string `json:"fingerprint"` DeviceModel *string `json:"deviceModel"` // nullable @@ -51,12 +53,37 @@ func NewDevice(info RegisterDto, ip net.IP, detectedPort int, detectedHttps bool // UpdateLastSeen updates the last seen timestamp for a device func (d *Device) UpdateLastSeen() { + d.mu.Lock() d.LastSeen = time.Now() d.Available = true + d.mu.Unlock() +} + +// GetLastSeen returns the last seen timestamp (thread-safe). +func (d *Device) GetLastSeen() time.Time { + d.mu.RLock() + defer d.mu.RUnlock() + return d.LastSeen +} + +// GetAvailable returns whether the device is available (thread-safe). +func (d *Device) GetAvailable() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.Available +} + +// SetLastSeen sets the last seen timestamp (thread-safe). +func (d *Device) SetLastSeen(t time.Time) { + d.mu.Lock() + d.LastSeen = t + d.mu.Unlock() } // IsStale checks if a device hasn't been seen recently func (d *Device) IsStale(staleThreshold time.Duration) bool { + d.mu.RLock() + defer d.mu.RUnlock() return time.Since(d.LastSeen) > staleThreshold } diff --git a/pkg/model/dto.go b/pkg/model/dto.go index e3f8977..a8f44b9 100644 --- a/pkg/model/dto.go +++ b/pkg/model/dto.go @@ -12,9 +12,6 @@ const ( DeviceTypeWeb DeviceType = "web" DeviceTypeHeadless DeviceType = "headless" DeviceTypeServer DeviceType = "server" - DeviceTypeLaptop DeviceType = "laptop" - DeviceTypeTablet DeviceType = "tablet" - DeviceTypeOther DeviceType = "other" ) // ProtocolType defines the protocol type. @@ -91,13 +88,16 @@ func (d *DeviceInfo) ToMulticastDto(announce bool) MulticastDto { } // InfoDto represents the response for /info and /register endpoints. +// Also used as the info block in prepare-upload requests (port + protocol required). type InfoDto struct { - Alias string `json:"alias"` - Version string `json:"version"` - DeviceModel *string `json:"deviceModel"` // nullable - DeviceType DeviceType `json:"deviceType"` - Fingerprint string `json:"fingerprint"` - Download bool `json:"download"` + Alias string `json:"alias"` + Version string `json:"version"` + DeviceModel *string `json:"deviceModel"` // nullable + DeviceType DeviceType `json:"deviceType"` + Fingerprint string `json:"fingerprint"` + Port int `json:"port,omitempty"` + Protocol ProtocolType `json:"protocol,omitempty"` + Download bool `json:"download"` } // RegisterDto represents the request body for /register endpoint (sent by the discoverer). @@ -127,13 +127,8 @@ type MulticastDto struct { // PrepareUploadRequestDto is sent to prepare file uploads type PrepareUploadRequestDto struct { - Info InfoDto `json:"info"` - Files map[string]FileDto `json:"files"` - SendZipped bool `json:"sendZipped"` - ForceBulk bool `json:"forceBulk"` - TargetPath string `json:"targetPath"` - KeepFolders bool `json:"keepFolders"` - Token string `json:"token,omitempty"` + Info InfoDto `json:"info"` + Files map[string]FileDto `json:"files"` } // FileDto contains information about a file being uploaded @@ -145,7 +140,6 @@ type FileDto struct { SHA256 *string `json:"sha256,omitempty"` // Use pointer for nullable Preview *string `json:"preview,omitempty"` // Use pointer for nullable Metadata *FileMetadata `json:"metadata,omitempty"` // Use pointer for nullable - Legacy bool `json:"legacy,omitempty"` // Added from Dart code } // FileMetadata holds optional file metadata (added in v2.1) @@ -158,7 +152,6 @@ type FileMetadata struct { type PrepareUploadResponseDto struct { SessionID string `json:"sessionId"` Files map[string]string `json:"files"` - Token string `json:"token,omitempty"` } // ReceiveRequestResponseDto is returned for download preparations diff --git a/pkg/network/interfaces.go b/pkg/network/interfaces.go index 99dab9f..3236c2b 100644 --- a/pkg/network/interfaces.go +++ b/pkg/network/interfaces.go @@ -6,6 +6,8 @@ import ( "fmt" "net" "strings" + + "github.com/jackpal/gateway" ) // GetLocalIP returns the primary non-loopback IP address of the machine @@ -35,8 +37,8 @@ func GetLocalIPAddresses() ([]net.IP, error) { } for _, i := range ifaces { - // Skip down, loopback, and non-multicast interfaces - if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 || (i.Flags&net.FlagMulticast) == 0 { + // Skip down and loopback interfaces + if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 { continue } @@ -166,3 +168,15 @@ func GetSubnetIPs(ip net.IP) []net.IP { } return ips } + +// DefaultGatewayIP returns the IP address of the default network gateway. +func DefaultGatewayIP() (net.IP, error) { + return gateway.DiscoverGateway() +} + +// PrimaryLANIP returns the local IP address on the interface that owns +// the default gateway. This is useful for prioritizing the real LAN +// subnet when scanning, rather than scanning Docker/VPN subnets. +func PrimaryLANIP() (net.IP, error) { + return gateway.DiscoverInterface() +} diff --git a/pkg/send/anonymize.go b/pkg/send/anonymize.go new file mode 100644 index 0000000..558b1f5 --- /dev/null +++ b/pkg/send/anonymize.go @@ -0,0 +1,49 @@ +package send + +import "strings" + +// anonymizeFileName maps a MIME type to a generic filename for private mode. +func anonymizeFileName(contentType string) string { + switch { + case strings.HasPrefix(contentType, "image/jpeg"): + return "image.jpg" + case strings.HasPrefix(contentType, "image/png"): + return "image.png" + case strings.HasPrefix(contentType, "image/webp"): + return "image.webp" + case strings.HasPrefix(contentType, "image/"): + return "image.bin" + case strings.HasPrefix(contentType, "video/mp4"): + return "video.mp4" + case strings.HasPrefix(contentType, "video/webm"): + return "video.webm" + case strings.HasPrefix(contentType, "video/x-matroska"): + return "video.mkv" + case strings.HasPrefix(contentType, "video/quicktime"): + return "video.mov" + case strings.HasPrefix(contentType, "video/"): + return "video.bin" + case strings.HasPrefix(contentType, "audio/"): + return "audio.mp3" + case strings.HasPrefix(contentType, "text/plain"): + return "document.txt" + case strings.HasPrefix(contentType, "text/html"): + return "document.html" + case strings.HasPrefix(contentType, "text/"): + return "document.txt" + case contentType == "application/pdf": + return "document.pdf" + case strings.HasPrefix(contentType, "application/zip"): + return "archive.zip" + case strings.HasPrefix(contentType, "application/gzip"): + return "archive.tar.gz" + case strings.HasPrefix(contentType, "application/x-tar"): + return "archive.tar" + case strings.HasPrefix(contentType, "application/x-"): + return "archive.bin" + case strings.HasPrefix(contentType, "application/"): + return "document.bin" + default: + return "file.bin" + } +} diff --git a/pkg/send/filepath.go b/pkg/send/filepath.go new file mode 100644 index 0000000..0c93750 --- /dev/null +++ b/pkg/send/filepath.go @@ -0,0 +1,40 @@ +package send + +import ( + "os" + "path/filepath" +) + +func getFilesWithRelativePaths(paths []string) (map[string]string, error) { + result := make(map[string]string) + for _, p := range paths { + p = filepath.Clean(p) + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if info.IsDir() { + baseDir := filepath.Dir(p) + err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error { + if err != nil { + return err + } + if !fInfo.IsDir() { + rel, err := filepath.Rel(baseDir, path) + if err == nil { + result[path] = filepath.ToSlash(rel) + } else { + result[path] = filepath.Base(path) + } + } + return nil + }) + if err != nil { + return nil, err + } + } else { + result[p] = filepath.Base(p) + } + } + return result, nil +} diff --git a/pkg/send/send.go b/pkg/send/send.go index 306c611..aa22f53 100644 --- a/pkg/send/send.go +++ b/pkg/send/send.go @@ -3,11 +3,11 @@ package send import ( "bytes" "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "encoding/json" - "errors" "fmt" - "io" "net" "net/http" "os" @@ -23,46 +23,10 @@ import ( "github.com/bethropolis/localgo/pkg/metadata" "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/network" - "github.com/charmbracelet/huh" "github.com/google/uuid" "go.uber.org/zap" ) -// getFilesWithRelativePaths recursively flattens directories while preserving relative structure -func getFilesWithRelativePaths(paths []string) (map[string]string, error) { - result := make(map[string]string) - for _, p := range paths { - p = filepath.Clean(p) - info, err := os.Stat(p) - if err != nil { - return nil, err - } - if info.IsDir() { - baseDir := filepath.Dir(p) - err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error { - if err != nil { - return err - } - if !fInfo.IsDir() { - rel, err := filepath.Rel(baseDir, path) - if err == nil { - result[path] = filepath.ToSlash(rel) - } else { - result[path] = filepath.Base(path) - } - } - return nil - }) - if err != nil { - return nil, err - } - } else { - result[p] = filepath.Base(p) - } - } - return result, nil -} - // 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) error { if logger == nil { @@ -109,7 +73,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci multicastCtx, cancelMulticast := context.WithTimeout(ctx, 1500*time.Millisecond) defer cancelMulticast() - err := discoverySvc.Start(multicastCtx, cfg.Alias, cfg.Port, cfg.SecurityContext.CertificateHash, cfg.DeviceType, cfg.DeviceModel, cfg.HttpsEnabled) + err := discoverySvc.Start(multicastCtx, cfg.ToMulticastDto(false)) if err != nil { logger.Warnf("Multicast start failed: %v", err) } @@ -131,16 +95,7 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } - registerDto := model.RegisterDto{ - Alias: cfg.Alias, - Version: config.ProtocolVersion, - DeviceModel: cfg.DeviceModel, - DeviceType: cfg.DeviceType, - Fingerprint: cfg.SecurityContext.CertificateHash, - Port: cfg.Port, - Protocol: model.ProtocolTypeHTTP, - } - + registerDto := cfg.ToRegisterDto() httpFallback := discovery.NewHTTPDiscovery(nil, registerDto, nil, logger) localIPs, err := network.GetLocalIPAddresses() @@ -184,86 +139,6 @@ func SendFiles(ctx context.Context, cfg *config.Config, filePaths []string, reci return SendToDevice(ctx, cfg, targetDevice, filePaths, logger) } -// verifyDeviceFingerprint checks if a cached fingerprint differs from the target's -// and prompts the user to trust the updated fingerprint before proceeding. -func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { - if targetDevice == nil || targetDevice.Fingerprint == "" { - return nil - } - - cachedPeers := peerCache.GetPeers() - for _, cached := range cachedPeers { - if cached.Alias == targetDevice.Alias && cached.Fingerprint != targetDevice.Fingerprint { - cli.PrintWarning("The security fingerprint for '%s' has changed!", targetDevice.Alias) - - var trust bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Trust this new device fingerprint and update cache?"). - Value(&trust). - Affirmative("Trust & Save"). - Negative("Abort"), - ), - ).WithTheme(huh.ThemeCharm()) - - if err := form.Run(); err != nil || !trust { - return fmt.Errorf("security verification failed: untrusted certificate hash change") - } - - peerCache.Save(targetDevice) - break - } - } - return nil -} - -// anonymizeFileName maps a MIME type to a generic filename for private mode. -func anonymizeFileName(contentType string) string { - switch { - case strings.HasPrefix(contentType, "image/jpeg"): - return "image.jpg" - case strings.HasPrefix(contentType, "image/png"): - return "image.png" - case strings.HasPrefix(contentType, "image/webp"): - return "image.webp" - case strings.HasPrefix(contentType, "image/"): - return "image.bin" - case strings.HasPrefix(contentType, "video/mp4"): - return "video.mp4" - case strings.HasPrefix(contentType, "video/webm"): - return "video.webm" - case strings.HasPrefix(contentType, "video/x-matroska"): - return "video.mkv" - case strings.HasPrefix(contentType, "video/quicktime"): - return "video.mov" - case strings.HasPrefix(contentType, "video/"): - return "video.bin" - case strings.HasPrefix(contentType, "audio/"): - return "audio.mp3" - case strings.HasPrefix(contentType, "text/plain"): - return "document.txt" - case strings.HasPrefix(contentType, "text/html"): - return "document.html" - case strings.HasPrefix(contentType, "text/"): - return "document.txt" - case contentType == "application/pdf": - return "document.pdf" - case strings.HasPrefix(contentType, "application/zip"): - return "archive.zip" - case strings.HasPrefix(contentType, "application/gzip"): - return "archive.tar.gz" - case strings.HasPrefix(contentType, "application/x-tar"): - return "archive.tar" - case strings.HasPrefix(contentType, "application/x-"): - return "archive.bin" - case strings.HasPrefix(contentType, "application/"): - return "document.bin" - default: - return "file.bin" - } -} - func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, filePaths []string, logger *zap.SugaredLogger) error { if logger == nil { logger = zap.NewNop().Sugar() @@ -274,8 +149,23 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if device.Protocol == model.ProtocolTypeHTTPS { scheme = "https" + expectedFingerprint := device.Fingerprint tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("no peer certificates presented") + } + cert := state.PeerCertificates[0] + hash := sha256.Sum256(cert.Raw) + actual := hex.EncodeToString(hash[:]) + if !strings.EqualFold(actual, expectedFingerprint) { + return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expectedFingerprint, actual) + } + return nil + }, + }, } client.Transport = tr defer tr.CloseIdleConnections() @@ -349,7 +239,12 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, if cfg.Private { infoAlias = "Anonymous" infoDeviceModel = nil - infoDeviceType = model.DeviceTypeOther + infoDeviceType = model.DeviceTypeHeadless + } + + fingerprint := cfg.RandomFingerprint + if cfg.HttpsEnabled { + fingerprint = cfg.SecurityContext.CertificateHash } prepareDto := model.PrepareUploadRequestDto{ @@ -358,7 +253,9 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, Version: config.ProtocolVersion, DeviceModel: infoDeviceModel, DeviceType: infoDeviceType, - Fingerprint: cfg.SecurityContext.CertificateHash, + Fingerprint: fingerprint, + Port: device.Port, + Protocol: model.ProtocolType(scheme), Download: true, }, Files: filesDtoMap, @@ -449,115 +346,4 @@ func SendToDevice(ctx context.Context, cfg *config.Config, device *model.Device, return nil } -func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { - if logger == nil { - logger = zap.NewNop().Sugar() - } - - file, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - 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) - - stat, err := file.Stat() - if err != nil { - return fmt.Errorf("failed to get file stats: %w", err) - } - - var body io.ReadCloser = file - if trackProgress != nil { - bar := &progressBar{current: 0, track: trackProgress} - body = &progressTracker{Reader: file, bar: bar} - } - - // Wrap with idle timeout: cancel request if no data flows for 15s - uploadCtx, cancel := context.WithCancel(ctx) - body = NewIdleTimeoutReader(body, 15*time.Second, cancel) - - req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body) - if err != nil { - cancel() - return fmt.Errorf("failed to create upload request: %w", err) - } - req.Header.Set("Content-Type", "application/octet-stream") - req.ContentLength = stat.Size() - - resp, err := client.Do(req) - if err != nil { - if errors.Is(err, context.Canceled) { - return fmt.Errorf("upload stalled: no data transmitted for 15s") - } - return fmt.Errorf("failed to send upload request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("upload request failed with status: %s", resp.Status) - } - - return nil -} - -// IdleTimeoutReader wraps an io.ReadCloser and cancels the context if no data -// is read within the configured idle duration. -type IdleTimeoutReader struct { - r io.ReadCloser - idleTimeout time.Duration - timer *time.Timer - cancel func() -} -func NewIdleTimeoutReader(r io.ReadCloser, timeout time.Duration, cancel func()) *IdleTimeoutReader { - tr := &IdleTimeoutReader{ - r: r, - idleTimeout: timeout, - cancel: cancel, - } - tr.timer = time.AfterFunc(timeout, func() { - tr.cancel() - }) - return tr -} - -func (tr *IdleTimeoutReader) Read(p []byte) (int, error) { - tr.timer.Reset(tr.idleTimeout) - n, err := tr.r.Read(p) - if err != nil { - tr.timer.Stop() - } - return n, err -} - -func (tr *IdleTimeoutReader) Close() error { - tr.timer.Stop() - return tr.r.Close() -} - -type progressBar struct { - current int64 - track func(int64) -} - -type progressTracker struct { - io.Reader - bar *progressBar -} - -func (pt *progressTracker) Read(p []byte) (int, error) { - n, err := pt.Reader.Read(p) - if n > 0 && pt.bar != nil { - pt.bar.current += int64(n) - pt.bar.track(pt.bar.current) - } - return n, err -} - -func (pt *progressTracker) Close() error { - if f, ok := pt.Reader.(*os.File); ok { - return f.Close() - } - return nil -} diff --git a/pkg/send/upload.go b/pkg/send/upload.go new file mode 100644 index 0000000..d1eda76 --- /dev/null +++ b/pkg/send/upload.go @@ -0,0 +1,124 @@ +package send + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/bethropolis/localgo/pkg/model" + "go.uber.org/zap" +) + +func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, trackProgress func(int64), logger *zap.SugaredLogger) error { + if logger == nil { + logger = zap.NewNop().Sugar() + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + 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) + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to get file stats: %w", err) + } + + var body io.ReadCloser = file + if trackProgress != nil { + bar := &progressBar{current: 0, track: trackProgress} + body = &progressTracker{Reader: file, Closer: file, bar: bar} + } + + // Wrap with idle timeout: cancel request if no data flows for 15s + uploadCtx, cancel := context.WithCancel(ctx) + body = NewIdleTimeoutReader(body, 15*time.Second, cancel) + + req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body) + if err != nil { + cancel() + return fmt.Errorf("failed to create upload request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.ContentLength = stat.Size() + + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return fmt.Errorf("upload stalled: no data transmitted for 15s") + } + return fmt.Errorf("failed to send upload request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload request failed with status: %s", resp.Status) + } + + return nil +} + +// IdleTimeoutReader wraps an io.ReadCloser and cancels the context if no data +// is read within the configured idle duration. +type IdleTimeoutReader struct { + r io.ReadCloser + idleTimeout time.Duration + timer *time.Timer + cancel func() +} + +// NewIdleTimeoutReader creates an IdleTimeoutReader. +func NewIdleTimeoutReader(r io.ReadCloser, timeout time.Duration, cancel func()) *IdleTimeoutReader { + tr := &IdleTimeoutReader{ + r: r, + idleTimeout: timeout, + cancel: cancel, + } + tr.timer = time.AfterFunc(timeout, func() { + tr.cancel() + }) + return tr +} + +func (tr *IdleTimeoutReader) Read(p []byte) (int, error) { + tr.timer.Reset(tr.idleTimeout) + n, err := tr.r.Read(p) + if err != nil { + tr.timer.Stop() + } + return n, err +} + +func (tr *IdleTimeoutReader) Close() error { + tr.timer.Stop() + return tr.r.Close() +} + +type progressBar struct { + current int64 + track func(int64) +} + +type progressTracker struct { + io.Reader + io.Closer + bar *progressBar +} + +func (pt *progressTracker) Read(p []byte) (int, error) { + n, err := pt.Reader.Read(p) + if n > 0 && pt.bar != nil { + pt.bar.current += int64(n) + pt.bar.track(pt.bar.current) + } + return n, err +} diff --git a/pkg/send/verify.go b/pkg/send/verify.go new file mode 100644 index 0000000..de5ec4a --- /dev/null +++ b/pkg/send/verify.go @@ -0,0 +1,42 @@ +package send + +import ( + "fmt" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/discovery" + "github.com/bethropolis/localgo/pkg/model" + "github.com/charmbracelet/huh" +) + +func verifyDeviceFingerprint(peerCache *discovery.PeerCache, targetDevice *model.Device) error { + if targetDevice == nil || targetDevice.Fingerprint == "" { + return nil + } + + cachedPeers := peerCache.GetPeers() + for _, cached := range cachedPeers { + if cached.Alias == targetDevice.Alias && cached.Fingerprint != targetDevice.Fingerprint { + cli.PrintWarning("The security fingerprint for '%s' has changed!", targetDevice.Alias) + + var trust bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Trust this new device fingerprint and update cache?"). + Value(&trust). + Affirmative("Trust & Save"). + Negative("Abort"), + ), + ).WithTheme(huh.ThemeCharm()) + + if err := form.Run(); err != nil || !trust { + return fmt.Errorf("security verification failed: untrusted certificate hash change") + } + + peerCache.Save(targetDevice) + break + } + } + return nil +} diff --git a/pkg/server/handlers/discovery_handlers.go b/pkg/server/handlers/discovery_handlers.go index 70386d3..a60a476 100644 --- a/pkg/server/handlers/discovery_handlers.go +++ b/pkg/server/handlers/discovery_handlers.go @@ -48,13 +48,18 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { downloadCapable := h.sendService.GetSession() != nil // True if we have an active send session + fingerprint := h.config.RandomFingerprint + if h.config.HttpsEnabled { + fingerprint = h.config.SecurityContext.CertificateHash + } + alias := h.config.Alias deviceModel := h.config.DeviceModel deviceType := h.config.DeviceType if h.config.Private { alias = "Anonymous" deviceModel = nil - deviceType = model.DeviceTypeOther + deviceType = model.DeviceTypeHeadless } dto := model.InfoDto{ @@ -62,7 +67,7 @@ func (h *DiscoveryHandler) InfoHandler(w http.ResponseWriter, r *http.Request) { Version: config.ProtocolVersion, DeviceModel: deviceModel, DeviceType: deviceType, - Fingerprint: h.config.SecurityContext.CertificateHash, + Fingerprint: fingerprint, Download: downloadCapable, } @@ -110,13 +115,18 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques downloadCapable := h.sendService.GetSession() != nil + fingerprint := h.config.RandomFingerprint + if h.config.HttpsEnabled { + fingerprint = h.config.SecurityContext.CertificateHash + } + respAlias := h.config.Alias respDeviceModel := h.config.DeviceModel respDeviceType := h.config.DeviceType if h.config.Private { respAlias = "Anonymous" respDeviceModel = nil - respDeviceType = model.DeviceTypeOther + respDeviceType = model.DeviceTypeHeadless } responseDto := model.InfoDto{ @@ -124,7 +134,7 @@ func (h *DiscoveryHandler) RegisterHandler(w http.ResponseWriter, r *http.Reques Version: config.ProtocolVersion, DeviceModel: respDeviceModel, DeviceType: respDeviceType, - Fingerprint: h.config.SecurityContext.CertificateHash, + Fingerprint: fingerprint, Download: downloadCapable, } diff --git a/pkg/server/handlers/download_handlers.go b/pkg/server/handlers/download_handlers.go index 47dbc31..0b007aa 100644 --- a/pkg/server/handlers/download_handlers.go +++ b/pkg/server/handlers/download_handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "crypto/subtle" "fmt" "io" "net/http" @@ -36,13 +37,18 @@ func (h *DownloadHandler) PrepareDownloadHandler(w http.ResponseWriter, r *http. // --- PIN Check --- if h.config.PIN != "" { pin := r.URL.Query().Get("pin") - if pin != h.config.PIN { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { httputil.RespondError(w, http.StatusUnauthorized, "Invalid PIN") return } } - session := h.sendService.GetSession() + var session *services.ActiveSendSession + if sessionID := r.URL.Query().Get("sessionId"); sessionID != "" { + session = h.sendService.GetSessionByID(sessionID) + } else { + session = h.sendService.GetSession() + } if session == nil { httputil.RespondError(w, http.StatusNotFound, "No active sharing session") return @@ -67,7 +73,7 @@ func (h *DownloadHandler) DownloadHandler(w http.ResponseWriter, r *http.Request // --- PIN Check --- if h.config.PIN != "" { pin := r.URL.Query().Get("pin") - if pin != h.config.PIN { + if subtle.ConstantTimeCompare([]byte(pin), []byte(h.config.PIN)) != 1 { httputil.RespondError(w, http.StatusUnauthorized, "Invalid PIN") return } diff --git a/pkg/server/handlers/exec.go b/pkg/server/handlers/exec.go new file mode 100644 index 0000000..fa43989 --- /dev/null +++ b/pkg/server/handlers/exec.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP string, fileSize int64) { + if h.config.ExecHook == "" { + return + } + + go func() { + h.logger.Infof("Running exec hook: %s", h.config.ExecHook) + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", h.config.ExecHook) + } else { + cmd = exec.Command("sh", "-c", h.config.ExecHook) + } + cmd.Env = append(os.Environ(), + "LOCALGO_FILE="+filePath, + "LOCALGO_NAME="+fileName, + fmt.Sprintf("LOCALGO_SIZE=%d", fileSize), + "LOCALGO_ALIAS="+senderAlias, + "LOCALGO_IP="+senderIP, + ) + output, err := cmd.CombinedOutput() + if err != nil { + h.logger.Errorf("Exec hook failed: %v, output: %s", err, string(output)) + } else { + h.logger.Debugf("Exec hook completed, output: %s", string(output)) + } + }() +} diff --git a/pkg/server/handlers/history_log.go b/pkg/server/handlers/history_log.go new file mode 100644 index 0000000..cb64f05 --- /dev/null +++ b/pkg/server/handlers/history_log.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "github.com/bethropolis/localgo/pkg/history" +) + +func (h *ReceiveHandler) logTransfer(senderAlias, senderIP, fileName, filePath string, size int64, fileType, status string) { + if h.historyLog == nil { + return + } + entry := history.Entry{ + SenderAlias: senderAlias, + SenderIP: senderIP, + FileName: fileName, + FilePath: filePath, + FileSize: size, + FileType: fileType, + Status: status, + } + if err := h.historyLog.Log(entry); err != nil { + h.logger.Errorf("Failed to log transfer history: %v", err) + } +} diff --git a/pkg/server/handlers/prompt.go b/pkg/server/handlers/prompt.go new file mode 100644 index 0000000..3961857 --- /dev/null +++ b/pkg/server/handlers/prompt.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/bethropolis/localgo/pkg/cli" + "github.com/bethropolis/localgo/pkg/model" + "github.com/charmbracelet/huh" +) + +func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files map[string]model.FileDto) bool { + if cli.IsContainer() { + return false + } + + fileCount := len(files) + var totalSize int64 + for _, f := range files { + totalSize += f.Size + } + + cli.Notify("LocalGo: Incoming Transfer", + fmt.Sprintf("%s wants to send you %d file(s) (%s)", sender.Alias, fileCount, cli.FormatBytes(totalSize))) + + // Build a structured summary of the incoming files + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s (IP: %s)\n\nFiles:\n", sender.Alias, sender.IP)) + + count := 0 + for _, file := range files { + if count >= 5 { + sb.WriteString(fmt.Sprintf(" ... and %d more files\n", fileCount-5)) + break + } + isText := strings.HasPrefix(file.FileType, "text/plain") + if isText { + preview := "" + if file.Preview != nil && *file.Preview != "" { + preview = *file.Preview + if len(preview) > 50 { + preview = preview[:50] + "…" + } + sb.WriteString(fmt.Sprintf(" %s [Text] %q\n", cli.IconFile, preview)) + } else { + sb.WriteString(fmt.Sprintf(" %s [Text] %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) + } + } else { + sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) + } + count++ + } + + if totalSize > 0 { + sb.WriteString(fmt.Sprintf("\nTotal Size: %s", cli.FormatBytes(totalSize))) + } + + var accept bool = true + + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Accept Incoming File Transfer?"). + Description(sb.String()). + Value(&accept). + Affirmative("Accept"). + Negative("Reject"), + ), + ).WithTheme(huh.ThemeCharm()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := form.RunWithContext(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "\n%s Transfer automatically rejected.\n", cli.WarningStyle.Render(cli.IconWarning)) + return false + } + + return accept +} diff --git a/pkg/server/handlers/receive_handlers.go b/pkg/server/handlers/receive_handlers.go index df13aff..f6593c0 100644 --- a/pkg/server/handlers/receive_handlers.go +++ b/pkg/server/handlers/receive_handlers.go @@ -1,31 +1,21 @@ package handlers import ( - "bytes" - "context" "crypto/subtle" "encoding/json" - "fmt" - "io" "net" "net/http" - "os" "os/exec" - "path/filepath" "runtime" - "strings" "sync" - "time" "github.com/bethropolis/localgo/pkg/cli" - "github.com/bethropolis/localgo/pkg/clipboard" "github.com/bethropolis/localgo/pkg/config" "github.com/bethropolis/localgo/pkg/history" "github.com/bethropolis/localgo/pkg/httputil" "github.com/bethropolis/localgo/pkg/model" "github.com/bethropolis/localgo/pkg/server/services" "github.com/bethropolis/localgo/pkg/storage" - "github.com/charmbracelet/huh" "go.uber.org/zap" ) @@ -69,9 +59,6 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R } } - // --- Basic Session Check --- - // Concurrent sessions are now supported, so we no longer block if a session exists. - // --- Decode Request --- // Limit request body to prevent memory exhaustion from massive JSON (1 MB limit) decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024*1024)) @@ -85,7 +72,8 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R defer r.Body.Close() if len(requestDto.Files) == 0 { - httputil.RespondError(w, http.StatusBadRequest, "Request must contain at least one file") + h.logger.Info("Received empty file list on prepare-upload, returning 204 Finished") + w.WriteHeader(http.StatusNoContent) return } @@ -154,314 +142,11 @@ func (h *ReceiveHandler) PrepareUploadHandlerV2(w http.ResponseWriter, r *http.R httputil.RespondJSON(w, http.StatusOK, responseDto) } -// UploadHandlerV2 handles POST /v2/upload requests. -func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) { - h.logger.Info("Received /upload request") - if r.Method != http.MethodPost { - httputil.RespondError(w, http.StatusMethodNotAllowed, "Method Not Allowed") - return - } - - // --- Get Query Params --- - query := r.URL.Query() - reqSessionId := query.Get("sessionId") - reqFileId := query.Get("fileId") - reqToken := query.Get("token") - - if reqSessionId == "" || reqFileId == "" || reqToken == "" { - httputil.RespondError(w, http.StatusBadRequest, "Missing query parameters (sessionId, fileId, token)") - return - } - - // --- Validate Session and Token --- - session := h.receiveService.GetSessionByID(reqSessionId) - if session == nil { - h.logger.Warnf("Invalid sessionId '%s' for /upload", reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") // 403 Forbidden - return - } - - // Validate sender IP matches the one from prepare-upload - reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) - if reqIP != session.Sender.IP { - h.logger.Warnf("IP mismatch for /upload: request from %s, expected %s", reqIP, session.Sender.IP) - httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) // 403 Forbidden - return - } - - fileInfo, ok := session.Files[reqFileId] - if !ok || fileInfo.Token != reqToken { - h.logger.Warnf("Invalid fileId '%s' or token '%s' for session '%s'", reqFileId, reqToken, reqSessionId) - httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") // 403 Forbidden - return - } - - // --- File Saving --- - rawFileName := fileInfo.Dto.FileName - destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) - - // Path traversal prevention: ensure the resolved path is still within DownloadDir - cleanPath := filepath.Clean(destinationPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && - cleanPath != filepath.Clean(h.config.DownloadDir) { - h.logger.Errorf("Path traversal attempt detected: %s -> %s", rawFileName, cleanPath) - httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") - return - } - - h.logger.Infof("Starting save for file: %s (ID: %s) to %s", fileInfo.Dto.FileName, reqFileId, destinationPath) - - var trackProgress func(int64) - if !h.config.Quiet && session.Progress != nil { - displayName := fileInfo.Dto.FileName - if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { - preview := *fileInfo.Dto.Preview - if len(preview) > 20 { - preview = preview[:20] + "…" - } - displayName = preview - } - trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) - } - - // --- Progress Callback --- - onProgress := func(bytesWritten int64) { - if trackProgress != nil { - trackProgress(bytesWritten) - } - } - - // --- Body Size Limit --- - maxBodySize := h.config.MaxBodySize - if maxBodySize <= 0 { - maxBodySize = 100 * 1024 * 1024 * 1024 // 100GB default - } - bodyReader := http.MaxBytesReader(w, r.Body, maxBodySize) - defer r.Body.Close() - - var modified, accessed *string - if fileInfo.Dto.Metadata != nil { - modified = fileInfo.Dto.Metadata.Modified - accessed = fileInfo.Dto.Metadata.Accessed - } - - // --- Text/Clipboard Handling --- - // When the incoming transfer is plain text and clipboard is not disabled, - // try to copy the content directly to the system clipboard instead of writing - // to disk. On failure (headless / no display server) fall through to the - // normal file-save path so the content is never lost. - if strings.HasPrefix(fileInfo.Dto.FileType, "text/plain") && !h.config.NoClipboard { - limited := io.LimitReader(bodyReader, maxTextSize+1) - textBytes, readErr := io.ReadAll(limited) - - if readErr != nil { - h.logger.Errorf("Error reading text body for clipboard (file %s): %v", fileInfo.Dto.FileName, readErr) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") - return - } - - text := string(textBytes) - - if int64(len(textBytes)) > maxTextSize { - // Text is too large for clipboard; save to file instead. - h.logger.Warnf("Text transfer too large for clipboard (%d bytes), saving to file", len(textBytes)) - } else if clipErr := clipboard.Write(text); clipErr == nil { - // Successfully copied to clipboard. - preview := text - if len(preview) > 80 { - preview = preview[:80] + "…" - } - h.logger.Infof("Copied text to clipboard from %s: %q", fileInfo.Dto.FileName, preview) - - // Mark the progress bar as completed since no file write occurs - onProgress(fileInfo.Dto.Size) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) - h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) - httputil.RespondJSON(w, http.StatusOK, struct{}{}) - return - } else { - // Clipboard unavailable — fall back to file. - h.logger.Warnf("Clipboard unavailable (%v), saving text as file instead", clipErr) - } - - // Fall-back: save the full stream as a file. - var combinedReader io.Reader - if int64(len(textBytes)) > maxTextSize { - // Re-combine the already-read prefix with the remaining socket stream. - combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) - } else { - combinedReader = bytes.NewReader(textBytes) - } - destinationPath = storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) - cleanPath = filepath.Clean(destinationPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && - cleanPath != filepath.Clean(h.config.DownloadDir) { - httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") - return - } - savErr := storage.SaveStreamToFileWithMetadata( - combinedReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger, - ) - if savErr != nil { - h.logger.Errorf("Error saving text file %s: %v", fileInfo.Dto.FileName, savErr) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusFailed) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") - return - } - h.logger.Infof("Saved text as file: %s", destinationPath) - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) - httputil.RespondJSON(w, http.StatusOK, struct{}{}) - return - } - - err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) - - if err != nil { - h.logger.Errorf("Error saving file %s (ID: %s): %v", fileInfo.Dto.FileName, reqFileId, err) - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusFailed) - httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") - return - } - - // --- Success --- - h.logger.Infof("Finished saving file: %s (ID: %s)", fileInfo.Dto.FileName, reqFileId) - - h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) - - h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) - h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) - - httputil.RespondJSON(w, http.StatusOK, struct{}{}) -} - // PrepareUploadHandlerV1 handles POST /v1/prepare-upload requests (older protocol). func (h *ReceiveHandler) PrepareUploadHandlerV1(w http.ResponseWriter, r *http.Request) { - // This is a simplified version for V1. It will be removed in the future. h.PrepareUploadHandlerV2(w, r) } -func (h *ReceiveHandler) promptUserForAcceptance(sender model.DeviceInfo, files map[string]model.FileDto) bool { - if cli.IsContainer() { - return false - } - - fileCount := len(files) - var totalSize int64 - for _, f := range files { - totalSize += f.Size - } - - cli.Notify("LocalGo: Incoming Transfer", - fmt.Sprintf("%s wants to send you %d file(s) (%s)", sender.Alias, fileCount, cli.FormatBytes(totalSize))) - - // Build a structured summary of the incoming files - var sb strings.Builder - sb.WriteString(fmt.Sprintf("From: %s (IP: %s)\n\nFiles:\n", sender.Alias, sender.IP)) - - count := 0 - for _, file := range files { - if count >= 5 { - sb.WriteString(fmt.Sprintf(" ... and %d more files\n", fileCount-5)) - break - } - isText := strings.HasPrefix(file.FileType, "text/plain") - if isText { - preview := "" - if file.Preview != nil && *file.Preview != "" { - preview = *file.Preview - if len(preview) > 50 { - preview = preview[:50] + "…" - } - sb.WriteString(fmt.Sprintf(" %s [Text] %q\n", cli.IconFile, preview)) - } else { - sb.WriteString(fmt.Sprintf(" %s [Text] %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) - } - } else { - sb.WriteString(fmt.Sprintf(" %s %s (%s)\n", cli.IconFile, file.FileName, cli.FormatBytes(file.Size))) - } - count++ - } - - if totalSize > 0 { - sb.WriteString(fmt.Sprintf("\nTotal Size: %s", cli.FormatBytes(totalSize))) - } - - var accept bool = true - - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Accept Incoming File Transfer?"). - Description(sb.String()). - Value(&accept). - Affirmative("Accept"). - Negative("Reject"), - ), - ).WithTheme(huh.ThemeCharm()) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - err := form.RunWithContext(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "\n%s Transfer automatically rejected.\n", cli.WarningStyle.Render(cli.IconWarning)) - return false - } - - return accept -} - -func (h *ReceiveHandler) logTransfer(senderAlias, senderIP, fileName, filePath string, size int64, fileType, status string) { - if h.historyLog == nil { - return - } - entry := history.Entry{ - SenderAlias: senderAlias, - SenderIP: senderIP, - FileName: fileName, - FilePath: filePath, - FileSize: size, - FileType: fileType, - Status: status, - } - if err := h.historyLog.Log(entry); err != nil { - h.logger.Errorf("Failed to log transfer history: %v", err) - } -} - -func (h *ReceiveHandler) runExecHook(filePath, fileName, senderAlias, senderIP string, fileSize int64) { - if h.config.ExecHook == "" { - return - } - - go func() { - h.logger.Infof("Running exec hook: %s", h.config.ExecHook) - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", h.config.ExecHook) - } else { - cmd = exec.Command("sh", "-c", h.config.ExecHook) - } - cmd.Env = append(os.Environ(), - "LOCALGO_FILE="+filePath, - "LOCALGO_NAME="+fileName, - fmt.Sprintf("LOCALGO_SIZE=%d", fileSize), - "LOCALGO_ALIAS="+senderAlias, - "LOCALGO_IP="+senderIP, - ) - output, err := cmd.CombinedOutput() - if err != nil { - h.logger.Errorf("Exec hook failed: %v, output: %s", err, string(output)) - } else { - h.logger.Debugf("Exec hook completed, output: %s", string(output)) - } - }() -} - // CancelHandler handles POST /v2/cancel requests. func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { h.logger.Info("Received /cancel request") @@ -490,9 +175,12 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { } else if runtime.GOOS == "darwin" { cmd = "open" args = []string{h.config.DownloadDir} - } else { + } else if _, err := exec.LookPath("xdg-open"); err == nil { cmd = "xdg-open" args = []string{h.config.DownloadDir} + } else { + h.logger.Debugf("xdg-open not found in PATH, skip opening download dir") + return } exec.Command(cmd, args...).Run() }() @@ -503,5 +191,5 @@ func (h *ReceiveHandler) CancelHandler(w http.ResponseWriter, r *http.Request) { // successful transfer, so this is the normal post-upload flow — return 200. h.logger.Infof("/cancel received for already-closed session %s — treating as success.", reqSessionId) } - httputil.RespondJSON(w, http.StatusOK, struct{}{}) + w.WriteHeader(http.StatusOK) } diff --git a/pkg/server/handlers/receive_handlers_test.go b/pkg/server/handlers/receive_handlers_test.go index d57a56d..11797e0 100644 --- a/pkg/server/handlers/receive_handlers_test.go +++ b/pkg/server/handlers/receive_handlers_test.go @@ -113,11 +113,11 @@ func TestPrepareUploadHandlerV2_PINValidation(t *testing.T) { } } -func TestPrepareUploadHandlerV2_ConcurrentSessions(t *testing.T) { +func TestPrepareUploadHandlerV2_RejectsConcurrentSessions(t *testing.T) { handler, receiveService, _ := setupReceiveHandler(t, nil) // Create an active session - session1, _ := receiveService.CreateSession(model.DeviceInfo{IP: "192.168.1.100"}, map[string]model.FileDto{"f": {ID: "f"}}) + receiveService.CreateSession(model.DeviceInfo{IP: "192.168.1.100"}, map[string]model.FileDto{"f": {ID: "f"}}) reqDto := model.PrepareUploadRequestDto{ Files: map[string]model.FileDto{"file1": {ID: "file1", FileName: "test.txt", Size: 10}}, @@ -129,15 +129,8 @@ func TestPrepareUploadHandlerV2_ConcurrentSessions(t *testing.T) { handler.PrepareUploadHandlerV2(rr, req) - if status := rr.Code; status != http.StatusOK { - t.Errorf("handler returned wrong status code for concurrent session: got %v want %v", status, http.StatusOK) - } - - var respDto model.PrepareUploadResponseDto - json.NewDecoder(rr.Body).Decode(&respDto) - - if respDto.SessionID == "" || respDto.SessionID == session1.SessionID { - t.Errorf("expected new session to be created") + if status := rr.Code; status != http.StatusConflict { + t.Errorf("handler returned wrong status code for concurrent session: got %v want %v", status, http.StatusConflict) } } diff --git a/pkg/server/handlers/receive_upload.go b/pkg/server/handlers/receive_upload.go new file mode 100644 index 0000000..73f8928 --- /dev/null +++ b/pkg/server/handlers/receive_upload.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "bytes" + "fmt" + "io" + "net" + "net/http" + "path/filepath" + "strings" + + "github.com/bethropolis/localgo/pkg/clipboard" + "github.com/bethropolis/localgo/pkg/history" + "github.com/bethropolis/localgo/pkg/httputil" + "github.com/bethropolis/localgo/pkg/server/services" + "github.com/bethropolis/localgo/pkg/storage" +) + +func (h *ReceiveHandler) UploadHandlerV2(w http.ResponseWriter, r *http.Request) { + h.logger.Info("Received /upload request") + if r.Method != http.MethodPost { + httputil.RespondError(w, http.StatusMethodNotAllowed, "Method Not Allowed") + return + } + + // --- Get Query Params --- + query := r.URL.Query() + reqSessionId := query.Get("sessionId") + reqFileId := query.Get("fileId") + reqToken := query.Get("token") + + if reqSessionId == "" || reqFileId == "" || reqToken == "" { + httputil.RespondError(w, http.StatusBadRequest, "Missing query parameters (sessionId, fileId, token)") + return + } + + // --- Validate Session and Token --- + session := h.receiveService.GetSessionByID(reqSessionId) + if session == nil { + h.logger.Warnf("Invalid sessionId '%s' for /upload", reqSessionId) + httputil.RespondError(w, http.StatusForbidden, "Invalid session ID") // 403 Forbidden + return + } + + // Validate sender IP matches the one from prepare-upload + reqIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if reqIP != session.Sender.IP { + h.logger.Warnf("IP mismatch for /upload: request from %s, expected %s", reqIP, session.Sender.IP) + httputil.RespondError(w, http.StatusForbidden, fmt.Sprintf("Invalid IP address: %s", reqIP)) // 403 Forbidden + return + } + + fileInfo, ok := session.Files[reqFileId] + if !ok || fileInfo.Token != reqToken { + h.logger.Warnf("Invalid fileId '%s' or token '%s' for session '%s'", reqFileId, reqToken, reqSessionId) + httputil.RespondError(w, http.StatusForbidden, "Invalid fileId or token") // 403 Forbidden + return + } + + // --- File Saving --- + rawFileName := fileInfo.Dto.FileName + destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + + // Path traversal prevention: ensure the resolved path is still within DownloadDir + cleanPath := filepath.Clean(destinationPath) + if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && + cleanPath != filepath.Clean(h.config.DownloadDir) { + h.logger.Errorf("Path traversal attempt detected: %s -> %s", rawFileName, cleanPath) + httputil.RespondError(w, http.StatusBadRequest, "Invalid filename") + return + } + + h.logger.Infof("Starting save for file: %s (ID: %s) to %s", fileInfo.Dto.FileName, reqFileId, destinationPath) + + var trackProgress func(int64) + if !h.config.Quiet && session.Progress != nil { + displayName := fileInfo.Dto.FileName + if fileInfo.Dto.Preview != nil && *fileInfo.Dto.Preview != "" { + preview := *fileInfo.Dto.Preview + if len(preview) > 20 { + preview = preview[:20] + "…" + } + displayName = preview + } + trackProgress = session.Progress.AddBar(displayName, fileInfo.Dto.Size) + } + + // --- Progress Callback --- + onProgress := func(bytesWritten int64) { + if trackProgress != nil { + trackProgress(bytesWritten) + } + } + + // --- Body Size Limit --- + maxBodySize := h.config.MaxBodySize + if maxBodySize <= 0 { + maxBodySize = 100 * 1024 * 1024 * 1024 // 100GB default + } + bodyReader := http.MaxBytesReader(w, r.Body, maxBodySize) + defer r.Body.Close() + + var modified, accessed *string + if fileInfo.Dto.Metadata != nil { + modified = fileInfo.Dto.Metadata.Modified + accessed = fileInfo.Dto.Metadata.Accessed + } + + // --- Text/Clipboard Handling --- + // When the incoming transfer is plain text and clipboard is not disabled, + // try to copy the content directly to the system clipboard instead of writing + // to disk. On failure (headless / no display server) fall through to the + // normal file-save path so the content is never lost. + if strings.HasPrefix(fileInfo.Dto.FileType, "text/plain") && !h.config.NoClipboard { + limited := io.LimitReader(bodyReader, maxTextSize+1) + textBytes, readErr := io.ReadAll(limited) + + if readErr != nil { + h.logger.Errorf("Error reading text body for clipboard (file %s): %v", fileInfo.Dto.FileName, readErr) + httputil.RespondError(w, http.StatusInternalServerError, "Failed to read text content") + return + } + + text := string(textBytes) + + if int64(len(textBytes)) > maxTextSize { + // Text is too large for clipboard; save to file instead. + h.logger.Warnf("Text transfer too large for clipboard (%d bytes), saving to file", len(textBytes)) + } else if clipErr := clipboard.Write(text); clipErr == nil { + // Successfully copied to clipboard. + preview := text + if len(preview) > 80 { + preview = preview[:80] + "…" + } + h.logger.Infof("Copied text to clipboard from %s: %q", fileInfo.Dto.FileName, preview) + + // Mark the progress bar as completed since no file write occurs + onProgress(fileInfo.Dto.Size) + + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, "", int64(len(textBytes)), fileInfo.Dto.FileType, history.StatusClipboard) + h.runExecHook("", rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) + w.WriteHeader(http.StatusOK) + return + } else { + // Clipboard unavailable — fall back to file. + h.logger.Warnf("Clipboard unavailable (%v), saving text as file instead", clipErr) + } + + // Fall-back: save the full stream as a file. + h.saveTextAsFile(session, reqSessionId, reqFileId, rawFileName, bodyReader, textBytes, modified, accessed, onProgress) + return + } + + err := storage.SaveStreamToFileWithMetadata(bodyReader, destinationPath, fileInfo.Dto.Size, modified, accessed, fileInfo.Dto.SHA256, onProgress, h.logger) + + if err != nil { + h.logger.Errorf("Error saving file %s (ID: %s): %v", fileInfo.Dto.FileName, reqFileId, err) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusFailed) + httputil.RespondError(w, http.StatusInternalServerError, "Failed to save file") + return + } + + // --- Success --- + h.logger.Infof("Finished saving file: %s (ID: %s)", fileInfo.Dto.FileName, reqFileId) + + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, fileInfo.Dto.Size, fileInfo.Dto.FileType, history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, fileInfo.Dto.Size) + + w.WriteHeader(http.StatusOK) +} + +// saveTextAsFile saves text content as a file when clipboard is unavailable or text is too large. +func (h *ReceiveHandler) saveTextAsFile(session *services.ActiveReceiveSession, reqSessionId, reqFileId, rawFileName string, bodyReader io.Reader, textBytes []byte, modified, accessed *string, onProgress func(int64)) { + var combinedReader io.Reader + if int64(len(textBytes)) > maxTextSize { + combinedReader = io.MultiReader(bytes.NewReader(textBytes), bodyReader) + } else { + combinedReader = bytes.NewReader(textBytes) + } + destinationPath := storage.ResolveDuplicateFilename(h.config.DownloadDir, rawFileName) + cleanPath := filepath.Clean(destinationPath) + if !strings.HasPrefix(cleanPath, filepath.Clean(h.config.DownloadDir)+string(filepath.Separator)) && + cleanPath != filepath.Clean(h.config.DownloadDir) { + h.logger.Errorf("Path traversal attempt detected in text fallback: %s", rawFileName) + return + } + savErr := storage.SaveStreamToFileWithMetadata( + combinedReader, destinationPath, int64(len(textBytes)), modified, accessed, nil, onProgress, h.logger, + ) + if savErr != nil { + h.logger.Errorf("Error saving text file %s: %v", rawFileName, savErr) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusFailed) + return + } + h.logger.Infof("Saved text as file: %s", destinationPath) + h.receiveService.RemoveFileFromSession(reqSessionId, reqFileId) + h.logTransfer(session.Sender.Alias, session.Sender.IP, rawFileName, destinationPath, int64(len(textBytes)), "text/plain", history.StatusReceived) + h.runExecHook(destinationPath, rawFileName, session.Sender.Alias, session.Sender.IP, int64(len(textBytes))) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 1a3b390..bf905b2 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -96,8 +96,8 @@ func (s *Server) Start(ctx context.Context, readyChan chan<- struct{}) error { s.httpServer = &http.Server{ Addr: addr, Handler: s.muxRouter, - ReadTimeout: 0, - WriteTimeout: 0, + ReadTimeout: 30 * time.Second, + WriteTimeout: 300 * time.Second, ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } @@ -178,6 +178,9 @@ func (s *Server) Shutdown(ctx context.Context) error { } s.logger.Info("Server stopped.") s.httpServer = nil + if s.receiveService != nil { + s.receiveService.Close() + } if s.historyLog != nil { if err := s.historyLog.Close(); err != nil { s.logger.Warnf("Failed to close history log: %v", err) diff --git a/pkg/server/services/receive_service.go b/pkg/server/services/receive_service.go index 9a65fe8..a4439fb 100644 --- a/pkg/server/services/receive_service.go +++ b/pkg/server/services/receive_service.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "sync" "time" @@ -28,40 +29,61 @@ type ActiveFile struct { type ReceiveService struct { sessions map[string]*ActiveReceiveSession sessionMutex sync.RWMutex + stopCh chan struct{} + closeOnce sync.Once } // NewReceiveService creates a new ReceiveService. func NewReceiveService() *ReceiveService { s := &ReceiveService{ sessions: make(map[string]*ActiveReceiveSession), + stopCh: make(chan struct{}), } go s.cleanupLoop() return s } +// Close stops the cleanup loop and releases resources. +func (s *ReceiveService) Close() { + s.closeOnce.Do(func() { + close(s.stopCh) + }) +} + // cleanupLoop periodically checks and expires stale sessions func (s *ReceiveService) cleanupLoop() { ticker := time.NewTicker(1 * time.Minute) - for range ticker.C { - s.sessionMutex.Lock() - for id, session := range s.sessions { - if time.Since(session.CreatedAt) > 10*time.Minute { - if session.Progress != nil { - session.Progress.ForceComplete() - go session.Progress.Wait() + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.sessionMutex.Lock() + for id, session := range s.sessions { + if time.Since(session.CreatedAt) > 10*time.Minute { + if session.Progress != nil { + session.Progress.ForceComplete() + go session.Progress.Wait() + } + delete(s.sessions, id) } - delete(s.sessions, id) } + s.sessionMutex.Unlock() } - s.sessionMutex.Unlock() } } // CreateSession creates a new receive session. +// Returns an error if another session is already active (409 Blocked by another session). func (s *ReceiveService) CreateSession(sender model.DeviceInfo, files map[string]model.FileDto) (*ActiveReceiveSession, error) { s.sessionMutex.Lock() defer s.sessionMutex.Unlock() + if len(s.sessions) > 0 { + return nil, fmt.Errorf("another session is already active") + } + sessionId := uuid.NewString() sessionFiles := make(map[string]ActiveFile) for fileId, fileDto := range files { diff --git a/pkg/server/services/receive_service_test.go b/pkg/server/services/receive_service_test.go index 41dd22e..57993f4 100644 --- a/pkg/server/services/receive_service_test.go +++ b/pkg/server/services/receive_service_test.go @@ -44,7 +44,7 @@ func TestReceiveService_CreateSession(t *testing.T) { } } -func TestReceiveService_CreateSession_MultipleSessions(t *testing.T) { +func TestReceiveService_CreateSession_BlocksConcurrent(t *testing.T) { svc := NewReceiveService() sender := model.DeviceInfo{ @@ -57,11 +57,20 @@ func TestReceiveService_CreateSession_MultipleSessions(t *testing.T) { "file1": {ID: "file1", FileName: "test.txt", Size: 1024}, } - createdSession1, _ := svc.CreateSession(sender, files) - createdSession2, _ := svc.CreateSession(sender, files) + first, err := svc.CreateSession(sender, files) + if err != nil { + t.Fatalf("First CreateSession should succeed: %v", err) + } + if first == nil { + t.Fatal("Expected non-nil session") + } - if createdSession1.SessionID == createdSession2.SessionID { - t.Error("Expected different session IDs for concurrent sessions") + second, err := svc.CreateSession(sender, files) + if err == nil { + t.Error("Expected error for second concurrent session, got nil") + } + if second != nil { + t.Error("Expected nil session for blocked concurrent session") } } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index eaaea8e..c660b77 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -66,11 +66,20 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in return err } - outFile, err := os.Create(filePath) + // Write to a temporary file first, then atomically rename on success + tempPath := filePath + ".tmp" + outFile, err := os.Create(tempPath) if err != nil { - return fmt.Errorf("failed to create file %s: %w", filePath, err) + return fmt.Errorf("failed to create temp file: %w", err) } - defer outFile.Close() + + cleanup := true + defer func() { + outFile.Close() + if cleanup { + _ = os.Remove(tempPath) + } + }() progressWriter := &ProgressWriter{ Writer: outFile, @@ -95,29 +104,17 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in _, err = io.CopyBuffer(progressWriter, hashingReader, *bufPtr) if err != nil { - outFile.Close() - if removeErr := os.Remove(filePath); removeErr != nil { - if logger != nil { - logger.Warnw("Failed to remove partially written file", "path", filePath, "error", removeErr) - } - } - return fmt.Errorf("failed to copy stream to file %s: %w", filePath, err) + return fmt.Errorf("failed to copy stream: %w", err) } - if closeErr := outFile.Close(); closeErr != nil { - if removeErr := os.Remove(filePath); removeErr != nil && logger != nil { - logger.Warnw("Failed to remove incomplete file after close error", "path", filePath, "error", removeErr) - } - return fmt.Errorf("failed to close and flush file %s: %w", filePath, closeErr) + if err := outFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) } // Verify SHA-256 checksum if the sender provided one if hasher != nil { calculatedHash := hex.EncodeToString(hasher.Sum(nil)) if calculatedHash != *expectedSha256 { - if removeErr := os.Remove(filePath); removeErr != nil && logger != nil { - logger.Warnw("Failed to remove corrupted file", "path", filePath, "error", removeErr) - } return fmt.Errorf("integrity violation: SHA-256 mismatch (got %s, expected %s)", calculatedHash, *expectedSha256) } if logger != nil { @@ -125,6 +122,7 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in } } + // Apply timestamps to the temp file before promotion if modified != nil || accessed != nil { mtime := time.Now() atime := time.Now() @@ -151,13 +149,19 @@ func SaveStreamToFileWithMetadata(stream io.Reader, filePath string, fileSize in } } - if err := os.Chtimes(filePath, atime, mtime); err != nil { + if err := os.Chtimes(tempPath, atime, mtime); err != nil { if logger != nil { logger.Warnw("Failed to apply timestamps", "path", filePath, "error", err) } } } + // Atomically promote temp file to final path + if err := os.Rename(tempPath, filePath); err != nil { + return fmt.Errorf("failed to finalize transfer: %w", err) + } + cleanup = false + if logger != nil { logger.Infow("Successfully saved stream", "path", filePath) } @@ -201,7 +205,10 @@ func ResolveDuplicateFilename(dir, baseName string) string { // Fallback to avoid silent overwrite if (1) through (999) are all taken randomBytes := make([]byte, 3) - rand.Read(randomBytes) + if _, err := rand.Read(randomBytes); err != nil { + newName := fmt.Sprintf("%s_%d%s", nameWithoutExt, time.Now().UnixNano(), ext) + return filepath.Join(dir, newName) + } newName := fmt.Sprintf("%s_%s%s", nameWithoutExt, hex.EncodeToString(randomBytes), ext) return filepath.Join(dir, newName) } diff --git a/pkg/storage/storage_windows.go b/pkg/storage/storage_windows.go index d17a234..3b357d7 100644 --- a/pkg/storage/storage_windows.go +++ b/pkg/storage/storage_windows.go @@ -7,10 +7,12 @@ import ( "unsafe" ) -func getAvailableBytes(path string) (uint64, error) { - h := syscall.MustLoadDLL("kernel32.dll") - c := h.MustFindProc("GetDiskFreeSpaceExW") +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetDiskFreeSpace = modkernel32.NewProc("GetDiskFreeSpaceExW") +) +func getAvailableBytes(path string) (uint64, error) { var freeBytes int64 pathPtr, err := syscall.UTF16PtrFromString(path) @@ -18,7 +20,7 @@ func getAvailableBytes(path string) (uint64, error) { return 0, err } - r, _, err := c.Call( + r, _, err := procGetDiskFreeSpace.Call( uintptr(unsafe.Pointer(pathPtr)), uintptr(unsafe.Pointer(&freeBytes)), 0, diff --git a/scripts/install.sh b/scripts/install.sh index 14899dc..c0eee04 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -587,25 +587,25 @@ main() { fi # Run installation steps sequentially with progress counters - section "1/7" "Verifying system prerequisites" + section "1" "Verifying system prerequisites" check_prerequisites - section "2/7" "Compiling LocalGo static binary" + section "2" "Compiling LocalGo static binary" build_binary - section "3/7" "Creating configuration directories" + section "3" "Creating configuration directories" create_directories - section "4/7" "Installing executable binary" + section "4" "Installing executable binary" install_binary - section "5/7" "Deploying configuration environment" + section "5" "Deploying configuration environment" install_configuration - section "6/7" "Configuring systemd background services" + section "6" "Configuring systemd background services" install_service - section "7/7" "Generating shell auto-completions" + section "7" "Generating shell auto-completions" install_completion echo diff --git a/scripts/online-install.sh b/scripts/online-install.sh new file mode 100644 index 0000000..52e7b3d --- /dev/null +++ b/scripts/online-install.sh @@ -0,0 +1,514 @@ +#!/bin/bash +# +# 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). +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/bethropolis/localgo/main/scripts/online-install.sh | bash +# curl -fsSL ... | bash -s -- --mode system +# curl -fsSL ... | bash -s -- --mode system --service --completion +# curl -fsSL ... | bash -s -- --version 0.5.5 +# + +set -euo pipefail + +# ── Constants ────────────────────────────────────────────────────── +BINARY_NAME="localgo" +GH_OWNER="bethropolis" +GH_REPO="localgo" +GH_URL="https://github.com/$GH_OWNER/$GH_REPO" +GH_API="https://api.github.com/repos/$GH_OWNER/$GH_REPO" + +USER_BIN_DIR="$HOME/.local/bin" +USER_CONFIG_DIR="$HOME/.config/localgo" +USER_SERVICE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +SYSTEM_BIN_DIR="/usr/local/bin" +SYSTEM_CONFIG_DIR="/etc/localgo" +SYSTEM_SERVICE_DIR="/etc/systemd/system" + +# ── Flags (defaults — all extras opt-in) ─────────────────────────── +INSTALL_MODE="user" +INSTALL_SERVICE=false +INSTALL_COMPLETION=false +INSTALL_CONFIG=false +ASSUME_YES=false +DRY_RUN=false +PINNED_VERSION="" + +# ── Runtime ──────────────────────────────────────────────────────── +OS="" +ARCH="" +VERSION="" +TAG="" +TMPDIR="" +BINARY_PATH="" + +# ── Colors ───────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +NC='\033[0m' + +# ── Output helpers ───────────────────────────────────────────────── +info() { echo -e " ${BLUE}ℹ${NC} $*"; } +ok() { echo -e " ${GREEN}✔${NC} $*"; } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } +err() { echo -e " ${RED}✖${NC} $*" >&2; } +header(){ echo -e "\n ${MAGENTA}◆${NC} $*"; } + +# ── Usage ───────────────────────────────────────────────────────── +usage() { + cat </dev/null || true) + + if [[ -z "$latest_url" || "$latest_url" == *"/releases/latest" ]]; then + # Fallback: GitHub JSON API + info "Redirect resolution failed, falling back to GitHub API..." + latest_url=$(curl -sL "$GH_API/releases/latest" 2>/dev/null \ + | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true) + [[ -z "$latest_url" ]] && die "Failed to resolve latest version. Use --version to specify one." + TAG="$latest_url" + else + TAG=$(echo "$latest_url" | sed 's|.*/||') + fi + + VERSION="${TAG#v}" + ok "Latest release: $TAG" +} + +# ── Step 3: Print Plan ───────────────────────────────────────────── +print_plan() { + local dest + if [[ "$INSTALL_MODE" == "system" ]]; then + dest="$SYSTEM_BIN_DIR/$BINARY_NAME" + else + dest="$USER_BIN_DIR/$BINARY_NAME" + fi + + echo + echo " ┌─ Installation Plan ──────────────────────────────────┐" + printf " │ %-20s %-30s │\n" "Version:" "$TAG" + printf " │ %-20s %-30s │\n" "Platform:" "${OS}_${ARCH}" + printf " │ %-20s %-30s │\n" "Destination:" "$dest" + printf " │ %-20s %-30s │\n" "Service:" "$( [[ $INSTALL_SERVICE == true ]] && echo yes || echo no )" + printf " │ %-20s %-30s │\n" "Completions:" "$( [[ $INSTALL_COMPLETION == true ]] && echo yes || echo no )" + printf " │ %-20s %-30s │\n" "Config:" "$( [[ $INSTALL_CONFIG == true ]] && echo yes || echo no )" + echo " └──────────────────────────────────────────────────────┘" + echo + + if [[ "$INSTALL_MODE" == "system" && $EUID -ne 0 ]]; then + info "System mode: sudo will be used for file operations." + fi +} + +# ── Step 4: Download + Verify ────────────────────────────────────── +download_and_verify() { + header "Downloading LocalGo $TAG..." + + TMPDIR=$(mktemp -d) + local archive_name="localgo_${VERSION}_${OS}_${ARCH}.tar.gz" + local archive_url="$GH_URL/releases/download/$TAG/$archive_name" + local checksum_url="$GH_URL/releases/download/$TAG/checksums.txt" + + info "Downloading archive: $archive_name" + + if command -v curl &>/dev/null; then + curl -fsSL "$archive_url" -o "$TMPDIR/$archive_name" || die "Download failed: $archive_url" + curl -fsSL "$checksum_url" -o "$TMPDIR/checksums.txt" 2>/dev/null || warn "Checksums file not found, skipping verification" + elif command -v wget &>/dev/null; then + wget -qO "$TMPDIR/$archive_name" "$archive_url" || die "Download failed: $archive_url" + wget -qO "$TMPDIR/checksums.txt" "$checksum_url" 2>/dev/null || warn "Checksums file not found, skipping verification" + else + die "Neither curl nor wget found. Install one of them and retry." + fi + + ok "Archive downloaded" + + # ── Verify Checksum ── + if [[ -f "$TMPDIR/checksums.txt" ]]; then + local expected_hash + expected_hash=$(grep "$archive_name" "$TMPDIR/checksums.txt" | awk '{print $1}') + if [[ -n "$expected_hash" ]]; then + local actual_hash="" + if command -v sha256sum &>/dev/null; then + actual_hash=$(sha256sum "$TMPDIR/$archive_name" | awk '{print $1}') + elif command -v shasum &>/dev/null; then + actual_hash=$(shasum -a 256 "$TMPDIR/$archive_name" | awk '{print $1}') + fi + if [[ -n "$actual_hash" ]]; then + if [[ "$expected_hash" == "$actual_hash" ]]; then + ok "Checksum verified (SHA-256)" + else + die "Checksum mismatch! Expected: $expected_hash, Got: $actual_hash" + fi + fi + else + warn "No checksum entry for $archive_name, skipping verification" + fi + fi + + # ── Extract ── + info "Extracting archive..." + tar -xzf "$TMPDIR/$archive_name" -C "$TMPDIR" || die "Failed to extract archive" + ok "Archive extracted" + + # ── Locate Binary (handle both wrapped and flat archives) ── + BINARY_PATH=$(find "$TMPDIR" -maxdepth 3 -type f -name "$BINARY_NAME" 2>/dev/null | head -1) + [[ -z "$BINARY_PATH" ]] && die "Binary not found in archive" + ok "Binary located: $(basename "$BINARY_PATH") v$VERSION" +} + +# ── Step 5: Install Binary ───────────────────────────────────────── +install_binary() { + header "Installing binary..." + + local dest_dir + [[ "$INSTALL_MODE" == "system" ]] && dest_dir="$SYSTEM_BIN_DIR" || dest_dir="$USER_BIN_DIR" + + sudo_cmd mkdir -p "$dest_dir" + sudo_cmd cp "$BINARY_PATH" "$dest_dir/$BINARY_NAME" + sudo_cmd chmod 755 "$dest_dir/$BINARY_NAME" + + ok "Binary installed to $dest_dir/$BINARY_NAME" +} + +# ── Helper: locate asset dir (scripts/ in archive) ───────────────── +find_asset_dir() { + local dir + dir=$(dirname "$BINARY_PATH") + [[ -d "$dir/scripts" ]] && echo "$dir/scripts" && return + dir=$(dirname "$dir") + [[ -d "$dir/scripts" ]] && echo "$dir/scripts" && return + echo "" +} + +# ── Opt-in: Completions ──────────────────────────────────────────── +install_completions() { + header "Installing shell completions..." + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Completions not found in archive, skipping"; return; } + + local count=0 + + if [[ -f "$scripts_dir/bash_completion.sh" ]] && command -v bash &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/bash-completion/completions + sudo cp "$scripts_dir/bash_completion.sh" "/usr/share/bash-completion/completions/$BINARY_NAME" + else + mkdir -p "$HOME/.local/share/bash-completion/completions" + cp "$scripts_dir/bash_completion.sh" "$HOME/.local/share/bash-completion/completions/$BINARY_NAME" + fi + ok "Bash completions installed" + ((count++)) + fi + + if [[ -f "$scripts_dir/zsh_completion.zsh" ]] && command -v zsh &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/zsh/site-functions + sudo cp "$scripts_dir/zsh_completion.zsh" "/usr/share/zsh/site-functions/_$BINARY_NAME" + else + mkdir -p "$HOME/.local/share/zsh/site-functions" + cp "$scripts_dir/zsh_completion.zsh" "$HOME/.local/share/zsh/site-functions/_$BINARY_NAME" + fi + ok "Zsh completions installed" + ((count++)) + fi + + if [[ -f "$scripts_dir/fish_completion.fish" ]] && command -v fish &>/dev/null; then + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p /usr/share/fish/vendor_completions.d + sudo cp "$scripts_dir/fish_completion.fish" "/usr/share/fish/vendor_completions.d/$BINARY_NAME.fish" + else + mkdir -p "$HOME/.config/fish/completions" + cp "$scripts_dir/fish_completion.fish" "$HOME/.config/fish/completions/$BINARY_NAME.fish" + fi + ok "Fish completions installed" + ((count++)) + fi + + [[ $count -eq 0 ]] && warn "No compatible shell found for completions" +} + +# ── Opt-in: Service (Linux only, systemd required) ───────────────── +install_service() { + header "Installing systemd service..." + + [[ "$OS" != "linux" ]] && { warn "systemd not available on macOS, skipping"; return; } + command -v systemctl &>/dev/null || { warn "systemctl not found, skipping"; return; } + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Service file not found in archive, skipping"; return; } + + local service_src="" + [[ -f "$scripts_dir/localgo-pkg.service" ]] && service_src="$scripts_dir/localgo-pkg.service" + [[ -z "$service_src" && -f "$scripts_dir/localgo.service" ]] && service_src="$scripts_dir/localgo.service" + [[ -z "$service_src" ]] && { warn "Service file not found in archive, skipping"; return; } + + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + if [[ "$INSTALL_MODE" == "system" ]]; then + local svc_dest="$SYSTEM_SERVICE_DIR/$BINARY_NAME.service" + sudo mkdir -p "$SYSTEM_SERVICE_DIR" + sudo cp "$service_src" "$svc_dest" + sudo sed -i "s|ExecStart=.*|ExecStart=$bin_path serve --quiet --auto-accept|g" "$svc_dest" 2>/dev/null || true + sudo sed -i "/^EnvironmentFile=/d" "$svc_dest" 2>/dev/null || true + sudo systemctl daemon-reload 2>/dev/null || true + ok "System service installed: $svc_dest" + else + local svc_dest="$USER_SERVICE_DIR/$BINARY_NAME.service" + mkdir -p "$USER_SERVICE_DIR" + cp "$service_src" "$svc_dest" + sed -i "s|ExecStart=.*|ExecStart=$bin_path serve --quiet --auto-accept|g" "$svc_dest" 2>/dev/null || true + sed -i "/^EnvironmentFile=/d" "$svc_dest" 2>/dev/null || true + sed -i "/^User=/d" "$svc_dest" 2>/dev/null || true + sed -i "/^Group=/d" "$svc_dest" 2>/dev/null || true + systemctl --user daemon-reload 2>/dev/null || true + ok "User service installed: $svc_dest" + fi +} + +# ── Opt-in: Config ───────────────────────────────────────────────── +install_config() { + header "Installing configuration..." + + local scripts_dir + scripts_dir=$(find_asset_dir) + [[ -z "$scripts_dir" ]] && { warn "Config template not found in archive, skipping"; return; } + + local env_src="$scripts_dir/localgo.env.example" + [[ ! -f "$env_src" ]] && { warn "Config template not found in archive, skipping"; return; } + + if [[ "$INSTALL_MODE" == "system" ]]; then + sudo mkdir -p "$SYSTEM_CONFIG_DIR" + if [[ ! -f "$SYSTEM_CONFIG_DIR/localgo.env" ]]; then + sudo cp "$env_src" "$SYSTEM_CONFIG_DIR/localgo.env" + sudo chmod 644 "$SYSTEM_CONFIG_DIR/localgo.env" + ok "Config installed to $SYSTEM_CONFIG_DIR/localgo.env" + else + info "Config already exists at $SYSTEM_CONFIG_DIR/localgo.env, skipping" + fi + else + mkdir -p "$USER_CONFIG_DIR" + if [[ ! -f "$USER_CONFIG_DIR/localgo.env" ]]; then + cp "$env_src" "$USER_CONFIG_DIR/localgo.env" + chmod 600 "$USER_CONFIG_DIR/localgo.env" + ok "Config installed to $USER_CONFIG_DIR/localgo.env" + else + info "Config already exists at $USER_CONFIG_DIR/localgo.env, skipping" + fi + fi +} + +# ── Verify Installation ──────────────────────────────────────────── +verify_installation() { + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + if [[ ! -x "$bin_path" ]]; then + die "Binary not executable at $bin_path" + fi + + local installed_ver + installed_ver=$("$bin_path" version 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' || echo "$VERSION") + ok "LocalGo v$installed_ver verified at $bin_path" +} + +# ── Post-Install Summary ─────────────────────────────────────────── +print_summary() { + local bin_path + [[ "$INSTALL_MODE" == "system" ]] && bin_path="$SYSTEM_BIN_DIR/$BINARY_NAME" || bin_path="$USER_BIN_DIR/$BINARY_NAME" + + echo + echo " ┌─ Installation Complete ──────────────────────────┐" + printf " │ LocalGo v%-30s │\n" "$VERSION" + echo " └──────────────────────────────────────────────────┘" + echo + + info "Binary: $bin_path" + + if [[ "$INSTALL_MODE" == "user" && ":$PATH:" != *":$USER_BIN_DIR:"* ]]; then + warn "Add $USER_BIN_DIR to your PATH:" + case "${SHELL:-}" in + *fish) echo " fish_add_path $USER_BIN_DIR" ;; + *zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;; + *) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;; + esac + echo + fi + + info "Quick start:" + echo " $BINARY_NAME help # Show commands" + echo " $BINARY_NAME info # Device info" + echo " $BINARY_NAME discover # Find peers" + echo " $BINARY_NAME send --help # Send files" + echo + + if [[ "$INSTALL_SERVICE" == true && "$OS" == "linux" ]]; then + info "Service management:" + if [[ "$INSTALL_MODE" == "system" ]]; then + echo " sudo systemctl enable --now $BINARY_NAME" + echo " sudo journalctl -u $BINARY_NAME -f" + else + echo " systemctl --user enable --now $BINARY_NAME" + echo " journalctl --user -u $BINARY_NAME -f" + echo + info "To keep service alive after logout:" + echo " loginctl enable-linger $USER" + fi + echo + fi +} + +# ── Main ─────────────────────────────────────────────────────────── +main() { + echo " ◆ LocalGo Online Installer" + echo " LocalSend v2.1 Protocol CLI" + echo " ─────────────────────────────────" + echo + + # ── Parse args ── + while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) + PINNED_VERSION="$2" + shift 2 + ;; + --mode) + INSTALL_MODE="$2" + shift 2 + ;; + --service) INSTALL_SERVICE=true; shift ;; + --completion) INSTALL_COMPLETION=true; shift ;; + --config) INSTALL_CONFIG=true; shift ;; + -y|--yes) ASSUME_YES=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage ;; + *) die "Unknown option: $1 (use --help for usage)" ;; + esac + done + + [[ "$INSTALL_MODE" != "user" && "$INSTALL_MODE" != "system" ]] \ + && die "Invalid mode: $INSTALL_MODE (use user or system)" + + detect_platform + resolve_version + print_plan + + [[ "$DRY_RUN" == "true" ]] && { info "Dry run — exiting."; exit 0; } + + # Auto-yes when piped (non-TTY stdin) + [[ ! -t 0 ]] && ASSUME_YES=true + confirm_or_skip + + download_and_verify + install_binary + verify_installation + + [[ "$INSTALL_COMPLETION" == "true" ]] && install_completions + [[ "$INSTALL_SERVICE" == "true" ]] && install_service + [[ "$INSTALL_CONFIG" == "true" ]] && install_config + + print_summary +} + +main "$@" diff --git a/scripts/rc.d/localgo b/scripts/rc.d/localgo new file mode 100755 index 0000000..554eaf0 --- /dev/null +++ b/scripts/rc.d/localgo @@ -0,0 +1,21 @@ +#!/bin/sh + +# PROVIDE: localgo +# REQUIRE: NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="localgo" +rcvar="localgo_enable" + +load_rc_config $name + +: ${localgo_enable:="NO"} +: ${localgo_user:="nobody"} + +command="/usr/local/bin/localgo" +command_args="serve --quiet" +pidfile="/var/run/localgo.pid" + +run_rc_command "$1"