Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,10 @@ jobs:
args: build --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload binaries as artifacts
uses: actions/upload-artifact@v4
with:
name: fwatch-binaries-${{ github.sha }}
path: dist/fwatch_*
retention-days: 30
6 changes: 5 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ builds:
goos:
- linux
- darwin
- windows
ldflags:
- -s -w -X main.version={{.Version}}

archives:
- formats:
- format_overrides:
- goos: windows
format: zip
formats:
- tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
Expand Down
78 changes: 74 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,38 @@ paru -S fwatch-bin

Download the latest release for your platform from the [releases page](https://github.com/polarn/fwatch/releases).

#### Linux/macOS
```bash
# Example for Linux x86_64
wget https://github.com/polarn/fwatch/releases/latest/download/fwatch_Linux_x86_64.tar.gz
tar -xzf fwatch_Linux_x86_64.tar.gz
sudo mv fwatch /usr/local/bin/
```

#### Windows
1. Download `fwatch_Windows_x86_64.zip` from the [releases page](https://github.com/polarn/fwatch/releases)
2. Extract the ZIP file
3. Move `fwatch.exe` to a directory in your PATH, or run it directly

### From Source

```bash
# Build the binary
go build -o fwatch

# Optional: Install to your PATH
# Optional: Install to your PATH (Linux/macOS)
sudo cp fwatch /usr/local/bin/

# Windows: Move fwatch.exe to a directory in your PATH
```

## Configuration

By default, fwatch looks for its configuration file at `~/.config/fwatch/config.yaml` (or `$XDG_CONFIG_HOME/fwatch/config.yaml` if set).
By default, fwatch looks for its configuration file at:
- **Linux/macOS**: `~/.config/fwatch/config.yaml` (or `$XDG_CONFIG_HOME/fwatch/config.yaml` if set)
- **Windows**: `%APPDATA%\fwatch\config.yaml` (typically `C:\Users\YourName\AppData\Roaming\fwatch\config.yaml`)

### Linux/macOS Setup

1. Create the config directory and copy the example configuration:
```bash
Expand All @@ -67,19 +79,56 @@ rules:
destination: "/home/your_username/debian"
```

### Windows Setup

1. Create the config directory:
```powershell
New-Item -ItemType Directory -Force -Path "$env:APPDATA\fwatch"
```

2. Create `config.yaml` in `%APPDATA%\fwatch\` with content like:

```yaml
watch_dir: "C:\\Users\\YourName\\Downloads"
create_dirs: true

rules:
- extensions: [".zip"]
destination: "C:\\Users\\YourName\\Archives"
- extensions: [".exe", ".msi"]
destination: "C:\\Users\\YourName\\Installers"
```

**Note**: On Windows, use double backslashes (`\\`) or forward slashes (`/`) in paths.

## Usage

Run with default config location (`~/.config/fwatch/config.yaml`):
Run with default config location:
```bash
# Linux/macOS
./fwatch

# Windows (PowerShell or CMD)
fwatch.exe
```

Use a custom config file:
```bash
# Linux/macOS
./fwatch -config /path/to/config.yaml

# Windows
fwatch.exe -config C:\path\to\config.yaml
```

## Run as Systemd Service
Show version:
```bash
fwatch -version
```

## Run as a Service

### Linux (Systemd)

An example systemd service file (`fwatch.service`) is included. To install it:
```bash
Expand All @@ -100,6 +149,27 @@ systemctl --user status fwatch.service
journalctl --user -u fwatch.service -f
```

### Windows (Task Scheduler)

To run fwatch automatically at startup on Windows:

1. Open Task Scheduler (`taskschd.msc`)
2. Create a new Basic Task:
- **Name**: fwatch
- **Trigger**: At log on
- **Action**: Start a program
- **Program**: `C:\path\to\fwatch.exe`
- **Start in**: `C:\path\to\` (directory containing fwatch.exe)
3. Configure task to run whether user is logged in or not (optional)

Alternatively, use PowerShell to create the scheduled task:
```powershell
$action = New-ScheduledTaskAction -Execute "C:\path\to\fwatch.exe"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName "fwatch" -Action $action -Trigger $trigger -Principal $principal
```

**Note:** Make sure you've already configured fwatch (see [Configuration](#configuration) section above) before starting the service.

## Configuration Options
Expand Down
34 changes: 28 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,43 @@
# Copy this to config.yaml and customize

# Directory to watch for new files
# Linux/macOS example:
watch_dir: "/home/your_username/Downloads"
# Windows example (use double backslashes or forward slashes):
# watch_dir: "C:\\Users\\YourName\\Downloads"
# or
# watch_dir: "C:/Users/YourName/Downloads"

# File type routing rules
# Extensions should include the dot (e.g., ".zip", ".pdf")
rules:
- extensions: [".zip"]
destination: "/home/user/zip-archives"
- extensions: [".deb"]
destination: "/home/user/debian"
# Archives
- extensions: [".zip", ".tar", ".gz", ".7z", ".rar"]
destination: "/home/your_username/Archives"
# Windows: "C:\\Users\\YourName\\Archives"

# Linux packages
- extensions: [".deb", ".rpm", ".AppImage"]
destination: "/home/your_username/Packages"

# Windows installers (comment out on Linux/macOS)
# - extensions: [".exe", ".msi"]
# destination: "C:\\Users\\YourName\\Installers"

# Documents
- extensions: [".pdf", ".epub", ".mobi"]
destination: "/home/your_username/Documents/Books"
- extensions: [".jpg", ".jpeg", ".png", ".gif"]
# Windows: "C:\\Users\\YourName\\Documents\\Books"

# Images
- extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"]
destination: "/home/your_username/Pictures"
- extensions: [".mp3", ".flac", ".wav"]
# Windows: "C:\\Users\\YourName\\Pictures"

# Audio
- extensions: [".mp3", ".flac", ".wav", ".m4a"]
destination: "/home/your_username/Music"
# Windows: "C:\\Users\\YourName\\Music"

# Optional: Create destination directories if they don't exist
create_dirs: true
138 changes: 123 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"strings"
"time"

Expand All @@ -31,16 +32,24 @@ type Rule struct {
}

// getDefaultConfigPath returns the default configuration file path
// using XDG_CONFIG_HOME or falling back to ~/.config
// using platform-specific conventions
func getDefaultConfigPath() string {
// Check XDG_CONFIG_HOME first
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return filepath.Join(configHome, "fwatch", "config.yaml")
}

// Fall back to ~/.config
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".config", "fwatch", "config.yaml")
if runtime.GOOS == "windows" {
// Windows: use APPDATA or USERPROFILE
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "fwatch", "config.yaml")
}
if userProfile := os.Getenv("USERPROFILE"); userProfile != "" {
return filepath.Join(userProfile, "fwatch", "config.yaml")
}
} else {
// Unix-like: use XDG_CONFIG_HOME or ~/.config
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return filepath.Join(configHome, "fwatch", "config.yaml")
}
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".config", "fwatch", "config.yaml")
}
}

// Last resort: current directory
Expand Down Expand Up @@ -126,8 +135,24 @@ func watchDirectory(config *Config) error {

// Only process create and write events
if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write {
// Skip partial/temporary download files early
fileName := filepath.Base(event.Name)
if strings.HasSuffix(strings.ToLower(fileName), ".part") ||
strings.HasSuffix(strings.ToLower(fileName), ".tmp") ||
strings.HasSuffix(strings.ToLower(fileName), ".crdownload") || // Chrome
strings.HasSuffix(strings.ToLower(fileName), ".download") { // Safari
continue
}

// Small delay to ensure file is fully written
time.Sleep(100 * time.Millisecond)

// Wait for file to be ready (not locked by another process)
if !waitForFile(event.Name, 5*time.Second) {
log.Printf("File is locked or unavailable, skipping: %s", event.Name)
continue
}

processFile(event.Name, extMap)
}

Expand All @@ -151,6 +176,41 @@ func buildExtensionMap(rules []Rule) map[string]string {
return extMap
}

// waitForFile waits for a file to be ready (not locked) by attempting to open it
func waitForFile(filePath string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
attempts++
// Try to open the file exclusively to check if it's locked
file, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err == nil {
file.Close()
// Log if we had to wait
if attempts > 1 {
log.Printf("File ready after %d attempts: %s", attempts, filepath.Base(filePath))
}
return true
}

// If file doesn't exist, it might have been moved already
if os.IsNotExist(err) {
return false
}

// Check if it's a permission/locking error
if strings.Contains(err.Error(), "being used by another process") ||
strings.Contains(err.Error(), "permission denied") {
time.Sleep(200 * time.Millisecond)
continue
}

// Other errors, give up
return false
}
return false
}

func processFile(filePath string, extMap map[string]string) {
// Skip if file doesn't exist (might have been moved already)
info, err := os.Stat(filePath)
Expand Down Expand Up @@ -208,16 +268,32 @@ func moveFile(src, dst string) error {
return nil
}

// Check if it's a cross-device link error
// If so, fall back to copy + delete
if strings.Contains(err.Error(), "invalid cross-device link") {
// Check if it's a cross-device/cross-filesystem error
// Linux: "invalid cross-device link"
// Windows: ERROR_NOT_SAME_DEVICE or "The system cannot move the file to a different disk drive"
if isCrossDeviceError(err) {
return copyAndDelete(src, dst)
}

// For other errors, return them
return err
}

// isCrossDeviceError checks if the error indicates a cross-device/cross-filesystem operation
func isCrossDeviceError(err error) bool {
errMsg := err.Error()
// Linux error message
if strings.Contains(errMsg, "invalid cross-device link") {
return true
}
// Windows error messages
if strings.Contains(errMsg, "not the same device") ||
strings.Contains(errMsg, "different disk drive") {
return true
}
return false
}

// copyAndDelete copies a file and then deletes the source
func copyAndDelete(src, dst string) error {
// Open source file
Expand Down Expand Up @@ -250,10 +326,42 @@ func copyAndDelete(src, dst string) error {
return fmt.Errorf("syncing destination file: %w", err)
}

// Remove the source file
if err := os.Remove(src); err != nil {
// Close files before attempting delete
srcFile.Close()
dstFile.Close()

// Remove the source file with retries (for locked files on Windows)
return removeFileWithRetry(src, 3, 500*time.Millisecond)
}

// removeFileWithRetry attempts to remove a file with retries for locked files
func removeFileWithRetry(path string, maxRetries int, delay time.Duration) error {
var lastErr error
for i := 0; i < maxRetries; i++ {
err := os.Remove(path)
if err == nil {
return nil
}

lastErr = err

// If file doesn't exist, consider it success
if os.IsNotExist(err) {
return nil
}

// Check if it's a lock/permission error
if strings.Contains(err.Error(), "being used by another process") ||
strings.Contains(err.Error(), "permission denied") {
if i < maxRetries-1 {
time.Sleep(delay)
continue
}
}

// Other errors, return immediately
return fmt.Errorf("removing source file: %w", err)
}

return nil
return fmt.Errorf("removing source file after %d retries: %w", maxRetries, lastErr)
}