├── .github └── workflows │ ├── go.yml │ └── nix.yaml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── event ├── event.go ├── event_default.go ├── event_test.go └── event_types.go ├── examples ├── events │ └── events.go ├── hyprctl │ ├── .gitignore │ └── main.go └── hyprtabs │ ├── .gitignore │ └── main.go ├── flake.lock ├── flake.nix ├── go.mod ├── helpers ├── helpers.go ├── helpers_test.go └── helpers_type.go ├── internal └── assert │ └── assert.go ├── request.go ├── request_test.go └── request_types.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '=1.21' 22 | 23 | - name: Format 24 | run: test -z $(gofmt -l .) 25 | 26 | - name: Vet 27 | run: go vet -v ./... 28 | 29 | - name: Build 30 | run: go build -v ./... 31 | 32 | - name: Test 33 | run: go test -bench=. -short -v ./... 34 | 35 | - name: golangci-lint 36 | uses: golangci/golangci-lint-action@v6 37 | with: 38 | version: v1.64 39 | -------------------------------------------------------------------------------- /.github/workflows/nix.yaml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: DeterminateSystems/nix-installer-action@main 16 | 17 | - name: build package 18 | run: nix build -L 19 | 20 | - name: run VM tests 21 | run: nix build .#checks.x86_64-linux.testVm -L 22 | 23 | - uses: actions/upload-artifact@v4 24 | with: 25 | name: coverage 26 | path: | 27 | result/hyprland-go.* 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | .nixos-test-history 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - asasalint 4 | - asciicheck 5 | - bidichk 6 | - bodyclose 7 | - canonicalheader 8 | - containedctx 9 | - contextcheck 10 | # - copyloopvar 11 | # - cyclop 12 | - decorder 13 | # - depguard 14 | - dogsled 15 | - dupl 16 | # - dupword 17 | - durationcheck 18 | - err113 19 | - errcheck 20 | - errchkjson 21 | - errname 22 | - errorlint 23 | - exhaustive 24 | # - exhaustruct 25 | - exptostd 26 | - fatcontext 27 | # - forbidigo 28 | - forcetypeassert 29 | # - funlen 30 | - gci 31 | - ginkgolinter 32 | - gocheckcompilerdirectives 33 | # - gochecknoglobals 34 | # - gochecknoinits 35 | - gochecksumtype 36 | - gocognit 37 | - goconst 38 | # - gocritic 39 | - gocyclo 40 | - godot 41 | # - godox 42 | - gofmt 43 | - gofumpt 44 | - goheader 45 | - goimports 46 | - gomoddirectives 47 | - gomodguard 48 | - goprintffuncname 49 | - gosec 50 | - gosimple 51 | - gosmopolitan 52 | - govet 53 | - grouper 54 | - iface 55 | - importas 56 | - inamedparam 57 | - ineffassign 58 | # - interfacebloat 59 | # - intrange 60 | # - ireturn 61 | # - lll 62 | - loggercheck 63 | - maintidx 64 | - makezero 65 | - mirror 66 | - misspell 67 | # - mnd 68 | - musttag 69 | - nakedret 70 | - nestif 71 | - nilerr 72 | - nilnesserr 73 | - nilnil 74 | - nlreturn 75 | - noctx 76 | - nolintlint 77 | # - nonamedreturns 78 | - nosprintfhostport 79 | # - paralleltest 80 | - perfsprint 81 | - prealloc 82 | - predeclared 83 | - promlinter 84 | - protogetter 85 | - reassign 86 | - recvcheck 87 | # - revive 88 | - rowserrcheck 89 | - sloglint 90 | - spancheck 91 | - sqlclosecheck 92 | - staticcheck 93 | # - stylecheck 94 | - tagalign 95 | # - tagliatelle 96 | - testableexamples 97 | - testifylint 98 | # - testpackage 99 | - thelper 100 | - tparallel 101 | - unconvert 102 | - unparam 103 | - unused 104 | - usestdlibvars 105 | - usetesting 106 | # - varnamelen 107 | - wastedassign 108 | - whitespace 109 | # - wrapcheck 110 | - wsl 111 | - zerologlint 112 | issues: 113 | exclude-rules: 114 | - path: _test\.go 115 | linters: 116 | - errcheck 117 | - gosec 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Thiago Kenji Okada 4 | Copyright (c) 2024 labi-le 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hyprland-go 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/thiagokokada/hyprland-go.svg)](https://pkg.go.dev/github.com/thiagokokada/hyprland-go) 4 | [![Go](https://github.com/thiagokokada/hyprland-go/actions/workflows/go.yml/badge.svg)](https://github.com/thiagokokada/hyprland-go/actions/workflows/go.yml) 5 | [![Test](https://github.com/thiagokokada/hyprland-go/actions/workflows/nix.yaml/badge.svg)](https://github.com/thiagokokada/hyprland-go/actions/workflows/nix.yaml) 6 | [![Hyprland](https://img.shields.io/badge/Hyprland-0.44-blue)](https://github.com/hyprwm/Hyprland) 7 | [![stability-alpha](https://img.shields.io/badge/stability-alpha-f4d03f.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#alpha) 8 | 9 | An unofficial Go wrapper for Hyprland's IPC. 10 | 11 | ## Getting started 12 | 13 | ``` 14 | go get -u github.com/thiagokokada/hyprland-go 15 | ``` 16 | 17 | Look at the [`examples`](./examples) directory for examples on how to use the 18 | library. 19 | 20 | ## Why? 21 | 22 | - Go: it is a good language to prototype scripts thanks to `go run`, have low 23 | startup times after compilation and allow creation of static binaries that 24 | can be run anywhere (e.g.: you can commit the binary to your dotfiles, so you 25 | don't need to have a working Go compiler!) 26 | - Good developer experience: the API tries to support the most common use cases 27 | easily while also supporting low-level usage if necessary. Also includes 28 | proper error handling to make it easier to investigate issues 29 | - Performance: uses tricks like `bufio` and `bytes.Buffer` to archieve high 30 | performance: 31 | ```console 32 | # see examples/hyprtabs 33 | $ hyperfine -N ./hyprtabs # 1 window in workspace 34 | Benchmark 1: ./hyprtabs 35 | Time (mean ± σ): 5.5 ms ± 2.0 ms [User: 0.8 ms, System: 3.0 ms] 36 | Range (min … max): 2.2 ms … 11.8 ms 443 runs 37 | 38 | $ hyperfine -N ./hyprtabs # 10 windows in workspace, 122 commands to IPC! 39 | Benchmark 1: ./hyprtabs 40 | Time (mean ± σ): 12.0 ms ± 4.7 ms [User: 0.9 ms, System: 3.4 ms] 41 | Range (min … max): 4.4 ms … 20.0 ms 490 runs 42 | 43 | $ hyperfine -N ./hyprtabs # 20 windows in workspace, 242 commands to IPC!! 44 | Benchmark 1: ./hyprtabs 45 | Time (mean ± σ): 24.0 ms ± 10.9 ms [User: 0.9 ms, System: 3.3 ms] 46 | Range (min … max): 9.0 ms … 44.4 ms 77 runs 47 | ``` 48 | Compare the results above with the original 49 | [`hyprtabs.sh`](https://gist.github.com/Atrate/b08c5b67172abafa5e7286f4a952ca4d): 50 |
51 | 52 | $ hyperfine ./hyprtabs.sh # 1 window in workspace 53 | Benchmark 1: ./hyprtabs.sh 54 | Time (mean ± σ): 103.0 ms ± 8.1 ms [User: 51.6 ms, System: 88.1 ms] 55 | Range (min … max): 92.6 ms … 122.3 ms 30 runs 56 | 57 | $ hyperfine ./hyprtabs.sh # 10 windows in workspace 58 | Benchmark 1: ./hyprtabs.sh 59 | Time (mean ± σ): 115.5 ms ± 9.6 ms [User: 50.2 ms, System: 85.8 ms] 60 | Range (min … max): 94.8 ms … 136.8 ms 28 runs 61 | 62 | $ hyperfine ./hyprtabs.sh # 20 windows in workspace 63 | Benchmark 1: ./hyprtabs.sh 64 | Time (mean ± σ): 121.5 ms ± 5.8 ms [User: 50.7 ms, System: 82.4 ms] 65 | Range (min … max): 112.6 ms … 133.6 ms 23 runs 66 | 67 |
68 | - Zero dependencies: smaller binary sizes 69 | 70 | ## What is supported? 71 | 72 | - [Dispatchers:](https://wiki.hyprland.org/Configuring/Dispatchers/) for 73 | calling dispatchers, batch mode supported, e.g.: `c.Dispatch("exec kitty", 74 | "exec firefox")` 75 | - [Keywords:](https://wiki.hyprland.org/Configuring/Keywords/) for dealing with 76 | configuration options, e.g.: (`c.SetKeyword("bind SUPER,Q,exec,firefox", 77 | "general:border_size 1")`) 78 | - [Hyprctl commands:](https://wiki.hyprland.org/Configuring/Using-hyprctl/) 79 | most commands are supported, e.g.: `c.SetCursor("Adwaita", 80 | 32)`. 81 | + Commands that returns a JSON in `hyprctl -j` will return a proper struct, 82 | e.g.: `c.ActiveWorkspace().Monitor` 83 | - [Raw IPC commands:](https://wiki.hyprland.org/IPC/): while not recommended 84 | for general usage, sending commands directly to the IPC socket of Hyprland is 85 | supported for i.e.: performance, e.g.: `c.RawRequest("[[BATCH]] dispatch exec 86 | kitty, keyword general:border_size 1")` 87 | - [Events:](https://wiki.hyprland.org/Plugins/Development/Event-list/) to 88 | subscribe and handle Hyprland events, see 89 | [events](./examples/events/events.go) for an example on how to use it. 90 | 91 | ## Development 92 | 93 | If you are developing inside a Hyprland session, and have Go installed, you can 94 | simply run: 95 | 96 | ```console 97 | # -short flag is recommended otherwise this will run some possibly dangerous tests, like TestKill() 98 | go test -short -v 99 | ``` 100 | 101 | Keep in mind that this will probably mess your current session. We will reload 102 | your configuration at the end, but any dynamic configuration will be lost. 103 | 104 | We also have tests running in CI based in a [NixOS](https://nixos.org/) VM 105 | using [`nixosTests`](https://wiki.nixos.org/wiki/NixOS_VM_tests). Check the 106 | [`flake.nix`](./flake.nix) file. This will automatically start a VM running 107 | Hyprland and run the Go tests inside it. 108 | 109 | To run the NixOS tests locally, install [Nix](https://nixos.org/download/) in 110 | any Linux system and run: 111 | 112 | ```console 113 | nix --experimental-features 'nix-command flakes' flake check -L 114 | ``` 115 | 116 | If you want to debug tests, it is possible to run the VM in interactive mode by 117 | running: 118 | 119 | ```console 120 | nix --experimental-features 'nix-command flakes' build .#checks.x86-64_linux.testVm.driverInteractive 121 | ./result 122 | ``` 123 | 124 | And you can run `start_all()` to start the VM. 125 | 126 | ## Credits 127 | 128 | - [hyprland-ipc-client](https://github.com/labi-le/hyprland-ipc-client) for 129 | inspiration. 130 | -------------------------------------------------------------------------------- /event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/thiagokokada/hyprland-go/helpers" 12 | "github.com/thiagokokada/hyprland-go/internal/assert" 13 | ) 14 | 15 | const ( 16 | bufSize = 8192 17 | sep = ">>" 18 | ) 19 | 20 | // Initiate a new client or panic. 21 | // This should be the preferred method for user scripts, since it will 22 | // automatically find the proper socket to connect and use the 23 | // HYPRLAND_INSTANCE_SIGNATURE for the current user. 24 | // If you need to connect to arbitrary user instances or need a method that 25 | // will not panic on error, use [NewClient] instead. 26 | func MustClient() *EventClient { 27 | return assert.Must1(NewClient( 28 | assert.Must1(helpers.GetSocket(helpers.EventSocket))), 29 | ) 30 | } 31 | 32 | // Initiate a new event client. 33 | // Receive as parameters a socket that is generally localised in 34 | // '$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock'. 35 | func NewClient(socket string) (*EventClient, error) { 36 | conn, err := net.Dial("unix", socket) 37 | if err != nil { 38 | return nil, fmt.Errorf("error while connecting to socket: %w", err) 39 | } 40 | 41 | return &EventClient{conn: conn}, err 42 | } 43 | 44 | // Close the underlying connection. 45 | func (c *EventClient) Close() error { 46 | err := c.conn.Close() 47 | if err != nil { 48 | return fmt.Errorf("error while closing socket: %w", err) 49 | } 50 | 51 | return err 52 | } 53 | 54 | // Low-level receive event method, should be avoided unless there is no 55 | // alternative. 56 | func (c *EventClient) Receive(ctx context.Context) ([]ReceivedData, error) { 57 | buf := make([]byte, bufSize) 58 | 59 | n, err := readWithContext(ctx, c.conn, buf) 60 | if err != nil { 61 | return nil, fmt.Errorf("error while reading from socket: %w", err) 62 | } 63 | 64 | buf = buf[:n] 65 | 66 | var recv []ReceivedData //nolint:prealloc 67 | 68 | raw := strings.Split(string(buf), "\n") 69 | for _, event := range raw { 70 | if event == "" { 71 | continue 72 | } 73 | 74 | split := strings.Split(event, sep) 75 | if len(split) < 2 || split[0] == "" || split[1] == "" || split[1] == "," { 76 | continue 77 | } 78 | 79 | recv = append(recv, ReceivedData{ 80 | Type: EventType(split[0]), 81 | Data: RawData(split[1]), 82 | }) 83 | } 84 | 85 | return recv, nil 86 | } 87 | 88 | // Subscribe to events. 89 | // You need to pass an implementation of [EventHandler] interface for each of 90 | // the events you want to handle and all event types you want to handle. 91 | func (c *EventClient) Subscribe(ctx context.Context, ev EventHandler, events ...EventType) error { 92 | for { 93 | // Process an event 94 | if err := receiveAndProcessEvent(ctx, c, ev, events...); err != nil { 95 | return fmt.Errorf("event processing: %w", err) 96 | } 97 | } 98 | } 99 | 100 | func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (n int, err error) { 101 | done := make(chan struct{}) 102 | 103 | // Start a goroutine to perform the read 104 | go func() { 105 | n, err = conn.Read(buf) 106 | 107 | close(done) 108 | }() 109 | 110 | select { 111 | case <-done: 112 | return n, err 113 | case <-ctx.Done(): 114 | // Set a short deadline to unblock the Read() 115 | err = conn.SetReadDeadline(time.Now()) 116 | if err != nil { 117 | return 0, err 118 | } 119 | // Reset read deadline 120 | defer func() { 121 | if e := conn.SetReadDeadline(time.Time{}); e != nil { 122 | err = errors.Join(err, e) 123 | } 124 | }() 125 | // Make sure that the goroutine is done to avoid leaks 126 | <-done 127 | 128 | return 0, errors.Join(err, ctx.Err()) 129 | } 130 | } 131 | 132 | func receiveAndProcessEvent(ctx context.Context, c eventClient, ev EventHandler, events ...EventType) error { 133 | msg, err := c.Receive(ctx) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | for _, data := range msg { 139 | processEvent(ev, data, events) 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func processEvent(ev EventHandler, msg ReceivedData, events []EventType) { 146 | for _, event := range events { 147 | raw := strings.Split(string(msg.Data), ",") 148 | 149 | if msg.Type == event { 150 | switch event { 151 | case EventWorkspace: 152 | // e.g. "1" (workspace number) 153 | ev.Workspace(WorkspaceName(raw[0])) 154 | case EventFocusedMonitor: 155 | // idk 156 | ev.FocusedMonitor(FocusedMonitor{ 157 | MonitorName: MonitorName(raw[0]), 158 | WorkspaceName: WorkspaceName(raw[1]), 159 | }) 160 | case EventActiveWindow: 161 | // e.g. nvim,nvim event/event.go 162 | ev.ActiveWindow(ActiveWindow{ 163 | Name: raw[0], 164 | Title: raw[1], 165 | }) 166 | case EventFullscreen: 167 | // e.g. "true" or "false" 168 | ev.Fullscreen(raw[0] == "1") 169 | case EventMonitorRemoved: 170 | // e.g. idk 171 | ev.MonitorRemoved(MonitorName(raw[0])) 172 | case EventMonitorAdded: 173 | // e.g. idk 174 | ev.MonitorAdded(MonitorName(raw[0])) 175 | case EventCreateWorkspace: 176 | // e.g. "1" (workspace number) 177 | ev.CreateWorkspace(WorkspaceName(raw[0])) 178 | case EventDestroyWorkspace: 179 | // e.g. "1" (workspace number) 180 | ev.DestroyWorkspace(WorkspaceName(raw[0])) 181 | case EventMoveWorkspace: 182 | // e.g. idk 183 | ev.MoveWorkspace(MoveWorkspace{ 184 | WorkspaceName: WorkspaceName(raw[0]), 185 | MonitorName: MonitorName(raw[1]), 186 | }) 187 | case EventActiveLayout: 188 | // e.g. AT Translated Set 2 keyboard,Russian 189 | ev.ActiveLayout(ActiveLayout{ 190 | Type: raw[0], 191 | Name: raw[1], 192 | }) 193 | case EventOpenWindow: 194 | // e.g. 80864f60,1,Alacritty,Alacritty 195 | ev.OpenWindow(OpenWindow{ 196 | Address: raw[0], 197 | WorkspaceName: WorkspaceName(raw[1]), 198 | Class: raw[2], 199 | Title: raw[3], 200 | }) 201 | case EventCloseWindow: 202 | // e.g. 5 203 | ev.CloseWindow(CloseWindow{ 204 | Address: raw[0], 205 | }) 206 | case EventMoveWindow: 207 | // e.g. 5 208 | ev.MoveWindow(MoveWindow{ 209 | Address: raw[0], 210 | WorkspaceName: WorkspaceName(raw[1]), 211 | }) 212 | case EventOpenLayer: 213 | // e.g. wofi 214 | ev.OpenLayer(OpenLayer(raw[0])) 215 | case EventCloseLayer: 216 | // e.g. wofi 217 | ev.CloseLayer(CloseLayer(raw[0])) 218 | case EventSubMap: 219 | // e.g. idk 220 | ev.SubMap(SubMap(raw[0])) 221 | case EventScreencast: 222 | ev.Screencast(Screencast{ 223 | Sharing: raw[0] == "1", 224 | Owner: raw[1], 225 | }) 226 | } 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /event/event_default.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | // DefaultEventHandler is an implementation of [EventHandler] interface with 4 | // all handlers doing nothing. It is a good starting point to be embedded your 5 | // own struct to be extended. 6 | type DefaultEventHandler struct{} 7 | 8 | func (e *DefaultEventHandler) Workspace(WorkspaceName) {} 9 | func (e *DefaultEventHandler) FocusedMonitor(FocusedMonitor) {} 10 | func (e *DefaultEventHandler) ActiveWindow(ActiveWindow) {} 11 | func (e *DefaultEventHandler) Fullscreen(Fullscreen) {} 12 | func (e *DefaultEventHandler) MonitorRemoved(MonitorName) {} 13 | func (e *DefaultEventHandler) MonitorAdded(MonitorName) {} 14 | func (e *DefaultEventHandler) CreateWorkspace(WorkspaceName) {} 15 | func (e *DefaultEventHandler) DestroyWorkspace(WorkspaceName) {} 16 | func (e *DefaultEventHandler) MoveWorkspace(MoveWorkspace) {} 17 | func (e *DefaultEventHandler) ActiveLayout(ActiveLayout) {} 18 | func (e *DefaultEventHandler) OpenWindow(OpenWindow) {} 19 | func (e *DefaultEventHandler) CloseWindow(CloseWindow) {} 20 | func (e *DefaultEventHandler) MoveWindow(MoveWindow) {} 21 | func (e *DefaultEventHandler) OpenLayer(OpenLayer) {} 22 | func (e *DefaultEventHandler) CloseLayer(CloseLayer) {} 23 | func (e *DefaultEventHandler) SubMap(SubMap) {} 24 | func (e *DefaultEventHandler) Screencast(Screencast) {} 25 | -------------------------------------------------------------------------------- /event/event_test.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "math/rand" 8 | "net" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/thiagokokada/hyprland-go" 14 | "github.com/thiagokokada/hyprland-go/internal/assert" 15 | ) 16 | 17 | const socketPath = "/tmp/bench_unix_socket.sock" 18 | 19 | type FakeEventClient struct { 20 | EventClient 21 | } 22 | 23 | type FakeEventHandler struct { 24 | t *testing.T 25 | EventHandler 26 | } 27 | 28 | func TestReceive(t *testing.T) { 29 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { 30 | t.Skip("HYPRLAND_INSTANCE_SIGNATURE not set, skipping test") 31 | } 32 | 33 | // Generate an event 34 | go func() { 35 | c := hyprland.MustClient() 36 | 37 | time.Sleep(100 * time.Millisecond) 38 | c.Dispatch("exec kitty sh -c 'echo Testing hyprland-go events && sleep 1'") 39 | }() 40 | 41 | c := MustClient() 42 | defer c.Close() 43 | data, err := c.Receive(context.Background()) 44 | 45 | // We must capture the event 46 | assert.NoError(t, err) 47 | assert.GreaterOrEqual(t, len(data), 1) 48 | 49 | for _, d := range data { 50 | assert.NotEqual(t, string(d.Data), "") 51 | assert.NotEqual(t, string(d.Type), "") 52 | } 53 | } 54 | 55 | func TestSubscribe(t *testing.T) { 56 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { 57 | t.Skip("HYPRLAND_INSTANCE_SIGNATURE not set, skipping test") 58 | } 59 | 60 | c := MustClient() 61 | defer c.Close() 62 | 63 | // Make sure that we can exit a Subscribe loop by cancelling the 64 | // context 65 | ctx, cancel := context.WithTimeout( 66 | context.Background(), 67 | 100*time.Millisecond, 68 | ) 69 | 70 | err := c.Subscribe(ctx, &DefaultEventHandler{}, AllEvents...) 71 | 72 | cancel() 73 | 74 | assert.Error(t, err) 75 | assert.True(t, errors.Is(err, context.DeadlineExceeded)) 76 | 77 | // Make sure that we can call Subscribe again it can still be used, 78 | // e.g.: the conn read deadline is not set otherwise it will exit 79 | // immediately 80 | ctx, cancel = context.WithCancel(context.Background()) 81 | go func() { 82 | time.Sleep(100 * time.Millisecond) 83 | cancel() 84 | }() 85 | 86 | start := time.Now() 87 | err = c.Subscribe(ctx, &DefaultEventHandler{}, AllEvents...) 88 | elapsed := time.Since(start) 89 | 90 | assert.Error(t, err) 91 | assert.True(t, errors.Is(err, context.Canceled)) 92 | assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond) 93 | } 94 | 95 | func TestProcessEvent(t *testing.T) { 96 | h := &FakeEventHandler{t: t} 97 | c := &FakeEventClient{} 98 | err := receiveAndProcessEvent(context.Background(), c, h, AllEvents...) 99 | assert.NoError(t, err) 100 | } 101 | 102 | func (f *FakeEventClient) Receive(context.Context) ([]ReceivedData, error) { 103 | return []ReceivedData{ 104 | { 105 | Type: EventWorkspace, 106 | Data: "1", 107 | }, 108 | { 109 | Type: EventFocusedMonitor, 110 | Data: "1,1", 111 | // TODO I only have one monitor, so I didn't check this 112 | }, 113 | { 114 | Type: EventActiveWindow, 115 | Data: "nvim,nvim event/event_test.go", 116 | }, 117 | { 118 | Type: EventFullscreen, 119 | Data: "1", 120 | }, 121 | { 122 | Type: EventMonitorRemoved, 123 | Data: "1", 124 | // TODO I only have one monitor, so I didn't check this 125 | }, 126 | { 127 | Type: EventMonitorAdded, 128 | Data: "1", 129 | // TODO I only have one monitor, so I didn't check this 130 | }, 131 | { 132 | Type: EventCreateWorkspace, 133 | Data: "1", 134 | }, 135 | { 136 | Type: EventDestroyWorkspace, 137 | Data: "1", 138 | }, 139 | 140 | { 141 | Type: EventMoveWorkspace, 142 | Data: "1,1", 143 | // TODO I only have one monitor, so I didn't check this 144 | }, 145 | { 146 | Type: EventActiveLayout, 147 | Data: "AT Translated Set 2 keyboard,Russian", 148 | }, 149 | { 150 | Type: EventOpenWindow, 151 | Data: "80e62df0,2,jetbrains-goland,win430", 152 | }, 153 | { 154 | Type: EventCloseWindow, 155 | Data: "80e62df0", 156 | }, 157 | { 158 | Type: EventMoveWindow, 159 | Data: "80e62df0,1", 160 | }, 161 | { 162 | Type: EventOpenLayer, 163 | Data: "wofi", 164 | }, 165 | { 166 | Type: EventCloseLayer, 167 | Data: "wofi", 168 | }, 169 | { 170 | Type: EventSubMap, 171 | Data: "1", 172 | // idk 173 | }, 174 | { 175 | Type: EventScreencast, 176 | Data: "1,0", 177 | }, 178 | }, nil 179 | } 180 | 181 | func (h *FakeEventHandler) Workspace(w WorkspaceName) { 182 | assert.Equal(h.t, w, "1") 183 | } 184 | 185 | func (h *FakeEventHandler) FocusedMonitor(m FocusedMonitor) { 186 | assert.Equal(h.t, m.WorkspaceName, "1") 187 | assert.Equal(h.t, m.MonitorName, "1") 188 | } 189 | 190 | func (h *FakeEventHandler) ActiveWindow(w ActiveWindow) { 191 | assert.Equal(h.t, w.Name, "nvim") 192 | assert.Equal(h.t, w.Title, "nvim event/event_test.go") 193 | } 194 | 195 | func (h *FakeEventHandler) Fullscreen(f Fullscreen) { 196 | assert.Equal(h.t, f, true) 197 | } 198 | 199 | func (h *FakeEventHandler) MonitorRemoved(m MonitorName) { 200 | assert.Equal(h.t, m, "1") 201 | } 202 | 203 | func (h *FakeEventHandler) MonitorAdded(m MonitorName) { 204 | assert.Equal(h.t, m, "1") 205 | } 206 | 207 | func (h *FakeEventHandler) CreateWorkspace(w WorkspaceName) { 208 | assert.Equal(h.t, w, "1") 209 | } 210 | 211 | func (h *FakeEventHandler) DestroyWorkspace(w WorkspaceName) { 212 | assert.Equal(h.t, w, "1") 213 | } 214 | 215 | func (h *FakeEventHandler) MoveWorkspace(w MoveWorkspace) { 216 | assert.Equal(h.t, w.WorkspaceName, "1") 217 | assert.Equal(h.t, w.MonitorName, "1") 218 | } 219 | 220 | func (h *FakeEventHandler) ActiveLayout(l ActiveLayout) { 221 | assert.Equal(h.t, l.Name, "Russian") 222 | assert.Equal(h.t, l.Type, "AT Translated Set 2 keyboard") 223 | } 224 | 225 | func (h *FakeEventHandler) OpenWindow(o OpenWindow) { 226 | assert.Equal(h.t, o.Address, "80e62df0") 227 | assert.Equal(h.t, o.Class, "jetbrains-goland") 228 | assert.Equal(h.t, o.Title, "win430") 229 | assert.Equal(h.t, o.WorkspaceName, "2") 230 | } 231 | 232 | func (h *FakeEventHandler) CloseWindow(c CloseWindow) { 233 | assert.Equal(h.t, c.Address, "80e62df0") 234 | } 235 | 236 | func (h *FakeEventHandler) MoveWindow(m MoveWindow) { 237 | assert.Equal(h.t, m.Address, "80e62df0") 238 | assert.Equal(h.t, m.WorkspaceName, "1") 239 | } 240 | 241 | func (h *FakeEventHandler) OpenLayer(l OpenLayer) { 242 | assert.Equal(h.t, l, "wofi") 243 | } 244 | 245 | func (h *FakeEventHandler) CloseLayer(l CloseLayer) { 246 | assert.Equal(h.t, l, "wofi") 247 | } 248 | 249 | func (h *FakeEventHandler) SubMap(s SubMap) { 250 | assert.Equal(h.t, s, "1") 251 | } 252 | 253 | func (h *FakeEventHandler) Screencast(s Screencast) { 254 | assert.Equal(h.t, s.Owner, "0") 255 | assert.Equal(h.t, s.Sharing, true) 256 | } 257 | 258 | func BenchmarkReceive(b *testing.B) { 259 | go RandomStringServer() 260 | 261 | // Make sure the socket exist 262 | for i := 0; i < 10; i++ { 263 | time.Sleep(100 * time.Millisecond) 264 | 265 | if _, err := os.Stat(socketPath); err != nil { 266 | break 267 | } 268 | } 269 | 270 | c := assert.Must1(NewClient(socketPath)) 271 | defer c.Close() 272 | 273 | ctx := context.Background() 274 | 275 | // Reset setup time 276 | b.ResetTimer() 277 | 278 | for i := 0; i < b.N; i++ { 279 | c.Receive(ctx) 280 | } 281 | } 282 | 283 | // This function needs to be as fast as possible, otherwise this is the 284 | // bottleneck 285 | // https://stackoverflow.com/a/31832326 286 | const ( 287 | letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\n" 288 | letterIdxBits = 6 // 6 bits to represent a letter index 289 | letterIdxMask = 1<= 0; { 297 | if remain == 0 { 298 | cache, remain = rand.Int63(), letterIdxMax 299 | } 300 | 301 | if idx := int(cache & letterIdxMask); idx < len(letterBytes) { 302 | b[i] = letterBytes[idx] 303 | i-- 304 | } 305 | 306 | cache >>= letterIdxBits 307 | remain-- 308 | } 309 | 310 | return b 311 | } 312 | 313 | func RandomStringServer() { 314 | // Remove the previous socket file if it exists 315 | if err := os.RemoveAll(socketPath); err != nil { 316 | panic(err) 317 | } 318 | 319 | listener, err := net.Listen("unix", socketPath) 320 | if err != nil { 321 | panic(err) 322 | } 323 | defer listener.Close() 324 | 325 | for { 326 | conn, err := listener.Accept() 327 | if err != nil { 328 | panic(err) 329 | } 330 | 331 | writer := bufio.NewWriter(conn) 332 | 333 | go func(c net.Conn) { 334 | defer c.Close() 335 | 336 | for { 337 | prefix := []byte(">>>") 338 | randomData := RandomBytes(16) 339 | message := append(prefix, randomData...) 340 | 341 | // Send the message to the client 342 | _, err := writer.Write(message) 343 | if err != nil { 344 | return 345 | } 346 | } 347 | }(conn) 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /event/event_types.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | // EventClient is the event struct from hyprland-go. 9 | type EventClient struct { 10 | conn net.Conn 11 | } 12 | 13 | // Event Client interface, right now only used for testing. 14 | type eventClient interface { 15 | Receive(_ context.Context) ([]ReceivedData, error) 16 | } 17 | 18 | type RawData string 19 | 20 | type EventType string 21 | 22 | type ReceivedData struct { 23 | Type EventType 24 | Data RawData 25 | } 26 | 27 | // EventHandler is the interface that defines all methods to handle each of 28 | // events emitted by Hyprland. 29 | // You can find move information about each event in the main Hyprland Wiki: 30 | // https://wiki.hyprland.org/Plugins/Development/Event-list/. 31 | type EventHandler interface { 32 | // Workspace emitted on workspace change. Is emitted ONLY when a user 33 | // requests a workspace change, and is not emitted on mouse movements. 34 | Workspace(w WorkspaceName) 35 | // FocusedMonitor emitted on the active monitor being changed. 36 | FocusedMonitor(m FocusedMonitor) 37 | // ActiveWindow emitted on the active window being changed. 38 | ActiveWindow(w ActiveWindow) 39 | // Fullscreen emitted when a fullscreen status of a window changes. 40 | Fullscreen(f Fullscreen) 41 | // MonitorRemoved emitted when a monitor is removed (disconnected). 42 | MonitorRemoved(m MonitorName) 43 | // MonitorAdded emitted when a monitor is added (connected). 44 | MonitorAdded(m MonitorName) 45 | // CreateWorkspace emitted when a workspace is created. 46 | CreateWorkspace(w WorkspaceName) 47 | // DestroyWorkspace emitted when a workspace is destroyed. 48 | DestroyWorkspace(w WorkspaceName) 49 | // MoveWorkspace emitted when a workspace is moved to a different 50 | // monitor. 51 | MoveWorkspace(w MoveWorkspace) 52 | // ActiveLayout emitted on a layout change of the active keyboard. 53 | ActiveLayout(l ActiveLayout) 54 | // OpenWindow emitted when a window is opened. 55 | OpenWindow(o OpenWindow) 56 | // CloseWindow emitted when a window is closed. 57 | CloseWindow(c CloseWindow) 58 | // MoveWindow emitted when a window is moved to a workspace. 59 | MoveWindow(m MoveWindow) 60 | // OpenLayer emitted when a layerSurface is mapped. 61 | OpenLayer(l OpenLayer) 62 | // CloseLayer emitted when a layerSurface is unmapped. 63 | CloseLayer(c CloseLayer) 64 | // SubMap emitted when a keybind submap changes. Empty means default. 65 | SubMap(s SubMap) 66 | // Screencast is fired when the screencopy state of a client changes. 67 | // Keep in mind there might be multiple separate clients. 68 | Screencast(s Screencast) 69 | } 70 | 71 | const ( 72 | EventWorkspace EventType = "workspace" 73 | EventFocusedMonitor EventType = "focusedmon" 74 | EventActiveWindow EventType = "activewindow" 75 | EventFullscreen EventType = "fullscreen" 76 | EventMonitorRemoved EventType = "monitorremoved" 77 | EventMonitorAdded EventType = "monitoradded" 78 | EventCreateWorkspace EventType = "createworkspace" 79 | EventDestroyWorkspace EventType = "destroyworkspace" 80 | EventMoveWorkspace EventType = "moveworkspace" 81 | EventActiveLayout EventType = "activelayout" 82 | EventOpenWindow EventType = "openwindow" 83 | EventCloseWindow EventType = "closewindow" 84 | EventMoveWindow EventType = "movewindow" 85 | EventOpenLayer EventType = "openlayer" 86 | EventCloseLayer EventType = "closelayer" 87 | EventSubMap EventType = "submap" 88 | EventScreencast EventType = "screencast" 89 | ) 90 | 91 | // AllEvents is the combination of all event types, useful if you want to 92 | // subscribe to all supported events at the same time. 93 | // Keep in mind that generally explicit declaring which events you want to 94 | // subscribe is better, since new events will be added in future. 95 | var AllEvents = []EventType{ 96 | EventWorkspace, 97 | EventFocusedMonitor, 98 | EventActiveWindow, 99 | EventFullscreen, 100 | EventMonitorRemoved, 101 | EventMonitorAdded, 102 | EventCreateWorkspace, 103 | EventDestroyWorkspace, 104 | EventMoveWorkspace, 105 | EventActiveLayout, 106 | EventOpenWindow, 107 | EventCloseWindow, 108 | EventMoveWindow, 109 | EventOpenLayer, 110 | EventCloseLayer, 111 | EventSubMap, 112 | EventScreencast, 113 | } 114 | 115 | type MoveWorkspace struct { 116 | WorkspaceName 117 | MonitorName 118 | } 119 | 120 | type Fullscreen bool 121 | 122 | type MonitorName string 123 | 124 | type FocusedMonitor struct { 125 | MonitorName 126 | WorkspaceName 127 | } 128 | 129 | type WorkspaceName string 130 | 131 | type SubMap string 132 | 133 | type CloseLayer string 134 | 135 | type OpenLayer string 136 | 137 | type MoveWindow struct { 138 | Address string 139 | WorkspaceName 140 | } 141 | 142 | type CloseWindow struct { 143 | Address string 144 | } 145 | 146 | type OpenWindow struct { 147 | Address, Class, Title string 148 | WorkspaceName 149 | } 150 | 151 | type ActiveLayout struct { 152 | Type, Name string 153 | } 154 | 155 | type ActiveWindow struct { 156 | Name, Title string 157 | } 158 | 159 | type ActiveWorkspace WorkspaceName 160 | 161 | type Screencast struct { 162 | // True if a screen or window is being shared. 163 | Sharing bool 164 | 165 | // "0" if monitor is shared, "1" if window is shared. 166 | Owner string 167 | } 168 | -------------------------------------------------------------------------------- /examples/events/events.go: -------------------------------------------------------------------------------- 1 | // Basic example on how to handle events in hyprland-go. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/thiagokokada/hyprland-go/event" 10 | ) 11 | 12 | type ev struct { 13 | event.DefaultEventHandler 14 | } 15 | 16 | func must(err error) { 17 | if err != nil { 18 | panic(err) 19 | } 20 | } 21 | 22 | func (e *ev) Workspace(w event.WorkspaceName) { 23 | fmt.Printf("Workspace: %+v\n", w) 24 | } 25 | 26 | func (e *ev) ActiveWindow(w event.ActiveWindow) { 27 | fmt.Printf("ActiveWindow: %+v\n", w) 28 | } 29 | 30 | func main() { 31 | ctx, cancel := context.WithTimeout( 32 | context.Background(), 33 | 5*time.Second, 34 | ) 35 | defer cancel() 36 | 37 | c := event.MustClient() 38 | defer must(c.Close()) 39 | 40 | // Will listen for events for 5 seconds and exit 41 | must(c.Subscribe( 42 | ctx, 43 | &ev{}, 44 | event.EventWorkspace, 45 | event.EventActiveWindow, 46 | )) 47 | 48 | fmt.Println("Bye!") 49 | } 50 | -------------------------------------------------------------------------------- /examples/hyprctl/.gitignore: -------------------------------------------------------------------------------- 1 | hyprctl 2 | -------------------------------------------------------------------------------- /examples/hyprctl/main.go: -------------------------------------------------------------------------------- 1 | // Limited reimplementation of hyprctl using hyprland-go to show an example 2 | // on how it can be used. 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "os" 11 | "sort" 12 | "strings" 13 | 14 | "github.com/thiagokokada/hyprland-go" 15 | ) 16 | 17 | var ( 18 | c *hyprland.RequestClient 19 | // default error/usage output 20 | out io.Writer = flag.CommandLine.Output() 21 | ) 22 | 23 | // Needed for an acumulator flag, i.e.: can be passed multiple times, get the 24 | // results in a string array 25 | // https://stackoverflow.com/a/28323276 26 | type arrayFlags []string 27 | 28 | func (i *arrayFlags) String() string { 29 | return fmt.Sprintf("%v", *i) 30 | } 31 | 32 | func (i *arrayFlags) Set(v string) error { 33 | *i = append(*i, v) 34 | return nil 35 | } 36 | 37 | func must1[T any](v T, err error) T { 38 | must(err) 39 | return v 40 | } 41 | 42 | func must(err error) { 43 | if err != nil { 44 | panic(err) 45 | } 46 | } 47 | 48 | // Unmarshal structs as JSON and indent output 49 | func mustMarshalIndent(v any) []byte { 50 | return must1(json.MarshalIndent(v, "", " ")) 51 | } 52 | 53 | func usage(m map[string]func(args []string)) { 54 | must1(fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])) 55 | must1(fmt.Fprintf(out, " %s [subcommand] \n\n", os.Args[0])) 56 | must1(fmt.Fprintf(out, "Available subcommands:\n")) 57 | 58 | // Sort keys before printing, since Go randomises order 59 | subcommands := make([]string, len(m)) 60 | i := 0 61 | for s := range m { 62 | subcommands[i] = s 63 | i++ 64 | } 65 | sort.Strings(subcommands) 66 | for _, s := range subcommands { 67 | must1(fmt.Fprintf(out, " - %s\n", s)) 68 | } 69 | } 70 | 71 | func main() { 72 | batchFS := flag.NewFlagSet("batch", flag.ExitOnError) 73 | var batch arrayFlags 74 | batchFS.Var(&batch, "c", "Command to batch, can be passed multiple times. "+ 75 | "Please quote commands with arguments (e.g.: 'dispatch exec kitty')") 76 | 77 | dispatchFS := flag.NewFlagSet("dispatch", flag.ExitOnError) 78 | var dispatch arrayFlags 79 | dispatchFS.Var(&dispatch, "c", "Command to dispatch, can be passed multiple times. "+ 80 | "Please quote commands with arguments (e.g.: 'exec kitty')") 81 | 82 | setcursorFS := flag.NewFlagSet("setcursor", flag.ExitOnError) 83 | theme := setcursorFS.String("theme", "Adwaita", "Cursor theme") 84 | size := setcursorFS.Int("size", 32, "Cursor size") 85 | 86 | // Map pf subcommands to a function to handle the subcommand. Will 87 | // receive the subcommand arguments as parameter 88 | m := map[string]func(args []string){ 89 | "activewindow": func(_ []string) { 90 | v := must1(c.ActiveWindow()) 91 | must1(fmt.Printf("%s\n", mustMarshalIndent(v))) 92 | }, 93 | "activeworkspace": func(_ []string) { 94 | v := must1(c.ActiveWorkspace()) 95 | must1(fmt.Printf("%s\n", mustMarshalIndent(v))) 96 | }, 97 | "batch": func(args []string) { 98 | must(batchFS.Parse(args)) 99 | if len(batch) == 0 { 100 | must1(fmt.Fprintf(out, "Error: at least one '-c' is required for batch.\n")) 101 | os.Exit(1) 102 | } else { 103 | // Batch commands are done in the following way: 104 | // `[[BATCH]]command0 param0 param1; command1 param0 param1;` 105 | r := hyprland.RawRequest( 106 | fmt.Sprintf("[[BATCH]]%s", strings.Join(batch, ";")), 107 | ) 108 | v := must1(c.RawRequest(r)) 109 | must1(fmt.Printf("%s\n", v)) 110 | } 111 | }, 112 | "dispatch": func(args []string) { 113 | must(dispatchFS.Parse(args)) 114 | if len(dispatch) == 0 { 115 | must1(fmt.Fprintf(out, "Error: at least one '-c' is required for dispatch.\n")) 116 | os.Exit(1) 117 | } else { 118 | v := must1(c.Dispatch(dispatch...)) 119 | must1(fmt.Printf("%s\n", v)) 120 | } 121 | }, 122 | "kill": func(_ []string) { 123 | v := must1(c.Kill()) 124 | must1(fmt.Printf("%s\n", v)) 125 | }, 126 | "reload": func(_ []string) { 127 | v := must1(c.Reload()) 128 | must1(fmt.Printf("%s\n", v)) 129 | }, 130 | "setcursor": func(_ []string) { 131 | must(setcursorFS.Parse(os.Args[2:])) 132 | v := must1(c.SetCursor(*theme, *size)) 133 | must1(fmt.Printf("%s\n", v)) 134 | }, 135 | "version": func(_ []string) { 136 | v := must1(c.Version()) 137 | must1(fmt.Printf("%s\n", mustMarshalIndent(v))) 138 | }, 139 | } 140 | 141 | flag.Usage = func() { usage(m) } 142 | flag.Parse() 143 | 144 | if len(os.Args) < 2 { 145 | flag.Usage() 146 | os.Exit(1) 147 | } 148 | 149 | subcommand := os.Args[1] 150 | if run, ok := m[subcommand]; ok { 151 | c = hyprland.MustClient() 152 | run(os.Args[2:]) 153 | } else { 154 | must1(fmt.Fprintf(out, "Error: unknown subcommand: %s\n", subcommand)) 155 | os.Exit(1) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /examples/hyprtabs/.gitignore: -------------------------------------------------------------------------------- 1 | hyprtabs 2 | -------------------------------------------------------------------------------- /examples/hyprtabs/main.go: -------------------------------------------------------------------------------- 1 | // Group all windows in the current workspace, or ungroup, basically similar to 2 | // how i3/sway tabbed container works. 3 | // This script works better with "master" layouts (since the layout is more 4 | // predictable), but it also works in "dwindle" layouts as long the layout is 5 | // not too "deep" (e.g.: too many windows in the same workspace). See 6 | // https://github.com/hyprwm/Hyprland/issues/2822 for more details. 7 | package main 8 | 9 | import ( 10 | "fmt" 11 | 12 | "github.com/thiagokokada/hyprland-go" 13 | ) 14 | 15 | func must1[T any](v T, err error) T { 16 | must(err) 17 | return v 18 | } 19 | 20 | func must(err error) { 21 | if err != nil { 22 | panic(err) 23 | } 24 | } 25 | 26 | func main() { 27 | client := hyprland.MustClient() 28 | 29 | aWindow := must1(client.ActiveWindow()) 30 | if len(aWindow.Grouped) > 0 { 31 | must1(client.Dispatch( 32 | // If we are already in a group, ungroup 33 | "togglegroup", 34 | // Make the current window as master (when using master layout) 35 | "layoutmsg swapwithmaster master", 36 | )) 37 | } else { 38 | var cmdbuf []string 39 | aWorkspace := must1(client.ActiveWorkspace()) 40 | clients := must1(client.Clients()) 41 | 42 | // Grab all windows in the active workspace 43 | var windows []string 44 | for _, c := range clients { 45 | if c.Workspace.Id == aWorkspace.Id { 46 | windows = append(windows, c.Address) 47 | } 48 | } 49 | 50 | // Start by creating a new group 51 | cmdbuf = append(cmdbuf, "togglegroup") 52 | for _, w := range windows { 53 | // Move each window inside the group 54 | // Once is not enough in case of very "deep" layouts, 55 | // so we run this multiple times to try to make sure it 56 | // will work 57 | // For master layouts we also call swapwithmaster, this 58 | // makes the switch more reliable 59 | // FIXME: this workaround could be fixed if hyprland 60 | // supported moving windows based on address and not 61 | // only positions 62 | for i := 0; i < 2; i++ { 63 | cmdbuf = append(cmdbuf, fmt.Sprintf("focuswindow address:%s", w)) 64 | cmdbuf = append(cmdbuf, "layoutmsg swapwithmaster auto") 65 | cmdbuf = append(cmdbuf, "moveintogroup l") 66 | cmdbuf = append(cmdbuf, "moveintogroup r") 67 | cmdbuf = append(cmdbuf, "moveintogroup u") 68 | cmdbuf = append(cmdbuf, "moveintogroup d") 69 | } 70 | } 71 | // Focus in the active window at the end 72 | cmdbuf = append(cmdbuf, fmt.Sprintf("focuswindow address:%s", aWindow.Address)) 73 | 74 | // Dispatch buffered commands in one call for performance, 75 | // hyprland-go will take care of splitting it in smaller calls 76 | // if necessary 77 | must1(client.Dispatch(cmdbuf...)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1742169275, 6 | "narHash": "sha256-nkH2Edu9rClcsQp2PYBe8E6fp8LDPi2uDBQ6wyMdeXI=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "5d9b5431f967007b3952c057fc92af49a4c5f3b2", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Hyprland's IPC bindings for Go"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | }; 7 | 8 | outputs = 9 | { self, nixpkgs, ... }: 10 | let 11 | supportedSystems = [ 12 | "x86_64-linux" 13 | "aarch64-linux" 14 | ]; 15 | 16 | # Helper function to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'. 17 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 18 | 19 | # Nixpkgs instantiated for supported system types. 20 | nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); 21 | in 22 | { 23 | checks = forAllSystems ( 24 | system: 25 | let 26 | pkgs = nixpkgsFor.${system}; 27 | in 28 | { 29 | testVm = 30 | let 31 | user = "alice"; 32 | uid = 1000; 33 | home = "/home/${user}"; 34 | 35 | # Testing related file paths 36 | covHtml = "${home}/hyprland-go.html"; 37 | covOut = "${home}/hyprland-go.out"; 38 | glxinfoOut = "${home}/glxinfo.out"; 39 | testFinished = "${home}/test-finished"; 40 | testLog = "${home}/test.log"; 41 | in 42 | pkgs.testers.runNixOSTest { 43 | name = "hyprland-go"; 44 | 45 | nodes.machine = 46 | { 47 | config, 48 | pkgs, 49 | lib, 50 | ... 51 | }: 52 | { 53 | boot.loader.systemd-boot.enable = true; 54 | boot.loader.efi.canTouchEfiVariables = true; 55 | 56 | programs.hyprland.enable = true; 57 | 58 | users.users.${user} = { 59 | inherit home uid; 60 | isNormalUser = true; 61 | }; 62 | 63 | environment.systemPackages = with pkgs; [ 64 | glxinfo # grab information about GPU 65 | gnome-themes-extra # used in SetCursor() test 66 | go 67 | kitty 68 | ]; 69 | 70 | services.getty.autologinUser = user; 71 | 72 | virtualisation.qemu = { 73 | options = [ 74 | "-smp 2" 75 | "-m 4G" 76 | "-vga none" 77 | "-device virtio-gpu-pci" 78 | # Works only in Wayland, may be slightly faster 79 | # "-device virtio-gpu-rutabaga,gfxstream-vulkan=on,cross-domain=on,hostmem=2G" 80 | ]; 81 | }; 82 | 83 | # Start hyprland at login 84 | programs.bash.loginShellInit = 85 | # bash 86 | let 87 | testScript = 88 | pkgs.writeShellScript "hyprland-go-test" 89 | # bash 90 | '' 91 | set -euo pipefail 92 | 93 | trap 'echo $? > ${testFinished}' EXIT 94 | 95 | glxinfo -B > ${glxinfoOut} || true 96 | cd ${./.} 97 | 98 | export NIX_CI=1 99 | go test -bench=. -coverprofile ${covOut} -v ./... > ${testLog} 2>&1 100 | go tool cover -html=${covOut} -o ${covHtml} 101 | ''; 102 | hyprlandConf = 103 | pkgs.writeText "hyprland.conf" 104 | # hyprlang 105 | '' 106 | bind = SUPER, Q, exec, kitty # Bind() test need at least one bind 107 | exec-once = kitty sh -c ${testScript} 108 | animations { 109 | # slow, and nobody is looking anyway 110 | enabled = false 111 | } 112 | decoration { 113 | # slow, and nobody is looking anyway 114 | blur { 115 | enabled = false 116 | } 117 | } 118 | cursor { 119 | # improve cursor in VM 120 | no_hardware_cursors = true 121 | } 122 | ''; 123 | in 124 | # bash 125 | '' 126 | if [ "$(tty)" = "/dev/tty1" ]; then 127 | Hyprland --config ${hyprlandConf} 128 | fi 129 | ''; 130 | }; 131 | 132 | testScript = # python 133 | '' 134 | start_all() 135 | 136 | machine.wait_for_unit("multi-user.target") 137 | machine.wait_for_file("${testFinished}") 138 | 139 | print(machine.succeed("cat ${glxinfoOut} || true")) 140 | print(machine.succeed("cat ${testLog}")) 141 | print(machine.succeed("exit $(cat ${testFinished})")) 142 | 143 | machine.copy_from_vm("${covOut}") 144 | machine.copy_from_vm("${covHtml}") 145 | ''; 146 | }; 147 | } 148 | ); 149 | 150 | packages = forAllSystems ( 151 | system: 152 | let 153 | pkgs = nixpkgsFor.${system}; 154 | in 155 | rec { 156 | default = hyprland-go; 157 | hyprland-go = pkgs.buildGoModule { 158 | pname = "hyprland-go"; 159 | version = self.shortRev or "dirty"; 160 | 161 | src = ./.; 162 | 163 | subPackages = [ 164 | "examples/events" 165 | "examples/hyprctl" 166 | "examples/hyprtabs" 167 | ]; 168 | 169 | vendorHash = null; 170 | }; 171 | } 172 | ); 173 | }; 174 | } 175 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thiagokokada/hyprland-go 2 | 3 | retract v0.0.3 // Published too soon without proper event API consideration. 4 | 5 | go 1.21 6 | -------------------------------------------------------------------------------- /helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | ) 10 | 11 | // Returned if HYPRLAND_INSTANCE_SIGNATURE is empty. 12 | var ErrEmptyHis = errors.New("HYPRLAND_INSTANCE_SIGNATURE is empty") 13 | 14 | // Returns a Hyprland socket path. 15 | func GetSocket(socket Socket) (string, error) { 16 | his := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") 17 | if his == "" { 18 | return "", fmt.Errorf("%w, are you using Hyprland?", ErrEmptyHis) 19 | } 20 | 21 | // https://github.com/hyprwm/Hyprland/blob/83a5395eaa99fecef777827fff1de486c06b6180/hyprctl/main.cpp#L53-L62 22 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") 23 | 24 | u, err := user.Current() 25 | if err != nil { 26 | return "", fmt.Errorf("error while getting the current user: %w", err) 27 | } 28 | 29 | if runtimeDir == "" { 30 | user := u.Uid 31 | runtimeDir = filepath.Join("/run/user", user) 32 | } 33 | 34 | return filepath.Join(runtimeDir, "hypr", his, string(socket)), nil 35 | } 36 | -------------------------------------------------------------------------------- /helpers/helpers_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "os/user" 5 | "testing" 6 | 7 | "github.com/thiagokokada/hyprland-go/internal/assert" 8 | ) 9 | 10 | func TestGetSocketWithXdg(t *testing.T) { 11 | t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "foo") 12 | t.Setenv("XDG_RUNTIME_DIR", "/xdg") 13 | 14 | socket, err := GetSocket(RequestSocket) 15 | assert.NoError(t, err) 16 | assert.Equal(t, socket, "/xdg/hypr/foo/.socket.sock") 17 | 18 | socket, err = GetSocket(EventSocket) 19 | assert.NoError(t, err) 20 | assert.Equal(t, socket, "/xdg/hypr/foo/.socket2.sock") 21 | } 22 | 23 | func TestGetSocketWithoutXdg(t *testing.T) { 24 | t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "bar") 25 | t.Setenv("XDG_RUNTIME_DIR", "") 26 | 27 | u, err := user.Current() 28 | assert.NoError(t, err) 29 | 30 | socket, err := GetSocket(RequestSocket) 31 | assert.NoError(t, err) 32 | assert.Equal(t, socket, "/run/user/"+u.Uid+"/hypr/bar/.socket.sock") 33 | 34 | socket, err = GetSocket(EventSocket) 35 | assert.NoError(t, err) 36 | assert.Equal(t, socket, "/run/user/"+u.Uid+"/hypr/bar/.socket2.sock") 37 | } 38 | 39 | func TestGetSocketError(t *testing.T) { 40 | t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "") 41 | 42 | _, err := GetSocket(RequestSocket) 43 | assert.Error(t, err) 44 | } 45 | -------------------------------------------------------------------------------- /helpers/helpers_type.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | type Socket string 4 | 5 | const ( 6 | EventSocket Socket = ".socket2.sock" 7 | RequestSocket Socket = ".socket.sock" 8 | ) 9 | -------------------------------------------------------------------------------- /internal/assert/assert.go: -------------------------------------------------------------------------------- 1 | package assert 2 | 3 | import ( 4 | "cmp" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func Must1[T any](v T, err error) T { 10 | Must(err) 11 | 12 | return v 13 | } 14 | 15 | func Must(err error) { 16 | if err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | func Error(t *testing.T, err error) { 22 | t.Helper() 23 | 24 | if err == nil { 25 | t.Errorf("got: %#v, want: !nil", err) 26 | } 27 | } 28 | 29 | func NoError(t *testing.T, err error) { 30 | t.Helper() 31 | 32 | if err != nil { 33 | t.Errorf("got: %#v, want: nil", err) 34 | } 35 | } 36 | 37 | func DeepEqual(t *testing.T, got, want any) { 38 | t.Helper() 39 | 40 | if !reflect.DeepEqual(got, want) { 41 | t.Errorf("got: %#v, want: %#v", got, want) 42 | } 43 | } 44 | 45 | func DeepNotEqual(t *testing.T, got, want any) { 46 | t.Helper() 47 | 48 | if reflect.DeepEqual(got, want) { 49 | t.Errorf("got: %#v, want: !%#v", got, want) 50 | } 51 | } 52 | 53 | func Equal[T comparable](t *testing.T, got, want T) { 54 | t.Helper() 55 | 56 | if got != want { 57 | t.Errorf("got: %#v, want: %#v", got, want) 58 | } 59 | } 60 | 61 | func NotEqual[T comparable](t *testing.T, got, want T) { 62 | t.Helper() 63 | 64 | if got == want { 65 | t.Errorf("got: %#v, want: !%#v", got, want) 66 | } 67 | } 68 | 69 | func False(t *testing.T, got bool) { 70 | t.Helper() 71 | 72 | if got { 73 | t.Errorf("got: %#v, want: false", got) 74 | } 75 | } 76 | 77 | func True(t *testing.T, got bool) { 78 | t.Helper() 79 | 80 | if !got { 81 | t.Errorf("got: %#v, want: true", got) 82 | } 83 | } 84 | 85 | func GreaterOrEqual[T cmp.Ordered](t *testing.T, got, want T) { 86 | t.Helper() 87 | 88 | if !(got >= want) { 89 | t.Errorf("got: %#v, want: >=%#v", got, want) 90 | } 91 | } 92 | 93 | func Greater[T cmp.Ordered](t *testing.T, got, want T) { 94 | t.Helper() 95 | 96 | if !(got > want) { 97 | t.Errorf("got: %#v, want: >%#v", got, want) 98 | } 99 | } 100 | 101 | func LessOrEqual[T cmp.Ordered](t *testing.T, got, want T) { 102 | t.Helper() 103 | 104 | if !(got <= want) { 105 | t.Errorf("got: %#v, want: <=%#v", got, want) 106 | } 107 | } 108 | 109 | func Less[T cmp.Ordered](t *testing.T, got, want T) { 110 | t.Helper() 111 | 112 | if !(got < want) { 113 | t.Errorf("got: %#v, want: <%#v", got, want) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package hyprland 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net" 11 | "strings" 12 | 13 | "github.com/thiagokokada/hyprland-go/helpers" 14 | "github.com/thiagokokada/hyprland-go/internal/assert" 15 | ) 16 | 17 | var ( 18 | // Returned when the request command is too long, e.g., would not fit 19 | // the request buffer. 20 | ErrCommandTooLong = errors.New("command is too long") 21 | // Returned when the request is empty. 22 | ErrEmptyRequest = errors.New("empty request") 23 | // Returned when Hyprland returns an empty response. 24 | ErrEmptyResponse = errors.New("empty response") 25 | // Returned when the request is too big, e.g., would not fit the 26 | // request buffer. 27 | ErrRequestTooBig = errors.New("request too big") 28 | ) 29 | 30 | // Initiate a new client or panic. 31 | // This should be the preferred method for user scripts, since it will 32 | // automatically find the proper socket to connect and use the 33 | // HYPRLAND_INSTANCE_SIGNATURE for the current user. 34 | // If you need to connect to arbitrary user instances or need a method that 35 | // will not panic on error, use [NewClient] instead. 36 | func MustClient() *RequestClient { 37 | return NewClient( 38 | assert.Must1(helpers.GetSocket(helpers.RequestSocket)), 39 | ) 40 | } 41 | 42 | // Initiate a new client. 43 | // Receive as parameters a requestSocket that is generally localised in 44 | // '$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock'. 45 | func NewClient(socket string) *RequestClient { 46 | return &RequestClient{ 47 | conn: &net.UnixAddr{ 48 | Net: "unix", 49 | Name: socket, 50 | }, 51 | } 52 | } 53 | 54 | // Low-level request method, should be avoided unless there is no alternative. 55 | // Receives a byte array as parameter that should be a valid command similar to 56 | // 'hyprctl' command, e.g.: 'hyprctl dispatch exec kitty' will be 57 | // '[]byte("dispatch exec kitty")'. 58 | // Keep in mind that there is no validation. In case of an invalid request, the 59 | // response will generally be something different from "ok". 60 | func (c *RequestClient) RawRequest(request RawRequest) (response RawResponse, err error) { 61 | if len(request) == 0 { 62 | return nil, ErrEmptyRequest 63 | } 64 | 65 | // Connect to the request socket 66 | conn, err := net.DialUnix("unix", nil, c.conn) 67 | if err != nil { 68 | return nil, fmt.Errorf("error while connecting to socket: %w", err) 69 | } 70 | 71 | defer func() { 72 | if e := conn.Close(); e != nil { 73 | err = errors.Join(err, fmt.Errorf("error while closing socket: %w", e)) 74 | } 75 | }() 76 | 77 | if len(request) > bufSize { 78 | return nil, fmt.Errorf( 79 | "%w (%d>%d): %s", 80 | ErrRequestTooBig, 81 | len(request), 82 | bufSize, 83 | request, 84 | ) 85 | } 86 | 87 | writer := bufio.NewWriter(conn) 88 | 89 | // Send the request to the socket 90 | _, err = writer.Write(request) 91 | if err != nil { 92 | return nil, fmt.Errorf("error while writing to socket: %w", err) 93 | } 94 | 95 | err = writer.Flush() 96 | if err != nil { 97 | return nil, fmt.Errorf("error while flushing to socket: %w", err) 98 | } 99 | 100 | // Get the response back 101 | rbuf := bytes.NewBuffer(nil) 102 | sbuf := make([]byte, bufSize) 103 | reader := bufio.NewReader(conn) 104 | 105 | for { 106 | n, err := reader.Read(sbuf) 107 | if err != nil { 108 | if err == io.EOF { 109 | break 110 | } 111 | 112 | return nil, fmt.Errorf("error while reading from socket: %w", err) 113 | } 114 | 115 | rbuf.Write(sbuf[:n]) 116 | 117 | if n < bufSize { 118 | break 119 | } 120 | } 121 | 122 | return rbuf.Bytes(), err 123 | } 124 | 125 | // Active window command, similar to 'hyprctl activewindow'. 126 | // Returns a [Window] object. 127 | func (c *RequestClient) ActiveWindow() (w Window, err error) { 128 | response, err := c.doRequest("activewindow", nil, true) 129 | if err != nil { 130 | return w, err 131 | } 132 | 133 | return unmarshalResponse(response, &w) 134 | } 135 | 136 | // Get option command, similar to 'hyprctl activeworkspace'. 137 | // Returns a [Workspace] object. 138 | func (c *RequestClient) ActiveWorkspace() (w Workspace, err error) { 139 | response, err := c.doRequest("activeworkspace", nil, true) 140 | if err != nil { 141 | return w, err 142 | } 143 | 144 | return unmarshalResponse(response, &w) 145 | } 146 | 147 | // Animations command, similar to 'hyprctl animations'. 148 | // Returns a [Animation] object. 149 | func (c *RequestClient) Animations() (a [][]Animation, err error) { 150 | response, err := c.doRequest("animations", nil, true) 151 | if err != nil { 152 | return a, err 153 | } 154 | 155 | return unmarshalResponse(response, &a) 156 | } 157 | 158 | // Binds command, similar to 'hyprctl binds'. 159 | // Returns a [Bind] object. 160 | func (c *RequestClient) Binds() (b []Bind, err error) { 161 | response, err := c.doRequest("binds", nil, true) 162 | if err != nil { 163 | return b, err 164 | } 165 | 166 | return unmarshalResponse(response, &b) 167 | } 168 | 169 | // Clients command, similar to 'hyprctl clients'. 170 | // Returns a [Client] object. 171 | func (c *RequestClient) Clients() (cl []Client, err error) { 172 | response, err := c.doRequest("clients", nil, true) 173 | if err != nil { 174 | return cl, err 175 | } 176 | 177 | return unmarshalResponse(response, &cl) 178 | } 179 | 180 | // ConfigErrors command, similar to `hyprctl configerrors`. 181 | // Returns a [ConfigError] object. 182 | func (c *RequestClient) ConfigErrors() (ce []ConfigError, err error) { 183 | response, err := c.doRequest("configerrors", nil, true) 184 | if err != nil { 185 | return ce, err 186 | } 187 | 188 | return unmarshalResponse(response, &ce) 189 | } 190 | 191 | // Cursor position command, similar to 'hyprctl cursorpos'. 192 | // Returns a [CursorPos] object. 193 | func (c *RequestClient) CursorPos() (cu CursorPos, err error) { 194 | response, err := c.doRequest("cursorpos", nil, true) 195 | if err != nil { 196 | return cu, err 197 | } 198 | 199 | return unmarshalResponse(response, &cu) 200 | } 201 | 202 | // Decorations command, similar to `hyprctl decorations`. 203 | // Returns a [Decoration] object. 204 | func (c *RequestClient) Decorations(regex string) (d []Decoration, err error) { 205 | response, err := c.doRequest("decorations", []string{regex}, true) 206 | if err != nil { 207 | return d, err 208 | } 209 | // XXX: when no decoration is set, we get "none". 210 | // Is this something that also happen in other commands? 211 | if string(response) == "none" { 212 | return nil, nil 213 | } 214 | 215 | return unmarshalResponse(response, &d) 216 | } 217 | 218 | // Devices command, similar to `hyprctl devices`. 219 | // Returns a [Devices] object. 220 | func (c *RequestClient) Devices() (d Devices, err error) { 221 | response, err := c.doRequest("devices", nil, true) 222 | if err != nil { 223 | return d, err 224 | } 225 | 226 | return unmarshalResponse(response, &d) 227 | } 228 | 229 | // Dispatch commands, similar to 'hyprctl dispatch'. 230 | // Accept multiple commands at the same time, in this case it will use batch 231 | // mode, similar to 'hyprctl dispatch --batch'. 232 | // Returns a [Response] list for each parameter, that may be useful for further 233 | // validations. 234 | func (c *RequestClient) Dispatch(params ...string) (r []Response, err error) { 235 | raw, err := c.doRequest("dispatch", params, false) 236 | if err != nil { 237 | return r, err 238 | } 239 | 240 | return parseAndValidateResponse(params, raw) 241 | } 242 | 243 | // Get option command, similar to 'hyprctl getoption'. 244 | // Returns an [Option] object. 245 | func (c *RequestClient) GetOption(name string) (o Option, err error) { 246 | response, err := c.doRequest("getoption", []string{name}, true) 247 | if err != nil { 248 | return o, err 249 | } 250 | 251 | return unmarshalResponse(response, &o) 252 | } 253 | 254 | // Keyword command, similar to 'hyprctl keyword'. 255 | // Accept multiple commands at the same time, in this case it will use batch 256 | // mode, similar to 'hyprctl keyword --batch'. 257 | // Returns a [Response] list for each parameter, that may be useful for further 258 | // validations. 259 | func (c *RequestClient) Keyword(params ...string) (r []Response, err error) { 260 | raw, err := c.doRequest("keyword", params, false) 261 | if err != nil { 262 | return r, err 263 | } 264 | 265 | return parseAndValidateResponse(params, raw) 266 | } 267 | 268 | // Kill command, similar to 'hyprctl kill'. 269 | // Kill an app by clicking on it, can exit with ESCAPE. Will NOT wait until the 270 | // user to click in the window. 271 | // Returns a [Response], that may be useful for further validations. 272 | func (c *RequestClient) Kill() (r Response, err error) { 273 | raw, err := c.doRequest("kill", nil, true) 274 | if err != nil { 275 | return r, err 276 | } 277 | 278 | response, err := parseAndValidateResponse(nil, raw) 279 | 280 | return response[0], err // should return only one response 281 | } 282 | 283 | // Layer command, similar to 'hyprctl layers'. 284 | // Returns a [Layer] object. 285 | func (c *RequestClient) Layers() (l Layers, err error) { 286 | response, err := c.doRequest("layers", nil, true) 287 | if err != nil { 288 | return l, err 289 | } 290 | 291 | return unmarshalResponse(response, &l) 292 | } 293 | 294 | // Monitors command, similar to 'hyprctl monitors'. 295 | // Returns a [Monitor] object. 296 | func (c *RequestClient) Monitors() (m []Monitor, err error) { 297 | response, err := c.doRequest("monitors all", nil, true) 298 | if err != nil { 299 | return m, err 300 | } 301 | 302 | return unmarshalResponse(response, &m) 303 | } 304 | 305 | // Reload command, similar to 'hyprctl reload'. 306 | // Returns a [Response], that may be useful for further validations. 307 | func (c *RequestClient) Reload() (r Response, err error) { 308 | raw, err := c.doRequest("reload", nil, false) 309 | if err != nil { 310 | return r, err 311 | } 312 | 313 | response, err := parseAndValidateResponse(nil, raw) 314 | 315 | return response[0], err // should return only one response 316 | } 317 | 318 | // Set cursor command, similar to 'hyprctl setcursor'. 319 | // Returns a [Response], that may be useful for further validations. 320 | func (c *RequestClient) SetCursor(theme string, size int) (r Response, err error) { 321 | raw, err := c.doRequest("setcursor", []string{fmt.Sprintf("%s %d", theme, size)}, false) 322 | if err != nil { 323 | return r, err 324 | } 325 | 326 | response, err := parseAndValidateResponse(nil, raw) 327 | 328 | return response[0], err // should return only one response 329 | } 330 | 331 | // Set cursor command, similar to 'hyprctl switchxkblayout'. 332 | // Returns a [Response], that may be useful for further validations. 333 | // Param cmd can be either 'next', 'prev' or an ID (e.g: 0). 334 | func (c *RequestClient) SwitchXkbLayout(device string, cmd string) (r Response, err error) { 335 | raw, err := c.doRequest("switchxkblayout", []string{fmt.Sprintf("%s %s", device, cmd)}, false) 336 | if err != nil { 337 | return r, err 338 | } 339 | 340 | response, err := parseAndValidateResponse(nil, raw) 341 | 342 | return response[0], err // should return only one response 343 | } 344 | 345 | // Splash command, similar to 'hyprctl splash'. 346 | func (c *RequestClient) Splash() (s string, err error) { 347 | response, err := c.doRequest("splash", nil, false) 348 | if err != nil { 349 | return s, err 350 | } 351 | 352 | return string(response), nil 353 | } 354 | 355 | // Version command, similar to 'hyprctl version'. 356 | // Returns a [Version] object. 357 | func (c *RequestClient) Version() (v Version, err error) { 358 | response, err := c.doRequest("version", nil, true) 359 | if err != nil { 360 | return v, err 361 | } 362 | 363 | return unmarshalResponse(response, &v) 364 | } 365 | 366 | // Workspaces option command, similar to 'hyprctl workspaces'. 367 | // Returns a [Workspace] object. 368 | func (c *RequestClient) Workspaces() (w []Workspace, err error) { 369 | response, err := c.doRequest("workspaces", nil, true) 370 | if err != nil { 371 | return w, err 372 | } 373 | 374 | return unmarshalResponse(response, &w) 375 | } 376 | 377 | const ( 378 | // https://github.com/hyprwm/Hyprland/blob/918d8340afd652b011b937d29d5eea0be08467f5/hyprctl/main.cpp#L278 379 | batch = "[[BATCH]]" 380 | // https://github.com/hyprwm/Hyprland/blob/918d8340afd652b011b937d29d5eea0be08467f5/hyprctl/main.cpp#L257 381 | bufSize = 8192 382 | ) 383 | 384 | var ( 385 | jsonReqHeader = []byte{'j', '/'} 386 | reqSep = []byte{' ', ';'} 387 | ) 388 | 389 | func prepareRequest(buf *bytes.Buffer, command string, param string, jsonResp bool) int { 390 | if jsonResp { 391 | buf.Write(jsonReqHeader) 392 | } 393 | 394 | buf.WriteString(command) 395 | buf.WriteByte(reqSep[0]) 396 | buf.WriteString(param) 397 | buf.WriteByte(reqSep[1]) 398 | 399 | return buf.Len() 400 | } 401 | 402 | func prepareRequests(command string, params []string, jsonResp bool) (requests []RawRequest, err error) { 403 | if command == "" { 404 | // Panic since this is not supposed to happen, i.e.: only by 405 | // misuse since this function is internal 406 | panic("empty command") 407 | } 408 | 409 | // Buffer that will store the temporary prepared request 410 | buf := bytes.NewBuffer(nil) 411 | bufErr := func() error { 412 | return fmt.Errorf( 413 | "%w (%d>=%d): %s", 414 | ErrCommandTooLong, 415 | buf.Len(), 416 | bufSize, 417 | buf.String(), 418 | ) 419 | } 420 | 421 | switch len(params) { 422 | case 0: 423 | if jsonResp { 424 | buf.Write(jsonReqHeader) 425 | } 426 | 427 | buf.WriteString(command) 428 | 429 | if buf.Len() > bufSize { 430 | return nil, bufErr() 431 | } 432 | case 1: 433 | if jsonResp { 434 | buf.Write(jsonReqHeader) 435 | } 436 | 437 | buf.WriteString(command) 438 | buf.WriteByte(reqSep[0]) 439 | buf.WriteString(params[0]) 440 | 441 | if buf.Len() > bufSize { 442 | return nil, bufErr() 443 | } 444 | default: 445 | // Add [[BATCH]] to the buffer 446 | buf.WriteString(batch) 447 | // Initialise current length of buffer 448 | curLen := buf.Len() 449 | 450 | for _, param := range params { 451 | // Get the current command + param length + request 452 | // header and separators 453 | cmdLen := len(command) + len(param) + len(reqSep) 454 | if jsonResp { 455 | cmdLen += len(jsonReqHeader) 456 | } 457 | 458 | // If batch + command length is bigger than bufSize, 459 | // return an error since it will not fit the socket 460 | if len(batch)+cmdLen > bufSize { 461 | // Call prepare request for error 462 | prepareRequest(buf, command, param, jsonResp) 463 | 464 | return nil, bufErr() 465 | } 466 | 467 | // If the current length of the buffer + command + 468 | // param is bigger than bufSize, we will need to split 469 | // the request 470 | if curLen+cmdLen > bufSize { 471 | // Append current buffer contents to the 472 | // requests array 473 | requests = append(requests, buf.Bytes()) 474 | 475 | // Reset the current buffer and add [[BATCH]] 476 | buf.Reset() 477 | buf.WriteString(batch) 478 | } 479 | 480 | // Add the contents of the request to the buffer 481 | curLen = prepareRequest(buf, command, param, jsonResp) 482 | } 483 | } 484 | // Append any remaining buffer content to requests array 485 | requests = append(requests, buf.Bytes()) 486 | 487 | return requests, nil 488 | } 489 | 490 | func parseResponse(raw RawResponse) (response []Response, err error) { 491 | reader := bufio.NewReader(bytes.NewReader(raw)) 492 | scanner := bufio.NewScanner(reader) 493 | scanner.Split(bufio.ScanLines) 494 | 495 | for scanner.Scan() { 496 | resp := strings.TrimSpace(scanner.Text()) 497 | if resp == "" { 498 | continue 499 | } 500 | 501 | response = append(response, Response(resp)) 502 | } 503 | 504 | if err := scanner.Err(); err != nil { 505 | return response, err 506 | } 507 | 508 | return response, nil 509 | } 510 | 511 | func validateResponse(params []string, response []Response) ([]Response, error) { 512 | // Empty response, something went terrible wrong 513 | if len(response) == 0 { 514 | return []Response{}, fmt.Errorf("%w: empty response", ErrValidation) 515 | } 516 | 517 | // commands without parameters will have at least one return 518 | want := max(len(params), 1) 519 | 520 | // we have a different number of requests and responses 521 | if want != len(response) { 522 | return response, fmt.Errorf( 523 | "%w: want responses: %d, got: %d, responses: %v", 524 | ErrValidation, 525 | want, 526 | len(response), 527 | response, 528 | ) 529 | } 530 | 531 | // validate that all responses are ok 532 | for i, r := range response { 533 | if r != "ok" { 534 | return response, fmt.Errorf( 535 | "%w: non-ok response from param: %s, response: %s", 536 | ErrValidation, 537 | params[i], 538 | r, 539 | ) 540 | } 541 | } 542 | 543 | return response, nil 544 | } 545 | 546 | func parseAndValidateResponse(params []string, raw RawResponse) ([]Response, error) { 547 | response, err := parseResponse(raw) 548 | if err != nil { 549 | return response, err 550 | } 551 | 552 | return validateResponse(params, response) 553 | } 554 | 555 | func unmarshalResponse[T any](response RawResponse, v *T) (T, error) { 556 | if len(response) == 0 { 557 | return *v, ErrEmptyResponse 558 | } 559 | 560 | err := json.Unmarshal(response, &v) 561 | if err != nil { 562 | return *v, fmt.Errorf( 563 | "error while unmarshal: %w, response: %s", 564 | err, 565 | response, 566 | ) 567 | } 568 | 569 | return *v, nil 570 | } 571 | 572 | func (c *RequestClient) doRequest(command string, params []string, jsonResp bool) (response RawResponse, err error) { 573 | requests, err := prepareRequests(command, params, jsonResp) 574 | if err != nil { 575 | return nil, fmt.Errorf("error while preparing request: %w", err) 576 | } 577 | 578 | buf := bytes.NewBuffer(nil) 579 | 580 | for _, req := range requests { 581 | resp, err := c.RawRequest(req) 582 | if err != nil { 583 | return nil, fmt.Errorf("error while doing request: %w", err) 584 | } 585 | 586 | buf.Write(resp) 587 | } 588 | 589 | return buf.Bytes(), nil 590 | } 591 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package hyprland 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/thiagokokada/hyprland-go/internal/assert" 13 | ) 14 | 15 | var ( 16 | c *RequestClient 17 | reload = flag.Bool("reload", true, "reload configuration after tests end") 18 | ) 19 | 20 | func genParams(param string, n int) (params []string) { 21 | for i := 0; i < n; i++ { 22 | params = append(params, param) 23 | } 24 | 25 | return params 26 | } 27 | 28 | func checkEnvironment(t *testing.T) { 29 | t.Helper() 30 | 31 | if c == nil { 32 | t.Skip("HYPRLAND_INSTANCE_SIGNATURE not set, skipping test") 33 | } 34 | } 35 | 36 | func testCommandR(t *testing.T, command func() (Response, error)) { 37 | t.Helper() 38 | testCommand(t, command, "") 39 | } 40 | 41 | func testCommandRs(t *testing.T, command func() ([]Response, error)) { 42 | t.Helper() 43 | testCommand(t, command, []Response{}) 44 | } 45 | 46 | func testCommand[T any](t *testing.T, command func() (T, error), emptyValue any) { 47 | t.Helper() 48 | checkEnvironment(t) 49 | 50 | got, err := command() 51 | assert.NoError(t, err) 52 | assert.DeepNotEqual(t, got, emptyValue) 53 | t.Logf("got: %+v", got) 54 | } 55 | 56 | func setup() { 57 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { 58 | c = MustClient() 59 | } 60 | } 61 | 62 | func teardown() { 63 | if *reload && c != nil { 64 | // Make sure that the Hyprland config is in a sane state 65 | assert.Must1(c.Reload()) 66 | } 67 | } 68 | 69 | func TestMain(m *testing.M) { 70 | flag.Parse() 71 | 72 | setup() 73 | 74 | exitCode := m.Run() 75 | 76 | teardown() 77 | 78 | os.Exit(exitCode) 79 | } 80 | 81 | func TestPrepareRequests(t *testing.T) { 82 | // test params 83 | tests := []struct { 84 | command string 85 | params []string 86 | jsonResp bool 87 | expected []string 88 | }{ 89 | {"command", nil, true, []string{"j/command"}}, 90 | {"command", []string{"param0"}, true, []string{"j/command param0"}}, 91 | {"command", []string{"param0", "param1"}, true, []string{"[[BATCH]]j/command param0;j/command param1;"}}, 92 | {"command", nil, false, []string{"command"}}, 93 | {"command", []string{"param0"}, false, []string{"command param0"}}, 94 | {"command", []string{"param0", "param1"}, false, []string{"[[BATCH]]command param0;command param1;"}}, 95 | } 96 | for _, tt := range tests { 97 | t.Run(fmt.Sprintf("tests_%s-%s-%v", tt.command, tt.params, tt.jsonResp), func(t *testing.T) { 98 | requests, err := prepareRequests(tt.command, tt.params, tt.jsonResp) 99 | assert.NoError(t, err) 100 | 101 | for i, e := range tt.expected { 102 | assert.Equal(t, string(requests[i]), e) 103 | } 104 | }) 105 | } 106 | } 107 | 108 | func TestPrepareRequestsMass(t *testing.T) { 109 | // test massive amount of parameters 110 | tests := []struct { 111 | command string 112 | params []string 113 | expected int 114 | }{ 115 | {"command", genParams("very big param list", 1), 1}, 116 | {"command", genParams("very big param list", 50), 1}, 117 | {"command", genParams("very big param list", 100), 1}, 118 | {"command", genParams("very big param list", 500), 2}, 119 | {"command", genParams("very big param list", 1000), 4}, 120 | {"command", genParams("very big param list", 5000), 19}, 121 | {"command", genParams("very big param list", 10000), 37}, 122 | } 123 | for _, tt := range tests { 124 | t.Run(fmt.Sprintf("mass_tests_%s-%d", tt.command, len(tt.params)), func(t *testing.T) { 125 | requests, err := prepareRequests(tt.command, tt.params, true) 126 | assert.NoError(t, err) 127 | assert.Equal(t, len(requests), tt.expected) 128 | }) 129 | } 130 | } 131 | 132 | func TestPrepareRequestsError(t *testing.T) { 133 | tests := []struct { 134 | lastSafeLen int 135 | paramsLen int 136 | jsonResp bool 137 | }{ 138 | {bufSize - len(jsonReqHeader), 0, true}, // '/j' 139 | {bufSize - len(jsonReqHeader) - 2, 1, true}, // '/j p' 140 | {bufSize - len(jsonReqHeader) - len(batch) - len(reqSep) - 1, 2, true}, // '[[BATCH]]/j p;' 141 | {bufSize, 0, false}, // '' 142 | {bufSize - 2, 1, false}, // ' p' 143 | {bufSize - len(batch) - len(reqSep) - 1, 2, false}, // '[[BATCH]] p;' 144 | } 145 | for _, tt := range tests { 146 | t.Run(fmt.Sprintf("tests_%d-%d-%v", tt.lastSafeLen, tt.paramsLen, tt.jsonResp), func(t *testing.T) { 147 | // With last safe length, we should have no errors 148 | _, err := prepareRequests( 149 | strings.Repeat("c", tt.lastSafeLen), 150 | genParams("p", tt.paramsLen), 151 | tt.jsonResp, 152 | ) 153 | assert.NoError(t, err) 154 | // Now we should have errors 155 | _, err = prepareRequests( 156 | strings.Repeat("c", tt.lastSafeLen+1), 157 | genParams("p", tt.paramsLen), 158 | tt.jsonResp, 159 | ) 160 | assert.Error(t, err) 161 | }) 162 | } 163 | } 164 | 165 | func BenchmarkPrepareRequests(b *testing.B) { 166 | params := genParams("param", 10000) 167 | 168 | for i := 0; i < b.N; i++ { 169 | prepareRequests("command", params, true) 170 | } 171 | } 172 | 173 | func TestParseResponse(t *testing.T) { 174 | tests := []struct { 175 | response RawResponse 176 | want int 177 | }{ 178 | {RawResponse("ok"), 1}, 179 | {RawResponse(" ok "), 1}, 180 | {RawResponse("ok\r\nok"), 2}, 181 | {RawResponse(" \r\nok\r\n \r\n ok"), 2}, 182 | {RawResponse(strings.Repeat("ok\r\n", 5)), 5}, 183 | {RawResponse(strings.Repeat("ok\r\n\r\n", 5)), 5}, 184 | {RawResponse(strings.Repeat("ok\r\n\n", 10)), 10}, 185 | } 186 | for _, tt := range tests { 187 | t.Run(fmt.Sprintf("tests_%s-%d", tt.response, tt.want), func(t *testing.T) { 188 | response, err := parseResponse(tt.response) 189 | assert.NoError(t, err) 190 | assert.Equal(t, len(response), tt.want) 191 | 192 | for _, r := range response { 193 | assert.Equal(t, r, "ok") 194 | } 195 | }) 196 | } 197 | } 198 | 199 | func BenchmarkParseResponse(b *testing.B) { 200 | response := []byte(strings.Repeat("ok\r\n", 1000)) 201 | 202 | for i := 0; i < b.N; i++ { 203 | parseResponse(response) 204 | } 205 | } 206 | 207 | func TestValidateResponse(t *testing.T) { 208 | tests := []struct { 209 | params []string 210 | response []Response 211 | want []Response 212 | wantErr bool 213 | }{ 214 | // empty response should error 215 | {genParams("param", 1), []Response{}, []Response{}, true}, 216 | // happy path, nil param 217 | {nil, []Response{"ok"}, []Response{"ok"}, false}, 218 | // happy path, 1 param 219 | {genParams("param", 1), []Response{"ok"}, []Response{"ok"}, false}, 220 | // happy path, multiple params 221 | {genParams("param", 2), []Response{"ok", "ok"}, []Response{"ok", "ok"}, false}, 222 | // missing response 223 | {genParams("param", 2), []Response{"ok"}, []Response{"ok"}, true}, 224 | // non-ok response 225 | {genParams("param", 2), []Response{"ok", "Invalid command"}, []Response{"ok", "Invalid command"}, true}, 226 | } 227 | for _, tt := range tests { 228 | t.Run(fmt.Sprintf("tests_%v-%v", tt.params, tt.response), func(t *testing.T) { 229 | response, err := validateResponse(tt.params, tt.response) 230 | assert.DeepEqual(t, response, tt.want) 231 | 232 | if tt.wantErr { 233 | assert.Error(t, err) 234 | assert.True(t, errors.Is(err, ErrValidation)) 235 | } else { 236 | assert.NoError(t, err) 237 | } 238 | }) 239 | } 240 | } 241 | 242 | func TestRawRequest(t *testing.T) { 243 | testCommand(t, func() (RawResponse, error) { 244 | return c.RawRequest([]byte("splash")) 245 | }, RawResponse{}) 246 | } 247 | 248 | func TestActiveWindow(t *testing.T) { 249 | testCommand(t, c.ActiveWindow, Window{}) 250 | } 251 | 252 | func TestActiveWorkspace(t *testing.T) { 253 | testCommand(t, c.ActiveWorkspace, Workspace{}) 254 | } 255 | 256 | func TestAnimations(t *testing.T) { 257 | testCommand(t, c.Animations, [][]Animation{}) 258 | } 259 | 260 | func TestBinds(t *testing.T) { 261 | testCommand(t, c.Binds, []Bind{}) 262 | } 263 | 264 | func TestClients(t *testing.T) { 265 | testCommand(t, c.Clients, []Client{}) 266 | } 267 | 268 | func TestConfigErrors(t *testing.T) { 269 | testCommand(t, c.ConfigErrors, []ConfigError{}) 270 | } 271 | 272 | func TestCursorPos(t *testing.T) { 273 | if os.Getenv("NIX_CI") != "" { 274 | // https://github.com/NixOS/nixpkgs/issues/156067 275 | // https://github.com/hyprwm/Hyprland/discussions/1257 276 | t.Skip("skip test that always returns CursorPos{X:0, Y:0} in CI since we can't move cursor") 277 | } 278 | 279 | testCommand(t, c.CursorPos, CursorPos{}) 280 | } 281 | 282 | func TestDecorations(t *testing.T) { 283 | if testing.Short() { 284 | t.Skip("skip test that depends in kitty running") 285 | } 286 | 287 | testCommand(t, func() ([]Decoration, error) { 288 | return c.Decorations("kitty") 289 | }, []Decoration{}) 290 | } 291 | 292 | func TestDevices(t *testing.T) { 293 | testCommand(t, c.Devices, Devices{}) 294 | } 295 | 296 | func TestDispatch(t *testing.T) { 297 | testCommandRs(t, func() ([]Response, error) { 298 | return c.Dispatch("exec kitty sh -c 'echo Testing hyprland-go && sleep 1 && exit 0'") 299 | }) 300 | 301 | if testing.Short() { 302 | t.Skip("skip slow test") 303 | } 304 | // Testing if we can open at least the amount of instances we asked 305 | // Dispatch() to open. 306 | // The reason this test exist is because Hyprland has a hidden 307 | // limitation in the total amount of batch commands you can trigger, 308 | // but this is not documented and it also fails silently. 309 | // So this test allows us to validate that the current split of 310 | // batch commands is working as expected. 311 | // See also: prepareRequests function and MAX_COMMANDS const 312 | const want = 35 313 | 314 | const retries = 15 315 | 316 | t.Run(fmt.Sprintf("test_opening_%d_kitty_instances", want), func(t *testing.T) { 317 | _, err := c.Dispatch(genParams(fmt.Sprintf("exec kitty sh -c 'sleep %d && exit 0'", retries), want)...) 318 | assert.NoError(t, err) 319 | 320 | aw, err := c.ActiveWorkspace() 321 | assert.NoError(t, err) 322 | 323 | got := 0 324 | for i := 0; i < retries; i++ { 325 | got = 0 326 | 327 | time.Sleep(1 * time.Second) 328 | 329 | cls, err := c.Clients() 330 | assert.NoError(t, err) 331 | 332 | for _, cl := range cls { 333 | if cl.Workspace.Id == aw.Id && cl.Class == "kitty" { 334 | got += 1 335 | } 336 | } 337 | 338 | if got >= want { 339 | t.Logf("after retries: %d, got kitty: %d, finishing test", i+1, got) 340 | 341 | return 342 | } 343 | } 344 | // after maximum amount of retries, give up 345 | t.Errorf("after retries: %d, got kitty: %d, want at least: %d", retries, got, want) 346 | }) 347 | } 348 | 349 | func TestGetOption(t *testing.T) { 350 | tests := []struct{ option string }{ 351 | {"general:border_size"}, 352 | {"gestures:workspace_swipe"}, 353 | {"misc:vrr"}, 354 | {"cursor:zoom_factor"}, 355 | } 356 | for _, tt := range tests { 357 | t.Run(fmt.Sprintf("mass_tests_%v", tt.option), func(t *testing.T) { 358 | testCommand(t, func() (Option, error) { 359 | return c.GetOption(tt.option) 360 | }, Option{}) 361 | }) 362 | } 363 | } 364 | 365 | func TestKeyword(t *testing.T) { 366 | testCommandRs(t, func() ([]Response, error) { 367 | return c.Keyword("bind SUPER,K,exec,kitty", "general:border_size 5") 368 | }) 369 | } 370 | 371 | func TestKill(t *testing.T) { 372 | if testing.Short() { 373 | t.Skip("skip test that kill window") 374 | } 375 | 376 | testCommandR(t, c.Kill) 377 | } 378 | 379 | func TestLayers(t *testing.T) { 380 | testCommand(t, c.Layers, Layers{}) 381 | } 382 | 383 | func TestMonitors(t *testing.T) { 384 | testCommand(t, c.Monitors, []Monitor{}) 385 | } 386 | 387 | func TestReload(t *testing.T) { 388 | if testing.Short() { 389 | t.Skip("skip test that reload config") 390 | } 391 | 392 | testCommandR(t, c.Reload) 393 | } 394 | 395 | func TestSetCursor(t *testing.T) { 396 | testCommandR(t, func() (Response, error) { 397 | return c.SetCursor("Adwaita", 32) 398 | }) 399 | } 400 | 401 | func TestSwitchXkbLayout(t *testing.T) { 402 | // Need to find a keyboard, call Devices() 403 | checkEnvironment(t) 404 | 405 | devices, err := c.Devices() 406 | assert.NoError(t, err) 407 | 408 | testCommandR(t, func() (Response, error) { 409 | return c.SwitchXkbLayout(devices.Keyboards[0].Name, "next") 410 | }) 411 | } 412 | 413 | func TestSplash(t *testing.T) { 414 | testCommand(t, c.Splash, "") 415 | } 416 | 417 | func BenchmarkSplash(b *testing.B) { 418 | if c == nil { 419 | b.Skip("HYPRLAND_INSTANCE_SIGNATURE not set, skipping test") 420 | } 421 | 422 | for i := 0; i < b.N; i++ { 423 | c.Splash() 424 | } 425 | } 426 | 427 | func TestWorkspaces(t *testing.T) { 428 | testCommand(t, c.Workspaces, []Workspace{}) 429 | } 430 | 431 | func TestVersion(t *testing.T) { 432 | testCommand(t, c.Version, Version{}) 433 | 434 | if os.Getenv("NIX_CI") != "" { 435 | // make sure that we are running the same version of Hyprland 436 | // in NixOS VM test that we are declaring as compatible 437 | v, _ := c.Version() 438 | assert.Equal(t, v.Tag, "v"+HYPRLAND_VERSION) 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /request_types.go: -------------------------------------------------------------------------------- 1 | package hyprland 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | ) 7 | 8 | // Indicates the version where the structs are up-to-date. 9 | const HYPRLAND_VERSION = "0.47.2" 10 | 11 | // Represents a raw request that is passed for Hyprland's socket. 12 | type RawRequest []byte 13 | 14 | // Represents a raw response returned from the Hyprland's socket. 15 | type RawResponse []byte 16 | 17 | // Represents a parsed response returned from the Hyprland's socket. 18 | type Response string 19 | 20 | // RequestClient is the main struct from hyprland-go. 21 | type RequestClient struct { 22 | conn *net.UnixAddr 23 | } 24 | 25 | // ErrValidation is used to return errors from response validation. In some 26 | // cases you may want to ignore those errors, in this case you can use 27 | // [errors.Is] to compare the errors returned with this type. 28 | var ErrValidation = errors.New("validation error") 29 | 30 | // Unmarshal structs for requests. 31 | // Try to keep struct fields in the same order as the output for `hyprctl -j` 32 | // for sanity. 33 | 34 | type Animation struct { 35 | Name string `json:"name"` 36 | Overridden bool `json:"overridden"` 37 | Bezier string `json:"bezier"` 38 | Enabled bool `json:"enabled"` 39 | Speed float64 `json:"speed"` 40 | Style string `json:"style"` 41 | } 42 | 43 | type Bind struct { 44 | Locked bool `json:"locked"` 45 | Mouse bool `json:"mouse"` 46 | Release bool `json:"release"` 47 | Repeat bool `json:"repeat"` 48 | NonConsuming bool `json:"non_consuming"` 49 | HasDescription bool `json:"has_description"` 50 | ModMask int `json:"modmask"` 51 | SubMap string `json:"submap"` 52 | Key string `json:"key"` 53 | KeyCode int `json:"keycode"` 54 | CatchAll bool `json:"catch_all"` 55 | Description string `json:"description"` 56 | Dispatcher string `json:"dispatcher"` 57 | Arg string `json:"arg"` 58 | } 59 | 60 | type FullscreenState int 61 | 62 | const ( 63 | None FullscreenState = iota 64 | Maximized 65 | Fullscreen 66 | MaximizedFullscreen 67 | ) 68 | 69 | type Client struct { 70 | Address string `json:"address"` 71 | Mapped bool `json:"mapped"` 72 | Hidden bool `json:"hidden"` 73 | At []int `json:"at"` 74 | Size []int `json:"size"` 75 | Workspace WorkspaceType `json:"workspace"` 76 | Floating bool `json:"floating"` 77 | Pseudo bool `json:"pseudo"` 78 | Monitor int `json:"monitor"` 79 | Class string `json:"class"` 80 | Title string `json:"title"` 81 | InitialClass string `json:"initialClass"` 82 | InitialTitle string `json:"initialTitle"` 83 | Pid int `json:"pid"` 84 | Xwayland bool `json:"xwayland"` 85 | Pinned bool `json:"pinned"` 86 | Fullscreen FullscreenState `json:"fullscreen"` 87 | FullscreenClient FullscreenState `json:"fullscreenClient"` 88 | Grouped []string `json:"grouped"` 89 | Tags []string `json:"tags"` 90 | Swallowing string `json:"swallowing"` 91 | FocusHistoryId int `json:"focusHistoryID"` 92 | } 93 | 94 | type ConfigError string 95 | 96 | type CursorPos struct { 97 | X int `json:"x"` 98 | Y int `json:"y"` 99 | } 100 | 101 | type Decoration struct { 102 | DecorationName string `json:"decorationName"` 103 | Priority int `json:"priority"` 104 | } 105 | 106 | type Devices struct { 107 | Mice []struct { 108 | Address string `json:"address"` 109 | Name string `json:"name"` 110 | DefaultSpeed float64 `json:"defaultSpeed"` 111 | } `json:"mice"` 112 | Keyboards []struct { 113 | Address string `json:"address"` 114 | Name string `json:"name"` 115 | Rules string `json:"rules"` 116 | Model string `json:"model"` 117 | Layout string `json:"layout"` 118 | Variant string `json:"variant"` 119 | Options string `json:"options"` 120 | ActiveKeymap string `json:"active_keymap"` 121 | Main bool `json:"main"` 122 | } `json:"keyboards"` 123 | Tablets []interface{} `json:"tablets"` // TODO: need a tablet to test 124 | Touch []interface{} `json:"touch"` // TODO: need a touchscreen to test 125 | Switches []struct { 126 | Address string `json:"address"` 127 | Name string `json:"name"` 128 | } `json:"switches"` 129 | } 130 | 131 | type Output string 132 | 133 | type Layers map[Output]Layer 134 | 135 | type Layer struct { 136 | Levels map[int][]LayerField `json:"levels"` 137 | } 138 | 139 | type LayerField struct { 140 | Address string `json:"address"` 141 | X int `json:"x"` 142 | Y int `json:"y"` 143 | W int `json:"w"` 144 | H int `json:"h"` 145 | Namespace string `json:"namespace"` 146 | } 147 | 148 | type Monitor struct { 149 | Id int `json:"id"` 150 | Name string `json:"name"` 151 | Description string `json:"description"` 152 | Make string `json:"make"` 153 | Model string `json:"model"` 154 | Serial string `json:"serial"` 155 | Width int `json:"width"` 156 | Height int `json:"height"` 157 | RefreshRate float64 `json:"refreshRate"` 158 | X int `json:"x"` 159 | Y int `json:"y"` 160 | ActiveWorkspace WorkspaceType `json:"activeWorkspace"` 161 | SpecialWorkspace WorkspaceType `json:"specialWorkspace"` 162 | Reserved []int `json:"reserved"` 163 | Scale float64 `json:"scale"` 164 | Transform int `json:"transform"` 165 | Focused bool `json:"focused"` 166 | DpmsStatus bool `json:"dpmsStatus"` 167 | Vrr bool `json:"vrr"` 168 | ActivelyTearing bool `json:"activelyTearing"` 169 | CurrentFormat string `json:"currentFormat"` 170 | AvailableModes []string `json:"availableModes"` 171 | } 172 | 173 | type Option struct { 174 | Option string `json:"option"` 175 | Int int `json:"int"` 176 | Float float64 `json:"float"` 177 | Set bool `json:"set"` 178 | } 179 | 180 | type Version struct { 181 | Branch string `json:"branch"` 182 | Commit string `json:"commit"` 183 | Dirty bool `json:"dirty"` 184 | CommitMessage string `json:"commit_message"` 185 | CommitDate string `json:"commit_date"` 186 | Tag string `json:"tag"` 187 | Commits string `json:"commits"` 188 | Flags []string `json:"flags"` 189 | } 190 | 191 | type Window struct { 192 | Client 193 | } 194 | 195 | type Workspace struct { 196 | WorkspaceType 197 | Monitor string `json:"monitor"` 198 | MonitorID int `json:"monitorID"` 199 | Windows int `json:"windows"` 200 | HasFullScreen bool `json:"hasfullscreen"` 201 | LastWindow string `json:"lastwindow"` 202 | LastWindowTitle string `json:"lastwindowtitle"` 203 | } 204 | 205 | type WorkspaceType struct { 206 | Id int `json:"id"` 207 | Name string `json:"name"` 208 | } 209 | --------------------------------------------------------------------------------