diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..3515733 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "commitMessagePrefix": "[dependencies]", + "branchName": "main", + "extends": [ + "config:base", + ":disableRateLimiting" + ], + "packageRules": [ + { + "matchManagers": [ + "github-actions" + ], + "groupName": "github-actions" + }, + { + "matchManagers": [ + "gomod" + ], + "groupName": "gomod" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..40cdafb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,37 @@ +name: lint + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/lint.yml' + pull_request: + branches: + - main + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + - name: Cache go module + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + key: go-${{ hashFiles('**/go.sum') }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e3adcc5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,94 @@ +name: test + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/test.yml' + pull_request: + branches: + - main + +jobs: + build: + name: Linux Debug build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + - name: Build + run: | + make test + build_go120: + name: Linux Debug build (Go 1.20) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ~1.20 + continue-on-error: true + - name: Build + run: | + make test + build_go121: + name: Linux Debug build (Go 1.21) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ~1.21 + continue-on-error: true + - name: Build + run: | + make test + build_windows: + name: Windows Debug build + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + continue-on-error: true + - name: Build + run: | + make test + build_darwin: + name: macOS Debug build + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + continue-on-error: true + - name: Build + run: | + make test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1298ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea/ +/vendor/ +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d3147cf --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,18 @@ +linters: + disable-all: true + enable: + - gofumpt + - govet + - gci + - staticcheck + +run: + go: 1.22 + +linters-settings: + gci: + custom-order: true + sections: + - standard + - prefix(github.com/sagernet/) + - default \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e3e29e --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..91ac97b --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +fmt: + @gofumpt -l -w . + @gofmt -s -w . + @gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" . + +fmt_install: + go install -v mvdan.cc/gofumpt@latest + go install -v github.com/daixiang0/gci@latest + +lint: + GOOS=linux golangci-lint run ./... + GOOS=android golangci-lint run ./... + GOOS=windows golangci-lint run ./... + GOOS=darwin golangci-lint run ./... + GOOS=freebsd golangci-lint run ./... + +lint_install: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +test: + go test $(shell go list ./... | grep -v /internal/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..246205a --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# fswatch + +![Test](https://github.com/sagernet/fswatch/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/sagernet/fswatch/actions/workflows/lint.yml/badge.svg) +[![Go Reference](https://pkg.go.dev/badge/github.com/sagernet/fswatch.svg)](https://pkg.go.dev/github.com/sagernet/fswatch) + +fswatch is a simple [fsnotify] wrapper to watch file updates correctly. + +[fsnotify]: https://github.com/fsnotify/fsnotify + +Install +--- + +```bash +go get github.com/sagernet/fswatch +``` + +Example +--- + +```go +package main + +import ( + "log" + + "github.com/sagernet/fswatch" +) + +func main() { + var watchPath []string + watchPath = append(watchPath, "/tmp/my_file") + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: watchPath, + Callback: func(path string) { + log.Println("file updated: ", path) + }, + }) + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + // Block main goroutine forever. + <-make(chan struct{}) +} + +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c516a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/sagernet/fswatch + +go 1.20 + +require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/sagernet/sing v0.4.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0803d1b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sagernet/sing v0.4.1 h1:zVlpE+7k7AFoC2pv6ReqLf0PIHjihL/jsBl5k05PQFk= +github.com/sagernet/sing v0.4.1/go.mod h1:ieZHA/+Y9YZfXs2I3WtuwgyCZ6GPsIR7HdKb1SdEnls= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..fde9e20 --- /dev/null +++ b/watcher.go @@ -0,0 +1,134 @@ +package fswatch + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/fsnotify/fsnotify" +) + +const DefaultWaitTimeout = 100 * time.Millisecond + +// Watcher is a fsnotify watcher to watch files correctly +type Watcher struct { + watchDirect bool + watchTarget []string + watchPath []string + callback func(path string) + waitTimeout time.Duration + logger logger.Logger + watcher *fsnotify.Watcher +} + +type Options struct { + // Path is the list of files or directories to watch + Path []string + + // Direct is the flag to watch the file directly if file will never be removed + Direct bool + + // Callback is the function to call when a file is updated + Callback func(path string) + + // WaitTimeout is the time to wait write events before calling the callback + // DefaultWaitTimeout is used by default + WaitTimeout time.Duration + + // Logger is the logger to log errors + // optional + Logger logger.Logger +} + +func NewWatcher(options Options) (*Watcher, error) { + if len(options.Path) == 0 || options.Callback == nil { + return nil, os.ErrInvalid + } + waitTimeout := options.WaitTimeout + if waitTimeout == 0 { + waitTimeout = DefaultWaitTimeout + } + var watchTarget []string + if options.Direct { + watchTarget = options.Path + } else { + watchTarget = common.Uniq(common.Map(options.Path, filepath.Dir)) + // TODO: update sing to use common.Remove when it's stable + watchTarget = common.Filter(watchTarget, func(it string) bool { + return !common.Any(watchTarget, func(path string) bool { + return len(path) > len(it) && strings.HasPrefix(path, it) + }) + }) + } + return &Watcher{ + watchDirect: options.Direct, + watchTarget: watchTarget, + watchPath: options.Path, + callback: options.Callback, + waitTimeout: waitTimeout, + logger: options.Logger, + }, nil +} + +func (w *Watcher) Start() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return E.Cause(err, "fswatch: create fsnotify watcher") + } + for _, target := range w.watchTarget { + err = watcher.Add(target) + if err != nil { + return E.Cause(err, "fswatch: watch ", target) + } + } + w.watcher = watcher + go w.loopUpdate() + return nil +} + +func (w *Watcher) Close() error { + return w.watcher.Close() +} + +func (w *Watcher) loopUpdate() { + var timerAccess sync.Mutex + timerMap := make(map[string]*time.Timer) + for { + select { + case event, loaded := <-w.watcher.Events: + if !loaded { + return + } + if common.Contains(w.watchTarget, event.Name) && (event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove)) { + w.logger.Error("fswatch: watcher removed: ", event.Name) + } else if common.Contains(w.watchPath, event.Name) && (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) { + timerAccess.Lock() + timer := timerMap[event.Name] + if timer != nil { + timer.Reset(w.waitTimeout) + } else { + timerMap[event.Name] = time.AfterFunc(w.waitTimeout, func() { + w.callback(event.Name) + timerAccess.Lock() + delete(timerMap, event.Name) + timerAccess.Unlock() + }) + } + timerAccess.Unlock() + } + case err, loaded := <-w.watcher.Errors: + if !loaded { + return + } + if w.logger != nil { + w.logger.Error("fswatch: ", err) + } + } + } +} diff --git a/watcher_test.go b/watcher_test.go new file mode 100644 index 0000000..a9650ce --- /dev/null +++ b/watcher_test.go @@ -0,0 +1,62 @@ +package fswatch_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/sagernet/fswatch" + + "github.com/stretchr/testify/require" +) + +func TestFileWatcher(t *testing.T) { + t.Parallel() + tempDir, err := os.MkdirTemp("", "sing-box-file-watcher-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + watchPath := filepath.Join(tempDir, "test") + fileContent := "Hello world!" + done := make(chan struct{}) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{watchPath}, + Callback: func(path string) { + newContent, err := os.ReadFile(watchPath) + require.NoError(t, err) + require.Equal(t, fileContent, string(newContent)) + close(done) + }, + }) + require.NoError(t, err) + defer watcher.Close() + require.NoError(t, watcher.Start()) + file, err := os.Create(watchPath) + require.NoError(t, err) + _, err = file.WriteString(fileContent) + require.NoError(t, err) + require.NoError(t, file.Close()) + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("watch timeout") + } + done = make(chan struct{}) + require.NoError(t, os.Remove(watchPath)) + tempPath := filepath.Join(tempDir, "temp") + require.NoError(t, os.WriteFile(tempPath, []byte(fileContent), 0o644)) + require.NoError(t, os.Rename(tempPath, watchPath)) + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("watch timeout") + } + done = make(chan struct{}) + require.NoError(t, os.Remove(watchPath)) + require.NoError(t, os.WriteFile(tempPath, []byte(fileContent), 0o644)) + select { + case <-done: + t.Fatal("invalid event") + case <-time.After(1 * time.Second): + } +}