Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
6f8a9cc
feat: add private mode, progress bar fixes, metadata stripping, and c…
bethropolis May 29, 2026
be29c69
feat: add send --ip, scan --range flags, ParseCIDRRange, export SendT…
bethropolis May 29, 2026
c01ef58
fix: docker-start passes CMD args correctly (no double localgo)
bethropolis Jun 7, 2026
37be6e8
fix(scratch): set LOCALSEND_DOWNLOAD_DIR and LOCALSEND_SECURITY_DIR e…
bethropolis Jun 7, 2026
b013c88
fix: create discovery DTOs after server binds port
bethropolis Jun 7, 2026
f6ed6a5
fix(scratch): add LOCALSEND_AUTO_ACCEPT=true env var
bethropolis Jun 7, 2026
2a8a00b
fix(scratch): add XDG_CACHE_HOME so peer cache is writable
bethropolis Jun 7, 2026
9144f42
fix(security): PIN constant-time compare, server timeouts, private mo…
bethropolis Jun 20, 2026
64be12d
fix(logic): config set parsing, scan/discover timeouts, share port or…
bethropolis Jun 20, 2026
ad832f9
fix(concurrency): Device mutex for LastSeen/Available, ReceiveService…
bethropolis Jun 20, 2026
413bcd1
refactor(code quality): SortFunc, mutex-safe anonymize, saveTextAsFil…
bethropolis Jun 20, 2026
8bfafe2
fix(security): bypass DiscoverDevices private mode in cmd/send.go
bethropolis Jun 20, 2026
138952b
fix(help): correct discover --timeout default from 5 to 10
bethropolis Jun 20, 2026
97a0c4a
docs(help): add completion cmd, missing flags for serve/share/send, -…
bethropolis Jun 20, 2026
7aaf291
feat(cli): add --no-color flag, respect NO_COLOR env in logging Init
bethropolis Jun 20, 2026
5f13a84
feat(freebsd): enable clipboard support via clipboard_unix.go (linux|…
bethropolis Jun 20, 2026
3599891
feat(freebsd): add rc.d init script for localgo service
bethropolis Jun 20, 2026
47f61e2
fix: check xdg-open availability before opening download directory
bethropolis Jun 20, 2026
32a628d
feat(network): add gateway-based LAN subnet prioritization for scan a…
bethropolis Jun 20, 2026
8d35b6c
fix(discovery): send multicast response via multicast addr instead of…
bethropolis Jun 21, 2026
de481d0
fix(scan): filter local machine out of HTTP scan results
bethropolis Jun 21, 2026
cf37d46
fix(discover): fall back to HTTP subnet scan when multicast returns n…
bethropolis Jun 21, 2026
2f47675
fix(send): remove interactive clipboard prompt, filepicker is the def…
bethropolis Jun 21, 2026
c5b3a8d
fix(protocol): change ProtocolVersion from '2.1' to '2.0' to match spec
bethropolis Jun 21, 2026
01be941
fix(protocol): select correct fingerprint in HTTP mode (random string…
bethropolis Jun 21, 2026
a08245a
fix(security): use constant-time PIN comparison in DownloadHandler
bethropolis Jun 21, 2026
beb3629
fix(discovery): use POST /register instead of deprecated GET /info fo…
bethropolis Jun 21, 2026
261b904
fix(protocol): implement session blocking, return 409 for concurrent …
bethropolis Jun 21, 2026
fd65357
fix(protocol): validate ?sessionId in PrepareDownloadHandler
bethropolis Jun 21, 2026
0c4ea80
fix(protocol): use valid deviceType 'headless' in private mode, retur…
bethropolis Jun 21, 2026
4825c46
refactor(dto): remove spec-noncompliant extra fields from DTO structs
bethropolis Jun 21, 2026
52f39a8
fix(protocol): add port/protocol to prepare-upload info block
bethropolis Jun 21, 2026
221bfda
fix(security): verify TLS certificate fingerprint during file transfer
bethropolis Jun 21, 2026
d1af3c1
fix(protocol): force HTTP for share command (browser download API)
bethropolis Jun 21, 2026
68d35a9
fix: improve TLS error diag, always prompt device picker, silence usa…
bethropolis Jun 21, 2026
0f2c8ce
chore: final state after protocol audit fixes
bethropolis Jun 21, 2026
c0edea8
fix: case-insensitive TLS fingerprint comparison
bethropolis Jun 21, 2026
3d9c9bb
fix: bug fix
bethropolis Jun 21, 2026
53ffe3d
feat(share): add TUI file picker, extract shared picker to pkg/cli
bethropolis Jun 22, 2026
51de7a2
fix: remove duplicate -p shorthand in devices command
bethropolis Jun 22, 2026
16da01b
fix: stability fixes and enhancements
bethropolis Jun 23, 2026
b43e423
feat: add GitHub Pages docs site and online installer
bethropolis Jun 23, 2026
0348ddb
chore: stable release prep — bugs, atomic writes, safety
bethropolis Jun 23, 2026
814b5fd
refactor: split 6 large files into 19 single-responsibility units
bethropolis Jun 23, 2026
582c35d
docs: add v0.6.0 changelog entry
bethropolis Jun 23, 2026
a2d7f13
Merge remote-tracking branch 'origin/main' into dev
bethropolis Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -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
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>` and `localgo scan --range <CIDR>` 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 <address>` flag for direct IP-based send (skips discovery)
- `scan --range <CIDR>` 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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions cmd/localgo/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"strconv"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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])
}
Expand Down
17 changes: 7 additions & 10 deletions cmd/localgo/cmd/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -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"))
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 49 additions & 12 deletions cmd/localgo/cmd/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -81,18 +78,58 @@ 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 {
zap.S().Warnf("Discovery completed with warnings: %v", discErr)
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.")
Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion cmd/localgo/cmd/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading