diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..81c125e --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,31 @@ +name: goreleaser + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v4 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..d9ac7bf --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,12 @@ +builds: + - id: "sway-yast" + main: "./main.go" + binary: "sway-yast" + goos: + - linux + goarch: + - 386 + - amd64 + - arm64 + goarm: + - 7 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..53b9d6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +## v0.2.0 +- mouse follows focus +- pick space +- pick win +- run path +- live config changes + +## v0.1.0 +- initial release \ No newline at end of file diff --git a/README.md b/README.md index 8e8881f..d452fb4 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,84 @@ # sway-yast -**Y**et **A**nother **S**way **T**ab is a single-binary alt+tab clone (Most Recently Used) window switcher for [Sway WM](https://github.com/swaywm/sway). - -![Dark mode](./assets/dark.png) -![Light mode](./assets/light.png) - -## Features - -1. Daemon (IPC & RPC) architecture, filesystem-free -2. Uses `fzf`, works in the terminal -3. Renders a popup using `foot` (optional) -4. Dark mode support (optional)
- Checks `gsettings get org.gnome.desktop.interface color-scheme` -5. Shows workspaces and outputs (especially headless) -6. 1-hand compatible keystrokes - -## Usage - -1. Install
- `go install github.com/pancsta/sway-yast@latest` +Sway **Y**et **A**nother **S**way **T**ab is a text-based window switcher which mimics alt+tab behavior (Most Recently Used order) +for [Sway WM](https://github.com/swaywm/sway). + + +| Dark Mode | Light Mode | +|------------------------------------------|------------------------------------------| +| ![Dark mode](./assets/dark.png) | ![Light mode](./assets/light.png) | + +```text +$ sway-yast --help +Usage: + sway-yast [flags] + sway-yast [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + config Change the config of a running daemon process + daemon Start tracking focus in sway + fzf Run fzf with a list of windows + fzf-path Run fzf with a list of executable files from PATH + fzf-pick-space Run fzf with a list of workspaces to pick + fzf-pick-win Run fzf with a list of windows to pick + help Help about any command + mru-list Print a list of MRU window IDs + path Show the +x files from PATH using foot + pick-space Show the workspace picker using foot + pick-win Show the window picker using foot + switcher Show the switcher window using foot + +Flags: + -h, --help help for sway-yast + --version Print version and exit + +Use "sway-yast [command] --help" for more information about a command. +``` + +```text +$ sway-yast daemon --help +Start tracking focus in sway + +Usage: + sway-yast daemon [flags] + +Flags: + --autoconfig Automatic configuration of layout (default true) + --default-keybindings Add default keybindings + -h, --help help for daemon + --mouse-follows-focus Calls 'input ... map_to_output OUTPUT' on each focus + +``` + +## features + +- daemon (IPC & RPC) architecture, filesystem-free +- uses `fzf` so it works in the terminal +- renders a floating popup using `foot` (optional) +- dark mode support (optional)
+ checks `gsettings get org.gnome.desktop.interface color-scheme` +- 1-hand keystrokes +- [mouse follows focus](#mouse-follows-focus) mode (optional) +- additional features (popups) + - move a workspace to the current output + - move a window to the current workspace + - run anything in your `PATH` +- general MRU watcher via `mru-list` + +## usage + +1. Install using one of
+ - Binary from [the releases page](https://github.com/pancsta/sway-yast/releases/latest) + - `go install github.com/pancsta/sway-yast@latest` + - `git clone && go mod tidy && go build` 2. Start the daemon
- `sway-yast daemon` -3. Add a binding (optional)
- `swaymsg bindsym alt+tab exec sway-yast switcher` -4. Run in the terminal (optional)
+ `sway-yast daemon --default-keystrokes` +3. Use directly in the terminal (optional)
`sway-yast fzf` -5. Press `alt+tab` +4. Press `alt+tab` -## Key bindings +## keystrokes Normal mode: @@ -57,11 +108,79 @@ Example - switch to Krusader by name: - `k`, `r`, `u` - `enter` -## Configuration +### default keystrokes + +Various ways to get the default keybindings. + +```bash +$ sway-yast daemon --default-keybindings +``` + +```bash +# shell +swaymsg bindsym alt+tab exec sway-yast switcher +swaymsg bindsym mod4+o exec sway-yast pick-space +swaymsg bindsym mod4+p exec sway-yast pick-win +swaymsg bindsym mod4+d exec sway-yast path +``` + +```text +# config +bindsym alt+tab exec sway-yast switcher +bindsym $mod+o exec sway-yast pick-space +bindsym $mod+p exec sway-yast pick-win +bindsym $mod+d exec sway-yast path +``` + +## mouse follows focus + +```bash +$ sway-yast daemon --mouse-follows-focus +``` + +Using `input map_to_output`, traps the relative cursor inside the currently focused output. Changing focus moves the cursor between outputs (thus the name). Useful for VNC screens on separate machines. When combined with [waycorner](https://github.com/AndreasBackx/waycorner), it creates a synergy-like effect. + +### waycorner config example + +```toml +# HEADLESS-1 (right screen) +[pro5-left] +enter_command = [ "sway-pointer-output", "2" ] +locations = ["left"] +[pro5-left.output] +description = ".*output 1.*" + +# HEADLESS-2 (left screen) +[mini6-right] +enter_command = [ "sway-pointer-output", "1" ] +locations = ["right"] +[mini6-right.output] +description = ".*output 2.*" +``` + +## configuration + +See the [top config section in main.go](main.go), modify and `go build`. + +## additional features (popups) + +### move a workspace to the current output + +![move workspace](assets/move-workspace.png) + +### move a window to the current workspace + +![move window](assets/move-window.png) + +### run anything in your `PATH` + +![path runner](assets/path-runner.png) + +## troubleshooting -See the [config section in main.go](main.go), modify and `go build`. +`env YAST_LOG=1 sway-yast` -## Kudos +## kudos - [applist.py](https://github.com/davxy/dotfiles/blob/main/_old/sway/applist.py) - [sway-fzfify](https://github.com/ldelossa/sway-fzfify) diff --git a/assets/dark.png b/assets/dark.png index 719f77a..5cfb27c 100644 Binary files a/assets/dark.png and b/assets/dark.png differ diff --git a/assets/grafana-dashboard-dark.png b/assets/grafana-dashboard-dark.png new file mode 100644 index 0000000..4b3056c Binary files /dev/null and b/assets/grafana-dashboard-dark.png differ diff --git a/assets/grafana-dashboard-light.png b/assets/grafana-dashboard-light.png new file mode 100644 index 0000000..a1d2e4f Binary files /dev/null and b/assets/grafana-dashboard-light.png differ diff --git a/assets/light.png b/assets/light.png index ac1074d..17213de 100644 Binary files a/assets/light.png and b/assets/light.png differ diff --git a/assets/move-window.png b/assets/move-window.png new file mode 100644 index 0000000..2a7366e Binary files /dev/null and b/assets/move-window.png differ diff --git a/assets/move-workspace.png b/assets/move-workspace.png new file mode 100644 index 0000000..38b66d4 Binary files /dev/null and b/assets/move-workspace.png differ diff --git a/assets/path-runner.png b/assets/path-runner.png new file mode 100644 index 0000000..e76c818 Binary files /dev/null and b/assets/path-runner.png differ diff --git a/cmds.go b/cmds.go new file mode 100644 index 0000000..d99eedf --- /dev/null +++ b/cmds.go @@ -0,0 +1,282 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "runtime/debug" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// TODO put these in a config and make it customizable +const ( + shellFzf = ` + fzf \ + --prompt 'Switcher: ' \ + --bind "load:pos(2)" \ + --bind "change:pos(1)" \ + --layout=reverse --info=hidden \ + --bind=space:accept,tab:offset-down,btab:offset-up +` + shellFzfPickWin = ` + fzf \ + --prompt 'Move which window to this workspace?: ' \ + --layout=reverse --info=hidden \ + --bind=space:accept,tab:offset-down,btab:offset-up +` + shellFzfPickSpace = ` + fzf \ + --prompt 'Move which workspace to this output?: ' \ + --layout=reverse --info=hidden \ + --bind=space:accept,tab:offset-down,btab:offset-up +` + shellFzfPath = ` + fzf \ + --prompt 'Run: ' \ + --layout=reverse --info=hidden \ + --bind=space:accept,tab:offset-down,btab:offset-up +` + // junegunn/seoul256.vim (light) + shellFzfLight = ` \ + --color=bg+:#D9D9D9,bg:#E1E1E1,border:#C8C8C8,spinner:#719899,hl:#719872,fg:#616161,header:#719872,info:#727100,pointer:#E12672,marker:#E17899,fg+:#616161,preview-bg:#D9D9D9,prompt:#0099BD,hl+:#719899 +` + shellSwitcher = ` + foot --title "sway-yast" sway-yast fzf +` + shellPickWin = ` + foot --title "sway-yast" sway-yast fzf-pick-win +` + shellPickSpace = ` + foot --title "sway-yast" sway-yast fzf-pick-space +` + shellPath = ` + foot --title "sway-yast" sway-yast fzf-path +` +) + +///// TERM WRAPPER COMMANDS ///// + +// TODO open on all visible outputs, as screen session clients +// use https://github.com/rajveermalviya/go-wayland +func cmdSwitcher(_ *cobra.Command, _ []string) { + if !shouldOpen() { + log.Fatal("fzf error: already open") + } + _, err := run(shellSwitcher) + if err != nil { + log.Fatalf("foot error: %s", err) + } +} + +func cmdPickWin(_ *cobra.Command, _ []string) { + if !shouldOpen() { + log.Fatal("fzf error: already open") + } + _, err := run(shellPickWin) + if err != nil { + log.Fatal("foot error: " + err.Error()) + } +} + +func cmdPickSpace(_ *cobra.Command, _ []string) { + if !shouldOpen() { + log.Fatal("fzf error: already open") + } + _, err := run(shellPickSpace) + if err != nil { + log.Fatalf("foot error: %s", err) + } +} + +func cmdPath(_ *cobra.Command, _ []string) { + if !shouldOpen() { + log.Fatal("fzf error: already open") + } + _, err := run(shellPath) + if err != nil { + log.Fatalf("foot error: %s", err) + } +} + +func cmdConfig(cmd *cobra.Command, _ []string) { + mouseFollow, _ := cmd.Flags().GetBool("mouse-follows-focus") + result, err := rpcCall("Daemon.RemoteSetConfig", RPCArgs{ + MouseFollowsFocus: mouseFollow, + }) + if err != nil { + log.Fatalf("rpc error: %s", err) + } + if result != "" { + log.Fatal("config error") + } + fmt.Println("Config updated") + // TODO print the current config +} + +func cmdRoot(cmd *cobra.Command, _ []string) { + version, _ := cmd.Flags().GetBool("version") + if version { + build, ok := debug.ReadBuildInfo() + if !ok { + panic("No build info available") + } + fmt.Println(build.Main.Version) + os.Exit(0) + } else { + fmt.Println("Yet Another Sway Tab\n\nUsage:\n$ sway-yast daemon\n$ sway-yast --help") + } +} + +///// FZF COMMANDS ///// + +func cmdFzf(_ *cobra.Command, _ []string) { + // req the daemon + input, err := rpcCall("Daemon.RemoteFZFList", RPCArgs{}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } + + // run fzf + result, err := fzf(shellFzf, &input) + if err != nil { + log.Fatalf("fzf error: %s", err) + } + + // match the window's ID at the end of the line + winID, err := matchWinID(result) + if err != nil { + log.Fatalf("error: %s", err) + } + + // focus the window + _, err = rpcCall("Daemon.RemoteFocusWinID", RPCArgs{WinID: winID}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } +} + +func cmdFzfPickWin(_ *cobra.Command, _ []string) { + // req the daemon + input, err := rpcCall("Daemon.RemoteFZFListPickWin", RPCArgs{}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } + // run fzf + result, err := fzf(shellFzfPickWin, &input) + if err != nil { + log.Fatalf("fzf error: %s", err) + } + + // match the window's ID at the end of the line + winID, err := matchWinID(result) + if err != nil { + log.Fatalf("error: %s", err) + } + + // move the window to the current workspace + _, err = rpcCall("Daemon.RemoteMoveWinToSpace", RPCArgs{WinID: winID}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } +} + +func cmdFzfPickSpace(_ *cobra.Command, _ []string) { + // req the daemon + list, err := rpcCall("Daemon.RemoteFZFListPickSpace", RPCArgs{}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } + + // run fzf to pick the workspace + result, err := fzf(shellFzfPickSpace, &list) + if err != nil { + log.Fatalf("fzf error: %s", err) + } + + // move the workspace to the current output + _, err = rpcCall("Daemon.RemoteMoveSpaceToOutput", RPCArgs{ + Workspace: strings.Trim(result, " \n"), + }) + if err != nil { + log.Fatalf("rpc error: %s", err) + } +} + +func cmdFzfPath(_ *cobra.Command, _ []string) { + // req the daemon + list, err := rpcCall("Daemon.RemoteGetPathFiles", RPCArgs{}) + if err != nil { + log.Fatalf("rpc error: %s", err) + } + + // run fzf + result, err := fzf(shellFzfPath, &list) + if err != nil { + log.Fatalf("fzf error: %s", err) + } + + // return the picked exe + log.Printf("path: %s", result) + _, err = rpcCall("Daemon.RemoteExec", RPCArgs{ + ExePath: result, + }) + if err != nil { + log.Fatalf("error: cant run %s", result) + } +} + +///// HELPERS ///// + +func matchWinID(result string) (int, error) { + re := regexp.MustCompile(`\((\d+)\)\s*$`) + match := re.FindStringSubmatch(result) + if len(match) == 0 { + return 0, fmt.Errorf("no winID match") + } + return strconv.Atoi(match[1]) +} + +func shouldOpen() bool { + pid := os.Getpid() + shouldOpen, err := rpcCall("Daemon.RemoteShouldOpen", RPCArgs{PID: pid}) + if err != nil { + log.Printf("rpc error: %s", err) + return false + } + return shouldOpen == "true" +} + +func fzf(cmd string, input *string) (string, error) { + shell := os.Getenv("SHELL") + if len(shell) == 0 { + shell = "sh" + } + if isLightMode() { + cmd = strings.TrimRight(cmd, " \n") + shellFzfLight + } + fzf := exec.Command(shell, "-c", cmd) + fzf.Stdin = bytes.NewBuffer([]byte(*input)) + // bind the UI + fzf.Stderr = os.Stderr + // read the result + result, err := fzf.Output() + if err != nil { + return "", err + } + return string(result), nil +} + +func run(cmd string) (string, error) { + shell := os.Getenv("SHELL") + if len(shell) == 0 { + shell = "sh" + } + out, err := exec.Command(shell, "-c", cmd).Output() + return string(out), err +} diff --git a/go.mod b/go.mod index e4c14ca..3eb33bb 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,25 @@ go 1.21.6 require ( github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703 + github.com/fsnotify/fsnotify v1.7.0 + github.com/pancsta/asyncmachine-go v0.5.0 github.com/spf13/cobra v1.8.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/prometheus/client_golang v1.19.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/samber/lo v1.39.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/sys v0.16.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect ) diff --git a/go.sum b/go.sum index 4afca1f..3121013 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,51 @@ github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703 h1:jFBAp9zuZcZTkF3eOXwJ18fMKpyDC8HMwkyyACgy1Ao= github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703/go.mod h1:hZTd2MPJSe6oi3O8arSxP2WEoIuHLgct4v0t1ssBtTU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +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/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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/main.go b/main.go index 6b4ab0b..42510f3 100644 --- a/main.go +++ b/main.go @@ -1,50 +1,33 @@ package main import ( - "bytes" + "context" "fmt" + "io" "log" - "net" - "net/rpc" "os" "os/exec" - "regexp" - "runtime/debug" "strconv" "strings" - "syscall" "time" "github.com/Difrex/gosway/ipc" + "github.com/pancsta/sway-yast/watcher" + "github.com/samber/lo" "github.com/spf13/cobra" ) // CONFIG const ( maxTracked = 100 - lenSpace = 6 - lenID = 4 + lenSpace = 8 lenDisplay = 3 lenApp = 15 - lenTitle = 35 + lenTitle = 40 rpcHost = "localhost:7853" + rpcHostDbg = "localhost:7854" // how long a PID can hold the switcher pidTimeout = time.Second * 3 - cmdFzf = ` - fzf \ - --prompt 'Switcher: ' \ - --bind "load:pos(2)" \ - --bind "change:pos(1)" \ - --layout=reverse --info=hidden \ - --bind=space:accept,tab:offset-down,btab:offset-up -` - // junegunn/seoul256.vim (light) - cmdFzfLight = ` \ - --color=bg+:#D9D9D9,bg:#E1E1E1,border:#C8C8C8,spinner:#719899,hl:#719872,fg:#616161,header:#719872,info:#727100,pointer:#E12672,marker:#E17899,fg+:#616161,preview-bg:#D9D9D9,prompt:#0099BD,hl+:#719899 -` - cmdSwitcher = ` - foot --title "sway-yast" sway-yast fzf -` ) // config end @@ -52,21 +35,154 @@ const ( type WindowFocus []string type WindowData struct { + ID int Output string Workspace string Title string App string } -var winFocus WindowFocus -var winData map[string]WindowData - type Daemon struct { - conn *ipc.SwayConnection + conn *ipc.SwayConnection + mouseFollowsFocus bool + watcher *watcher.PathWatcher + ctx context.Context + winFocus WindowFocus + winData map[string]WindowData + openedByPID int + openedAt time.Time + autoconfig bool + defaultKeybindings bool } +func main() { + // TODO --status (PID, config, windows count) + // TODO readme, screenshots + // TODO auto bind default shortcuts via --bind-default-keys + // alt+tab, cmd+o, cmd+p + // TODO include desktop shortcuts in "path" (Name, Exe) + // - ~/.local/share/applications + // - /usr/share/applications + if os.Getenv("YAST_LOG") == "" { + log.SetOutput(io.Discard) + } + cmdDaemon := &cobra.Command{ + Use: "daemon", + Short: "Start tracking focus in sway", + Run: func(cmd *cobra.Command, args []string) { + mouseFollow, _ := cmd.Flags().GetBool("mouse-follows-focus") + autoconfig, _ := cmd.Flags().GetBool("autoconfig") + defaultKeybindings, _ := cmd.Flags().GetBool("default-keybindings") + d := &Daemon{ + mouseFollowsFocus: mouseFollow, + autoconfig: autoconfig, + defaultKeybindings: defaultKeybindings, + } + if mouseFollow { + log.Println("Mouse follows focus enabled") + } + d.Start() + }, + } + // TODO extract + cmdDaemon.Flags().Bool("mouse-follows-focus", false, + "Calls 'input ... map_to_output OUTPUT' on each focus") + cmdDaemon.Flags().Bool("autoconfig", true, + "Automatic configuration of layout") + cmdDaemon.Flags().Bool("default-keybindings", false, + "Add default keybindings") + + cmdList := &cobra.Command{ + Use: "mru-list", + Short: "Print a list of MRU window IDs", + Run: func(cmd *cobra.Command, args []string) { + fmt.Print(rpcCall("Daemon.RemoteWinList", RPCArgs{})) + }, + } + + cmdFzf := &cobra.Command{ + Use: "fzf", + Short: "Run fzf with a list of windows", + Run: cmdFzf, + } + + cmdFzfPickWin := &cobra.Command{ + Use: "fzf-pick-win", + Short: "Run fzf with a list of windows to pick", + Run: cmdFzfPickWin, + } + + cmdFzfPickSpace := &cobra.Command{ + Use: "fzf-pick-space", + Short: "Run fzf with a list of workspaces to pick", + Run: cmdFzfPickSpace, + } + + cmdFzfPath := &cobra.Command{ + Use: "fzf-path", + Short: "Run fzf with a list of executable files from PATH", + Run: cmdFzfPath, + } + + cmdSwitcher := &cobra.Command{ + Use: "switcher", + Short: "Show the switcher window using foot", + Run: cmdSwitcher, + } + + cmdPickWin := &cobra.Command{ + Use: "pick-win", + Short: "Show the window picker using foot", + Run: cmdPickWin, + } + + cmdPickSpace := &cobra.Command{ + Use: "pick-space", + Short: "Show the workspace picker using foot", + Run: cmdPickSpace, + } + + cmdPath := &cobra.Command{ + Use: "path", + Short: "Show the +x files from PATH using foot", + Run: cmdPath, + } + + cmdConfig := &cobra.Command{ + Use: "config", + Short: "Change the config of a running daemon process", + Run: cmdConfig, + } + // TODO extract + cmdConfig.Flags().Bool("mouse-follows-focus", false, + "Calls 'input ... map_to_output OUTPUT' on each focus") + + var rootCmd = &cobra.Command{ + Use: "sway-yast", + Run: cmdRoot, + } + rootCmd.AddCommand(cmdDaemon, cmdList, cmdFzf, cmdSwitcher, cmdFzfPickWin, cmdPickWin, cmdConfig, cmdFzfPickSpace, + cmdPickSpace, cmdPath, cmdFzfPath) + rootCmd.Flags().Bool("version", false, + "Print version and exit") + + err := rootCmd.Execute() + if err != nil { + log.Fatal("cobra error:", err) + } +} + +///// DAEMON ///// + func (d *Daemon) Start() { - winData = make(map[string]WindowData) + var err error + d.ctx = context.Background() + d.winData = make(map[string]WindowData) + d.watcher, err = watcher.New(d.ctx) + if err != nil { + log.Fatalf("error: %s", err) + } + // TODO reconnect backoff? conn, err := ipc.NewSwayConnection() if err != nil { panic(err) @@ -87,11 +203,34 @@ func (d *Daemon) Start() { } // set up the window layout - _, err = conn.RunSwayCommand(`for_window [title="sway-yast"] floating enable border none`) - if err != nil { - log.Fatal("error:", err) + if d.autoconfig { + // TODO support --autoconfig=false + // unbind existing and bind our own alt+tab binding + msgs := []string{ + `for_window [title="sway-yast"] floating enable`, + `for_window [title="sway-yast"] border none`, + `for_window [title="sway-yast"] sticky enable`, + } + err = d.SwayMsgs(msgs) + if err != nil { + log.Fatal("error:", err) + } } + if d.defaultKeybindings { + msgs := []string{ + `bindsym alt+tab exec sway-yast switcher`, + `bindsym mod4+o exec sway-yast pick-space`, + `bindsym mod4+p exec sway-yast pick-win`, + `bindsym mod4+d exec sway-yast path`, + } + err = d.SwayMsgs(msgs) + if err != nil { + log.Fatal("error:", err) + } + } + + // TODO reconnect backoff? subCon, err := ipc.NewSwayConnection() if err != nil { panic(err) @@ -108,6 +247,7 @@ func (d *Daemon) Start() { defer s.Close() go rpcServer(d) + d.watcher.Start() log.Println("Listening for sway events...") for { @@ -116,28 +256,55 @@ func (d *Daemon) Start() { if event.Change == "focus" { d.OnFocus(&event.Container) } + // TODO test if event exist, needed when moving to another workspace, find the dest one + //if event.Change == "blur" { + // d.OnBlur(&event.Container) + //} if event.Change == "close" { d.OnClose(&event.Container) } case err := <-s.Errors: + // TODO reconnect / backoff log.Println("Error:", err) break } } } +// ListSpaces returns names of the current workspaces. +func (d *Daemon) ListSpaces(skipOutputs []string) ([]string, error) { + tree, err := d.conn.GetTree() + if err != nil { + return nil, err + } + var ret = []string{} + for _, output := range tree.Nodes { + if lo.Contains(skipOutputs, output.Name) { + continue + } + for _, workspace := range output.Nodes { + if workspace.Name == "__i3_scratch" { + continue + } + ret = append(ret, workspace.Name) + } + } + return ret, nil +} + func (d *Daemon) parseNode(con *ipc.Node, space, output string) { if con.Layout != "splith" && con.Layout != "splitv" && con.Layout != "tabbed" && con.Layout != "stacked" { id := strconv.Itoa(int(con.ID)) data := WindowData{ + ID: int(con.ID), Output: output, Workspace: space, Title: con.Name, // TODO AppID App: con.WindowProperties.Class, } - winData[id] = data - winFocus, _ = unshiftAndTrim(winFocus, id) + d.winData[id] = data + d.winFocus, _ = unshiftAndTrim(d.winFocus, id) } for _, node := range con.Nodes { d.parseNode(&node, space, output) @@ -147,24 +314,12 @@ func (d *Daemon) parseNode(con *ipc.Node, space, output string) { func (d *Daemon) OnClose(c *ipc.Container) { id := strconv.Itoa(c.ID) // remove ID from winFocus - for i, v := range winFocus { - if v == id { - winFocus = append(winFocus[:i], winFocus[i+1:]...) - break - } - } + d.winFocus = lo.Without(d.winFocus, id) // remove from winData - delete(winData, id) + delete(d.winData, id) } func (d *Daemon) OnFocus(con *ipc.Container) { - // TODO debug - defer func() { - if r := recover(); r != nil { - log.Println("Recovered from panic:", r) - fmt.Printf("Stack trace: %s\n", debug.Stack()) - } - }() // skip self if con.Name == "sway-yast" { return @@ -175,6 +330,7 @@ func (d *Daemon) OnFocus(con *ipc.Container) { } id := strconv.Itoa(con.ID) data := WindowData{ + ID: con.ID, Output: space.Output, Workspace: space.Name, Title: con.Name, @@ -186,201 +342,58 @@ func (d *Daemon) OnFocus(con *ipc.Container) { } else { data.App = "unknown" } - winData[id] = data + d.winData[id] = data var removed []string - winFocus, removed = unshiftAndTrim(winFocus, id) + d.winFocus, removed = unshiftAndTrim(d.winFocus, id) for _, id := range removed { - delete(winData, id) - } -} - -func main() { - cmdDaemon := &cobra.Command{ - Use: "daemon", - Short: "Start tracking focus in sway", - Run: func(_ *cobra.Command, _ []string) { - d := &Daemon{} - d.Start() - }, - } - - cmdList := &cobra.Command{ - Use: "mru-list", - Short: "Returns a list of MRU window IDs", - Run: func(cmd *cobra.Command, args []string) { - fmt.Print(rpcCall("Daemon.WinList", RPCArgs{})) - }, - } - - cmdFzf := &cobra.Command{ - Use: "fzf", - Short: "Render fzf with a list of windows", - Run: execFzf, - } - - cmdSwitcher := &cobra.Command{ - Use: "switcher", - Short: "Show the switcher window using foot", - Run: execSwitcher, - } - - var rootCmd = &cobra.Command{Use: "app"} - rootCmd.AddCommand(cmdDaemon, cmdList, cmdFzf, cmdSwitcher) - err := rootCmd.Execute() - if err != nil { - log.Fatal("cobra error:", err) - } -} - -func execSwitcher(_ *cobra.Command, _ []string) { - pid := os.Getpid() - if rpcCall("Daemon.ShouldOpen", RPCArgs{PID: pid}) != "true" { - log.Fatal("fzf error: already open") - } - shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "sh" - } - err := exec.Command(shell, "-c", cmdSwitcher).Run() - if err != nil { - log.Fatal("foot error: " + err.Error()) - } -} - -func execFzf(_ *cobra.Command, _ []string) { - // run fzf - fzfInput := rpcCall("Daemon.FZFList", RPCArgs{}) - shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "sh" - } - cmd := cmdFzf - if isLightMode() { - cmd = strings.TrimRight(cmd, " \n") + cmdFzfLight - } - fzf := exec.Command(shell, "-c", cmd) - fzf.Stdin = bytes.NewBuffer([]byte(fzfInput)) - // bind the UI - fzf.Stderr = os.Stderr - // read the result - result, err := fzf.Output() - if err != nil { - log.Fatal("fzf error: " + err.Error()) + delete(d.winData, id) } - re := regexp.MustCompile(`\((\d+)\)`) - match := re.FindStringSubmatch(string(result)) - if len(match) == 0 { - log.Fatal("fzf error: no match") + // move the pointer + if !d.mouseFollowsFocus { + return } - // focus the window - winID, err := strconv.Atoi(match[1]) + err = d.MouseToOutput(data.Output) if err != nil { - log.Fatal("fzf error: " + err.Error()) - } - rpcCall("Daemon.FocusWinID", RPCArgs{WinID: winID}) -} - -// RPC - -type RPCArgs struct { - WinID int - PID int -} - -// RPC method -func (d *Daemon) WinList(_ RPCArgs, reply *string) error { - ids := "" - for _, id := range winFocus { - ids += fmt.Sprintf("%s ", id) + log.Printf("error: %s", err) } - *reply = ids - return nil } -// RPC method -func (d *Daemon) FZFList(_ RPCArgs, reply *string) error { - ret := "" - for _, id := range winFocus { - data := winData[id] - display := strings.Replace(data.Output, "HEADLESS-", "H-", 1) - ret += fmt.Sprintf("%-*s (%s) %-*s | %-*s | %-*s | %-*s \n", - lenSpace, maxLen(data.Workspace, lenSpace), - id, lenID-len(id), " ", - lenDisplay, maxLen(display, lenDisplay), - lenApp, maxLen(data.App, lenApp), - lenTitle, maxLen(data.Title, lenTitle), - ) - } - *reply = ret - return nil -} - -var openedByPID int -var openedAt time.Time - -// RPC method -func (d *Daemon) ShouldOpen(args RPCArgs, reply *string) error { - if openedByPID == 0 { - *reply = "true" - openedByPID = args.PID - openedAt = time.Now() - return nil - } - // check if the holding process is alive - proc, _ := os.FindProcess(openedByPID) - timeoutOut := time.Now().Sub(openedAt) > pidTimeout - if dead := proc.Signal(syscall.Signal(0)); dead != nil || timeoutOut { - openedByPID = args.PID - openedAt = time.Now() - *reply = "true" - } else { - *reply = "false" +func (d *Daemon) CurrentWin() WindowData { + if len(d.winFocus) == 0 { + return WindowData{} } - return nil + id := d.winFocus[0] + return d.winData[id] } -// RPC method -func (d *Daemon) FocusWinID(args RPCArgs, _ *string) error { - log.Printf("focusing %d...", args.WinID) - _, err := d.conn.RunSwayCommand(fmt.Sprintf("[con_id=\"%d\"] focus", args.WinID)) +func (d *Daemon) FocusWinID(id int) error { + _, err := d.conn.RunSwayCommand(fmt.Sprintf(`[con_id="%d"] focus`, id)) if err != nil { return err } return nil } -var client *rpc.Client - -func rpcCall(method string, args RPCArgs) string { - var err error - if client == nil { - client, err = rpc.Dial("tcp", rpcHost) +func (d *Daemon) SwayMsgs(msgs []string) error { + for _, msg := range msgs { + _, err := d.conn.RunSwayCommand(msg) if err != nil { - fmt.Println("rpc connection error, is the daemon running?") - os.Exit(1) + return err } } - var reply string - err = client.Call(method, args, &reply) - if err != nil { - log.Fatal("rpc error:", err) - } - return reply + return nil } -func rpcServer(server any) { - err := rpc.Register(server) +func (d *Daemon) MouseToOutput(output string) error { + _, err := d.conn.RunSwayCommand(fmt.Sprintf( + `input 0:0:wlr_virtual_pointer_v1 map_to_output "%s"`, output)) if err != nil { - log.Fatal("register error:", err) - } - l, err := net.Listen("tcp", rpcHost) - if err != nil { - log.Fatal("listen error:", err) + return err } - rpc.Accept(l) + return nil } -// Utils +///// UTILS ///// func unshiftAndTrim(slice []string, id string) ([]string, []string) { for i, v := range slice { diff --git a/rpc.go b/rpc.go new file mode 100644 index 0000000..d8b5b3a --- /dev/null +++ b/rpc.go @@ -0,0 +1,258 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net" + "net/rpc" + "os" + "strings" + "syscall" + "time" + + ss "github.com/pancsta/sway-yast/watcher/states" +) + +// RPC + +var client *rpc.Client + +type RPCArgs struct { + WinID int + Workspace string + PID int + MouseFollowsFocus bool + ExePath string +} + +// RPC method +func (d *Daemon) RemoteWinList(_ RPCArgs, reply *string) error { + ids := "" + for _, id := range d.winFocus { + ids += fmt.Sprintf("%s ", id) + } + *reply = ids + return nil +} + +// RPC method +func (d *Daemon) RemoteFZFList(_ RPCArgs, reply *string) error { + ret := "" + // TODO extract + for _, id := range d.winFocus { + data := d.winData[id] + display := strings.Replace(data.Output, "HEADLESS-", "H-", 1) + // ret += fmt.Sprintf("%-*s (%s) %s| %-*s | %-*s | %-*s \n", + ret += fmt.Sprintf("%-*s | %-*s | %-*s | %-*s (%s) \n", + lenDisplay, maxLen(display, lenDisplay), + lenSpace, maxLen(data.Workspace, lenSpace), + lenApp, maxLen(data.App, lenApp), + lenTitle, maxLen(data.Title, lenTitle), + id, + ) + } + *reply = ret + return nil +} + +// RPC method +func (d *Daemon) RemoteFZFListPickWin(_ RPCArgs, reply *string) error { + wspace := d.winData[d.winFocus[0]].Workspace + ret := "" + // TODO extract + for _, id := range d.winFocus { + data := d.winData[id] + // skip same workspace + if data.Workspace == wspace { + continue + } + display := strings.Replace(data.Output, "HEADLESS-", "H-", 1) + // ret += fmt.Sprintf("%-*s (%s) %s| %-*s | %-*s | %-*s \n", + ret += fmt.Sprintf("%-*s | %-*s | %-*s | %-*s (%s) \n", + lenDisplay, maxLen(display, lenDisplay), + lenSpace, maxLen(data.Workspace, lenSpace), + lenApp, maxLen(data.App, lenApp), + lenTitle, maxLen(data.Title, lenTitle), + id, + ) + } + *reply = ret + return nil +} + +// RPC method +func (d *Daemon) RemoteFZFListPickSpace(_ RPCArgs, reply *string) error { + currWin := d.CurrentWin() + spaces, err := d.ListSpaces([]string{currWin.Output}) + if err != nil { + log.Printf("error: %s", err) + return err + } + *reply = strings.Join(spaces, "\n") + return nil +} + +// RPC method +func (d *Daemon) RemoteShouldOpen(args RPCArgs, reply *string) error { + if d.openedByPID == 0 { + *reply = "true" + d.openedByPID = args.PID + d.openedAt = time.Now() + return nil + } + // check if the holding process is alive + proc, _ := os.FindProcess(d.openedByPID) + timeoutOut := time.Now().Sub(d.openedAt) > pidTimeout + if dead := proc.Signal(syscall.Signal(0)); dead != nil || timeoutOut { + d.openedByPID = args.PID + d.openedAt = time.Now() + *reply = "true" + } else { + *reply = "false" + } + return nil +} + +// RPC method +func (d *Daemon) RemoteFocusWinID(args RPCArgs, _ *string) error { + log.Printf("focusing %d...", args.WinID) + err := d.FocusWinID(args.WinID) + if err != nil { + log.Printf("error: %s", err) + return err + } + return nil +} + +// RPC method +func (d *Daemon) RemoteMoveSpaceToOutput(args RPCArgs, _ *string) error { + currentWin := d.CurrentWin() + if currentWin.Output == "" { + err := errors.New("no focused window / output") + log.Printf("error: %s", err) + return err + } + log.Printf("moving space %s to %s", args.Workspace, currentWin.Output) + msgs := []string{ + fmt.Sprintf("workspace %s", args.Workspace), + fmt.Sprintf("move workspace to output %s", currentWin.Output), + } + err := d.SwayMsgs(msgs) + if err != nil { + log.Printf("error: %s", err) + return err + } + time.Sleep(100 * time.Millisecond) + // focus the original window back + err = d.FocusWinID(currentWin.ID) + if err != nil { + log.Printf("error: %s", err) + return err + } + if d.mouseFollowsFocus { + err = d.MouseToOutput(currentWin.Output) + if err != nil { + log.Printf("error: %s", err) + return err + } + } + return nil +} + +// RPC method +func (d *Daemon) RemoteMoveWinToSpace(args RPCArgs, _ *string) error { + space := d.CurrentWin().Workspace + if space == "" { + err := errors.New("no focused window / space") + log.Printf("error: %s", err) + return err + } + log.Printf("moving win %d to %s", args.WinID, space) + _, err := d.conn.RunSwayCommand(fmt.Sprintf(`[con_id="%d"] move workspace %s`, args.WinID, space)) + if err != nil { + log.Printf("error: %s", err) + return err + } + err = d.FocusWinID(args.WinID) + if err != nil { + log.Printf("error: %s", err) + return err + } + return nil +} + +// RPC method +func (d *Daemon) RemoteSetConfig(args RPCArgs, _ *string) error { + log.Printf("RemoteSetConfig %v...", args.MouseFollowsFocus) + d.mouseFollowsFocus = args.MouseFollowsFocus + if !d.mouseFollowsFocus { + // set the pointer to all the outputs + _, err := d.conn.RunSwayCommand(`input 0:0:wlr_virtual_pointer_v1 map_to_output "*"`) + if err != nil { + return err + } + } + return nil +} + +// RPC method +func (d *Daemon) RemoteGetPathFiles(args RPCArgs, ret *string) error { + log.Printf("RemoteGetPathFiles...") + <-d.watcher.Mach.When1(ss.AllRefreshed, nil) + log.Printf("AllRefreshed...") + d.watcher.ResultsLock.Lock() + defer d.watcher.ResultsLock.Unlock() + *ret += strings.Join(d.watcher.Results, "\n") + return nil +} + +// RPC method +func (d *Daemon) RemoteExec(args RPCArgs, ret *string) error { + log.Printf("RemoteExec...") + path := args.ExePath + _, err := d.conn.RunSwayCommand("exec " + path) + if err != nil { + return err + } + return nil +} + +// TODO timeout +func rpcCall(method string, args RPCArgs) (string, error) { + log.Printf("rpcCall %s...", method) + var err error + url := rpcHost + if os.Getenv("YAST_DEBUG") != "" { + url = rpcHostDbg + } + if client == nil { + client, err = rpc.Dial("tcp", url) + if err != nil { + fmt.Println("rpc connection error, is the daemon running?") + os.Exit(1) + } + } + var reply string + err = client.Call(method, args, &reply) + if err != nil { + return "", err + } + return reply, nil +} + +func rpcServer(server any) { + err := rpc.Register(server) + if err != nil { + log.Fatal("register error:", err) + } + url := rpcHost + if os.Getenv("YAST_DEBUG") != "" { + url = rpcHostDbg + } + l, err := net.Listen("tcp", url) + if err != nil { + log.Fatal("listen error:", err) + } + rpc.Accept(l) +} diff --git a/watcher/states/ss_watcher.go b/watcher/states/ss_watcher.go new file mode 100644 index 0000000..f683fb6 --- /dev/null +++ b/watcher/states/ss_watcher.go @@ -0,0 +1,65 @@ +package states + +import am "github.com/pancsta/asyncmachine-go/pkg/machine" + +// S is a type alias for a list of state names. +type S = am.S + +// States map defines relations and properties of states (for files). +var States = am.Struct{ + Init: {Add: S{Watching}}, + + Watching: { + Add: S{Init}, + After: S{Init}, + }, + ChangeEvent: { + Multi: true, + Require: S{Watching}, + }, + + Refreshing: { + Multi: true, + Remove: S{AllRefreshed}, + }, + Refreshed: {Multi: true}, + AllRefreshed: {}, +} + +// StatesDir map defines relations and properties of states (for directories). +var StatesDir = am.Struct{ + Refreshing: {Remove: groupRefreshed}, + Refreshed: {Remove: groupRefreshed}, + DirDebounced: {Remove: groupRefreshed}, + DirCached: {}, +} + +// Groups of mutually exclusive states. + +var groupRefreshed = S{Refreshing, Refreshed, DirDebounced} + +//#region boilerplate defs + +// Names of all the states (pkg enum). + +const ( + Init = "Init" + Watching = "Watching" + ChangeEvent = "ChangeEvent" + Refreshing = "Refreshing" + Refreshed = "Refreshed" + AllRefreshed = "AllRefreshed" + + // dir-only states + + DirDebounced = "DirDebounced" + DirCached = "DirCached" +) + +// Names is an ordered list of all the state names for files. +var Names = S{Init, Watching, ChangeEvent, Refreshing, Refreshed, AllRefreshed} + +// NamesDir is an ordered list of all the state names for directories. +var NamesDir = S{Refreshing, Refreshed, DirDebounced, DirCached} + +//#endregion diff --git a/watcher/watcher.go b/watcher/watcher.go new file mode 100644 index 0000000..1445b00 --- /dev/null +++ b/watcher/watcher.go @@ -0,0 +1,378 @@ +package watcher + +import ( + "context" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + am "github.com/pancsta/asyncmachine-go/pkg/machine" + "github.com/pancsta/asyncmachine-go/pkg/telemetry" + ss "github.com/pancsta/sway-yast/watcher/states" +) + +// PathWatcher watches all dirs in PATH for changes and returns a list +// of executables. +type PathWatcher struct { + am.ExceptionHandler + + Mach *am.Machine + ResultsLock sync.Mutex + Results []string + EnvPath string + + watcher *fsnotify.Watcher + dirCache map[string][]string + dirState map[string]*am.Machine + ongoing map[string]context.Context + lastRefresh map[string]time.Time +} + +func New(ctx context.Context) (*PathWatcher, error) { + w := &PathWatcher{ + EnvPath: os.Getenv("PATH"), + dirCache: make(map[string][]string), + dirState: make(map[string]*am.Machine), + ongoing: make(map[string]context.Context), + lastRefresh: make(map[string]time.Time), + } + opts := &am.Opts{ + ID: "watcher", + } + + if os.Getenv("YAST_DEBUG") != "" { + opts.HandlerTimeout = time.Minute + opts.DontPanicToException = true + } + w.Mach = am.New(ctx, ss.States, opts) + + err := w.Mach.VerifyStates(ss.Names) + if err != nil { + return nil, err + } + + err = w.Mach.BindHandlers(w) + if err != nil { + return nil, err + } + + w.Mach.SetTestLogger(log.Printf, am.LogChanges) + if os.Getenv("YAST_DEBUG") != "" { + err = telemetry.TransitionsToDBG(w.Mach, "") + if err != nil { + return nil, err + } + } + + return w, nil +} + +func (w *PathWatcher) InitState(e *am.Event) { + var err error + + w.watcher, err = fsnotify.NewWatcher() + if err != nil { + w.Mach.Remove1(ss.Init, nil) + w.Mach.AddErr(err) + } +} + +func (w *PathWatcher) InitEnd(e *am.Event) { + w.watcher.Close() +} + +func (w *PathWatcher) WatchingState(e *am.Event) { + path := w.EnvPath + dirs := strings.Split(path, string(os.PathListSeparator)) + + // start the loop (bound to this instance) + ctx := e.Machine.NewStateCtx(ss.Watching) + go w.watchLoop(ctx) + + // subscribe + for _, dirName := range dirs { + + // if path doesn't exist, continue + if _, err := os.Stat(dirName); os.IsNotExist(err) { + continue + } + + // create state per dir + err := w.watcher.Add(dirName) + if err != nil { + e.Machine.AddErr(err) + } + + // create a state for each dir + state := am.New(ctx, ss.StatesDir, nil) + err = state.VerifyStates(ss.NamesDir) + if err != nil { + e.Machine.AddErr(err) + continue + } + + w.dirState[dirName] = state + + // schedule a refresh + w.Mach.Add1(ss.Refreshing, am.A{"dirName": dirName}) + } +} + +func (w *PathWatcher) WatchingEnd(e *am.Event) { + paths := w.watcher.WatchList() + + for _, path := range paths { + err := w.watcher.Remove(path) + if err != nil { + e.Machine.AddErr(err) + } + } +} + +func (w *PathWatcher) watchLoop(ctx context.Context) { + for { + select { + + case event, ok := <-w.watcher.Events: + if !ok { + w.Mach.Remove1(ss.Watching, nil) + return + } + w.Mach.Add1(ss.ChangeEvent, am.A{ + "fsnotify.Event": event, + }) + + case err, ok := <-w.watcher.Errors: + if !ok { + w.Mach.Remove1(ss.Watching, nil) + return + } + w.Mach.AddErr(err) + + case <-ctx.Done(): + // state expired + return + } + } +} + +func (w *PathWatcher) ChangeEventState(e *am.Event) { + defer e.Machine.Remove1(ss.ChangeEvent, nil) + event := e.Args["fsnotify.Event"].(fsnotify.Event) + + // exe + isRemove := event.Op&fsnotify.Remove == fsnotify.Remove + if !isRemove { + isExe, err := isExecutable(event.Name) + if !isExe || err != nil { + return + } + } + dirName := filepath.Dir(event.Name) + + w.Mach.Add1(ss.Refreshing, am.A{ + "dirName": dirName, + }) +} + +func (w *PathWatcher) ExceptionState(e *am.Event) { + w.ExceptionHandler.ExceptionState(e) +} + +func (w *PathWatcher) RefreshingEnter(e *am.Event) bool { + + // validate req params + _, ok1 := e.Args["dirName"] + dirName, ok2 := e.Args["dirName"].(string) + dirState, ok3 := w.dirState[dirName] + depsOk := ok1 && ok2 && ok3 + if !depsOk { + return false + } + + // let the debounced refreshes pass + isDebounce, _ := e.Args["isDebounce"].(bool) + if dirState.Is1(ss.Refreshing) || (dirState.Is1(ss.DirDebounced) && !isDebounce) { + return false + } + + return true +} + +func (w *PathWatcher) RefreshingState(e *am.Event) { + w.Mach.Remove1(ss.Refreshing, nil) + + dirName := e.Args["dirName"].(string) + dirState := w.dirState[dirName] + // TODO config + debounce := time.Second + + // max 1 refresh per second + since := time.Since(w.lastRefresh[dirName]) + shouldDebounce := since < debounce + if dirState.Is1(ss.DirCached) && shouldDebounce { + w.Mach.Log("Debounce for %s", dirName) + dirState.Add1(ss.DirDebounced, nil) + + go func() { + time.Sleep(debounce) + w.Mach.Add1(ss.Refreshing, am.A{ + "dirName": dirName, + "isDebounce": true, + }) + }() + + return + } + + w.Mach.Log("Refreshing execs in %s", dirName) + dirState.Add1(ss.Refreshing, nil) + w.ongoing[dirName] = dirState.NewStateCtx(ss.Refreshing) + ctx := w.ongoing[dirName] + + go func() { + if ctx.Err() != nil { + return // expired + } + + executables, err := listExecutables(dirName) + if err != nil { + e.Machine.AddErr(err) + } + + w.Mach.Remove1(ss.Refreshing, am.A{ + "dirName": dirName, + }) + w.Mach.Add1(ss.Refreshed, am.A{ + "dirName": dirName, + "executables": executables, + }) + }() +} + +func (w *PathWatcher) RefreshingExit(e *am.Event) bool { + // GC + _, ok := e.Args["dirName"] + if ok { + dirName, ok := e.Args["dirName"].(string) + if ok { + delete(w.ongoing, dirName) + } + } + + // check completions + mut := e.Mutation() + + // removing Init is a force shutdown + removeInit := mut.Type == am.MutationRemove && mut.StateWasCalled(ss.Init) + + return len(w.ongoing) == 0 || removeInit +} + +func (w *PathWatcher) RefreshingEnd(e *am.Event) { + // forced cleanup + for i := range w.ongoing { + delete(w.ongoing, i) + } +} + +func (w *PathWatcher) RefreshedEnter(e *am.Event) bool { + // validate req params + _, ok1 := e.Args["dirName"].(string) + _, ok2 := e.Args["executables"].([]string) + + return ok1 && ok2 +} + +func (w *PathWatcher) RefreshedState(e *am.Event) { + w.Mach.Remove1(ss.Refreshed, nil) + + dirName := e.Args["dirName"].(string) + executables := e.Args["executables"].([]string) + w.dirCache[dirName] = executables + w.lastRefresh[dirName] = time.Now() + + // update the per-dir state + w.dirState[dirName].Add(am.S{ss.Refreshed, ss.DirCached}, nil) + + // try to finish the whole refresh + w.Mach.Add1(ss.AllRefreshed, nil) +} + +func (w *PathWatcher) AllRefreshedEnter(e *am.Event) bool { + return len(w.ongoing) == 0 +} + +func (w *PathWatcher) AllRefreshedState(e *am.Event) { + w.ResultsLock.Lock() + defer w.ResultsLock.Unlock() + + for _, executables := range w.dirCache { + w.Results = append(w.Results, executables...) + } + w.Results = uniqueStrings(w.Results) +} + +func (w *PathWatcher) Start() { + w.Mach.Add1(ss.Init, nil) +} + +func (w *PathWatcher) Stop() { + w.Mach.Remove1(ss.Init, nil) +} + +///// HELPERS ///// + +func isExecutable(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + return false, err + } + + return info.Mode().Perm()&0111 != 0, nil +} + +func listExecutables(dirPath string) ([]string, error) { + files, err := os.ReadDir(dirPath) + if err != nil { + return nil, err + } + + var executables []string + for _, file := range files { + if file.IsDir() { + continue + } + + fullPath := dirPath + "/" + file.Name() + isExe, err := isExecutable(fullPath) + if err != nil { + continue + } + + if isExe { + executables = append(executables, file.Name()) + } + } + + return executables, nil +} + +func uniqueStrings(s []string) []string { + seen := make(map[string]struct{}) + var result []string + + for _, v := range s { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + + return result +}