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"