├── .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 | [](https://pkg.go.dev/github.com/thiagokokada/hyprland-go)
4 | [](https://github.com/thiagokokada/hyprland-go/actions/workflows/go.yml)
5 | [](https://github.com/thiagokokada/hyprland-go/actions/workflows/nix.yaml)
6 | [](https://github.com/hyprwm/Hyprland)
7 | [](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 |
--------------------------------------------------------------------------------