From 2babcc8a21288015f056351db081d1247c47f609 Mon Sep 17 00:00:00 2001 From: Marcus Johansson Date: Sun, 23 Nov 2025 21:29:08 +0100 Subject: [PATCH 1/4] Add windows support --- .goreleaser.yml | 6 +++- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++--- config.example.yaml | 34 ++++++++++++++++---- main.go | 49 +++++++++++++++++++++------- 4 files changed, 144 insertions(+), 23 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 59e9940..2314dbe 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -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: >- diff --git a/README.md b/README.md index 2a5a06e..d540b0c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ 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 @@ -34,19 +35,30 @@ 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 @@ -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 @@ -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 diff --git a/config.example.yaml b/config.example.yaml index 52b30d1..e3bca74 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/main.go b/main.go index 918547e..81d7748 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "time" @@ -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 @@ -208,9 +217,10 @@ 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) } @@ -218,6 +228,21 @@ func moveFile(src, dst string) error { 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 From bcb63f6d9016d4b5bee7a909f26746bbd54b8301 Mon Sep 17 00:00:00 2001 From: Marcus Johansson Date: Sun, 23 Nov 2025 21:38:17 +0100 Subject: [PATCH 2/4] upload artifacts --- .github/workflows/test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87800f8..46ef405 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 From fe1e00cf30a05a524b8b88cfdea78c7506abdef0 Mon Sep 17 00:00:00 2001 From: Marcus Johansson Date: Sun, 23 Nov 2025 21:55:48 +0100 Subject: [PATCH 3/4] support for files being used by other process --- main.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 81d7748..bd4abe8 100644 --- a/main.go +++ b/main.go @@ -137,6 +137,13 @@ func watchDirectory(config *Config) error { if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write { // 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) } @@ -160,6 +167,35 @@ 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) + for time.Now().Before(deadline) { + // 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() + 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) @@ -275,10 +311,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) } From a12be45fbfe386466511c394585177a74ac75274 Mon Sep 17 00:00:00 2001 From: Marcus Johansson Date: Sun, 23 Nov 2025 22:12:47 +0100 Subject: [PATCH 4/4] fix --- main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/main.go b/main.go index bd4abe8..57266b9 100644 --- a/main.go +++ b/main.go @@ -135,6 +135,15 @@ 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) @@ -170,11 +179,17 @@ func buildExtensionMap(rules []Rule) map[string]string { // 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 }