Compare commits

..

No commits in common. "v0.0.4" and "main" have entirely different histories.
v0.0.4 ... main

17 changed files with 417 additions and 202 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
*_test.go
*_test.go

41
check_reachable.go Normal file
View File

@ -0,0 +1,41 @@
package hypr
import (
"context"
"strings"
"time"
)
// IsReachable pings the unix socket to check
// if Hyprland is ready to accept incoming requests
func (c *Client) IsReachable() bool {
str, err := readSocketString(c.SocketPath(), strings.NewReader("dispatch exec echo"))
if err != nil {
return false
}
return str == "ok"
}
// WaitUntilReachable periodically calls IsReachable
// and returns true as soon as IsReachable returns true.
// When ctx is closed before Hyprland is reachable, false is returned
func (c *Client) WaitUntilReachable(ctx context.Context) bool {
if c.IsReachable() {
return true
}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if c.IsReachable() {
return true
}
case <-ctx.Done():
return false
}
}
}

24
dispatch.go Normal file
View File

@ -0,0 +1,24 @@
package hypr
import (
"fmt"
"io"
"strings"
)
func (c *Client) Dispatch(cmd string) (io.ReadCloser, error) {
return readSocketRaw(c.SocketPath(), strings.NewReader(fmt.Sprintf("dispatch %s", cmd)))
}
func (c *Client) DispatchExpectOK(cmd string) error {
str, err := readSocketString(c.SocketPath(), strings.NewReader(fmt.Sprintf("dispatch %s", cmd)))
if err != nil {
return err
}
if strings.ToLower(strings.TrimSpace(str)) != "ok" {
return fmt.Errorf("dispatcher '%s' returned an error: %s", cmd, str)
}
return nil
}

View File

@ -1,36 +1,104 @@
package hypr
import (
"encoding/json"
"os/exec"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strconv"
"strings"
"git.milar.in/milarin/slices"
)
func GetWindows() ([]*Window, error) {
cmd := exec.Command("hyprctl", "-j", "clients")
func GetClient(signature string) (*Client, error) {
lockFilePath := fmt.Sprintf("/run/user/1000/hypr/%s/hyprland.lock", signature)
stdout, err := cmd.StdoutPipe()
file, err := os.Open(lockFilePath)
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
lines := strings.Split(string(data), "\n")
pid, err := strconv.Atoi(lines[0])
if err != nil {
return nil, err
}
windows := make([]*Window, 0, 5)
if err := json.NewDecoder(stdout).Decode(&windows); err != nil {
return &Client{
Signature: signature,
PID: pid,
WaylandSocket: lines[1],
}, nil
}
func GetDefaultClient() (*Client, error) {
signature, ok := os.LookupEnv("HYPRLAND_INSTANCE_SIGNATURE")
if !ok {
return nil, errors.New("default instance not found because HYPRLAND_INSTANCE_SIGNATURE is not set")
}
return GetClient(signature)
}
func GetClients() ([]*Client, error) {
entries, err := os.ReadDir("/run/user/1000/hypr")
if err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
return nil, err
entries = slices.Filter(entries, fs.DirEntry.IsDir)
clients := make([]*Client, 0, len(entries))
for _, entry := range entries {
client, err := GetClient(entry.Name())
if err != nil {
fmt.Println(err)
continue
}
clients = append(clients, client)
}
windows = slices.Filter(windows, func(w *Window) bool {
return w.PID != -1
return clients, nil
}
func WaitForDefaultClient(ctx context.Context) (*Client, error) {
signature, ok := os.LookupEnv("HYPRLAND_INSTANCE_SIGNATURE")
if !ok {
return nil, errors.New("default instance not found because HYPRLAND_INSTANCE_SIGNATURE is not set")
}
return WaitForClient(ctx, signature)
}
func WaitForClient(ctx context.Context, signature string) (*Client, error) {
lockFilePath := fmt.Sprintf("/run/user/1000/hypr/%s/hyprland.lock", signature)
lockFileExists := waitFor(ctx, func() bool {
_, err := os.Stat(lockFilePath)
return !errors.Is(err, os.ErrNotExist)
})
return windows, nil
if !lockFileExists {
return nil, errors.New("hyprland lock file not found")
}
client, err := GetClient(signature)
if err != nil {
return nil, err
}
if !client.WaitUntilReachable(context.Background()) {
return nil, errors.New("hyprland not reachable")
}
return client, nil
}

View File

@ -1,46 +0,0 @@
package hypr
import (
"bufio"
"context"
"net"
"os"
"strings"
"git.milar.in/milarin/channel"
"git.milar.in/milarin/slices"
)
func GetEventChannel(ctx context.Context, types ...EventType) (<-chan Event, error) {
conn, err := net.Dial("unix", os.ExpandEnv("/tmp/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"))
if err != nil {
return nil, err
}
out := make(chan Event, 10)
go func() {
defer conn.Close()
<-ctx.Done()
}()
go func() {
defer close(out)
defer conn.Close()
sc := bufio.NewScanner(conn)
for sc.Scan() {
out <- parseEvent(sc.Text())
}
}()
if len(types) == 0 {
return out, nil
}
typeMap := slices.ToStructMap(slices.Map(types, strings.ToLower))
return channel.Filter(out, func(e Event) bool {
_, ok := typeMap[e.Type]
return ok
}), nil
}

View File

@ -1,30 +0,0 @@
package hypr
import (
"encoding/json"
"os/exec"
)
func GetMonitors() ([]*Monitor, error) {
cmd := exec.Command("hyprctl", "-j", "monitors")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
monitors := make([]*Monitor, 0, 5)
if err := json.NewDecoder(stdout).Decode(&monitors); err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
return nil, err
}
return monitors, nil
}

View File

@ -1,54 +0,0 @@
package hypr
import (
"encoding/json"
"os/exec"
)
func GetWorkspaces() ([]*Workspace, error) {
cmd := exec.Command("hyprctl", "-j", "workspaces")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
workspaces := make([]*Workspace, 0, 5)
if err := json.NewDecoder(stdout).Decode(&workspaces); err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
return nil, err
}
return workspaces, nil
}
func GetActiveWorkspace() (*Workspace, error) {
cmd := exec.Command("hyprctl", "-j", "activeworkspace")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
workspace := &Workspace{}
if err := json.NewDecoder(stdout).Decode(workspace); err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
return nil, err
}
return workspace, nil
}

33
getters.go Normal file
View File

@ -0,0 +1,33 @@
package hypr
import (
"strings"
)
func (i *Client) GetActiveWindow() (*Window, error) {
return readSocket[*Window](i.SocketPath(), strings.NewReader("j/activewindow"))
}
func (i *Client) GetActiveWorkspace() (*Workspace, error) {
return readSocket[*Workspace](i.SocketPath(), strings.NewReader("j/activeworkspace"))
}
func (i *Client) GetBinds() ([]*Bind, error) {
return readSocket[[]*Bind](i.SocketPath(), strings.NewReader("j/binds"))
}
func (i *Client) GetWindows() ([]*Window, error) {
return readSocket[[]*Window](i.SocketPath(), strings.NewReader("j/clients"))
}
func (i *Client) GetCursorPos() (Point, error) {
return readSocket[Point](i.SocketPath(), strings.NewReader("j/cursorpos"))
}
func (i *Client) GetMonitors() ([]*Monitor, error) {
return readSocket[[]*Monitor](i.SocketPath(), strings.NewReader("j/monitors"))
}
func (i *Client) GetWorkspaces() ([]*Workspace, error) {
return readSocket[[]*Workspace](i.SocketPath(), strings.NewReader("j/workspaces"))
}

11
go.mod
View File

@ -1,10 +1,7 @@
module git.milar.in/milarin/hypr
module git.milar.in/hyprland-tools/hypr
go 1.21.5
go 1.21.6
require (
git.milar.in/milarin/channel v0.1.1
git.milar.in/milarin/slices v0.0.8
)
require git.milar.in/milarin/slices v0.0.8
require git.milar.in/milarin/gmath v0.0.5 // indirect
require git.milar.in/milarin/gmath v0.0.3 // indirect

6
go.sum
View File

@ -1,6 +1,4 @@
git.milar.in/milarin/channel v0.1.1 h1:s8+BdiOMmuRUDmChQ2i4G5GWsDCK9tKNHt1knLJx9zM=
git.milar.in/milarin/channel v0.1.1/go.mod h1:We83LTI8S7u7II3pD+A2ChCDWJfCkcBUCUqii9HjTtM=
git.milar.in/milarin/gmath v0.0.5 h1:qQQMUTbxEk5LriMMSRbElExDSouSJKYBo6zRcOYKVIU=
git.milar.in/milarin/gmath v0.0.5/go.mod h1:HDLftG5RLpiNGKiIWh+O2G1PYkNzyLDADO8Cd/1abiE=
git.milar.in/milarin/gmath v0.0.3 h1:ii6rKNItS55O/wtIFhD1cTN2BMwDZjTBmiOocKURvxM=
git.milar.in/milarin/gmath v0.0.3/go.mod h1:HDLftG5RLpiNGKiIWh+O2G1PYkNzyLDADO8Cd/1abiE=
git.milar.in/milarin/slices v0.0.8 h1:qN9TE3tkArdTixMKSnwvNPcApwAjxpLVwA5a9k1rm2s=
git.milar.in/milarin/slices v0.0.8/go.mod h1:qMhdtMnfWswc1rHpwgNw33lB84aNEkdBn5BDiYA+G3k=

22
model_bind.go Normal file
View File

@ -0,0 +1,22 @@
package hypr
import "encoding/json"
type Bind struct {
Locked bool `json:"locked"`
Mouse bool `json:"mouse"`
Release bool `json:"release"`
Repeat bool `json:"repeat"`
NonConsuming bool `json:"non_consuming"`
ModMask int `json:"modmask"`
Submap string `json:"submap"`
Key string `json:"key"`
KeyCode int `json:"keycode"`
Dispatcher string `json:"dispatcher"`
Arg string `json:"arg"`
}
func (b Bind) String() string {
data, _ := json.MarshalIndent(b, "", "\t")
return string(data)
}

View File

@ -27,38 +27,44 @@ type EventType = string
const (
EventTypeTick EventType = "tick"
EventTypeActiveWindow EventType = "activeWindow"
EventTypeActiveWindowV2 EventType = "activeWindowV2"
EventTypeKeyboardFocus EventType = "keyboardFocus"
EventTypeMoveWorkspace EventType = "moveWorkspace"
EventTypeFocusedMon EventType = "focusedMon"
EventTypeMoveWindow EventType = "moveWindow"
EventTypeOpenLayer EventType = "openLayer"
EventTypeCloseLayer EventType = "closeLayer"
EventTypeOpenWindow EventType = "openWindow"
EventTypeCloseWindow EventType = "closeWindow"
EventTypeActiveWindow EventType = "activewindow"
EventTypeActiveWindowV2 EventType = "activewindowv2"
EventTypeKeyboardFocus EventType = "keyboardfocus"
EventTypeMoveWorkspace EventType = "moveworkspace"
EventTypeMoveWorkspaceV2 EventType = "moveworkspacev2"
EventTypeFocusedMon EventType = "focusedmon"
EventTypeMoveWindow EventType = "movewindow"
EventTypeMoveWindowV2 EventType = "movewindowv2"
EventTypeOpenLayer EventType = "openlayer"
EventTypeCloseLayer EventType = "closelayer"
EventTypeOpenWindow EventType = "openwindow"
EventTypeCloseWindow EventType = "closewindow"
EventTypeUrgent EventType = "urgent"
EventTypeMinimize EventType = "minimize"
EventTypeMonitorAdded EventType = "monitorAdded"
EventTypeMonitorRemoved EventType = "monitorRemoved"
EventTypeCreateWorkspace EventType = "createWorkspace"
EventTypeDestroyWorkspace EventType = "destroyWorkspace"
EventTypeMonitorAdded EventType = "monitoradded"
EventTypeMonitorAddedV2 EventType = "monitoraddedv2"
EventTypeMonitorRemoved EventType = "monitorremoved"
EventTypeCreateWorkspace EventType = "createworkspace"
EventTypeCreateWorkspaceV2 EventType = "createworkspacev2"
EventTypeDestroyWorkspace EventType = "destroyworkspace"
EventTypeDestroyWorkspaceV2 EventType = "destroyworkspacev2"
EventTypeFullscreen EventType = "fullscreen"
EventTypeChangeFloatingMode EventType = "changeFloatingMode"
EventTypeChangeFloatingMode EventType = "changefloatingmode"
EventTypeWorkspace EventType = "workspace"
EventTypeWorkspaceV2 EventType = "workspacev2"
EventTypeSubmap EventType = "submap"
EventTypeMouseMove EventType = "mouseMove"
EventTypeMouseButton EventType = "mouseButton"
EventTypeMouseAxis EventType = "mouseAxis"
EventTypeTouchDown EventType = "touchDown"
EventTypeTouchUp EventType = "touchUp"
EventTypeTouchMove EventType = "touchMove"
EventTypeActiveLayout EventType = "activeLayout"
EventTypePreRender EventType = "preRender"
EventTypeMouseMove EventType = "mousemove"
EventTypeMouseButton EventType = "mousebutton"
EventTypeMouseAxis EventType = "mouseaxis"
EventTypeTouchDown EventType = "touchdown"
EventTypeTouchUp EventType = "touchup"
EventTypeTouchMove EventType = "touchmove"
EventTypeActiveLayout EventType = "activelayout"
EventTypePreRender EventType = "prerender"
EventTypeScreencast EventType = "screencast"
EventTypeRender EventType = "render"
EventTypeWindowtitle EventType = "windowtitle"
EventTypeConfigReloaded EventType = "configReloaded"
EventTypePreConfigReload EventType = "preConfigReload"
EventTypeKeyPress EventType = "keyPress"
EventTypeConfigReloaded EventType = "configreloaded"
EventTypePreConfigReload EventType = "preconfigreload"
EventTypeKeyPress EventType = "keypress"
)

25
model_instance.go Normal file
View File

@ -0,0 +1,25 @@
package hypr
import (
"encoding/json"
"fmt"
)
type Client struct {
Signature string `json:"instance"`
PID int `json:"pid"`
WaylandSocket string `json:"wl_socket"`
}
func (c Client) String() string {
data, _ := json.MarshalIndent(c, "", "\t")
return string(data)
}
func (c Client) SocketPath() string {
return fmt.Sprintf("/run/user/1000/hypr/%s/.socket.sock", c.Signature)
}
func (c Client) EventSocketPath() string {
return fmt.Sprintf("/run/user/1000/hypr/%s/.socket2.sock", c.Signature)
}

13
model_misc.go Normal file
View File

@ -0,0 +1,13 @@
package hypr
import "encoding/json"
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
func (p Point) String() string {
data, _ := json.MarshalIndent(p, "", "\t")
return string(data)
}

View File

@ -3,30 +3,39 @@ package hypr
import "encoding/json"
type Window struct {
Address string `json:"address"`
Mapped bool `json:"mapped"`
Hidden bool `json:"hidden"`
At [2]int `json:"at"`
Size [2]int `json:"size"`
Workspace WorkspaceIdent `json:"workspace"`
Floating bool `json:"floating"`
MonitorID int `json:"monitor"`
Class string `json:"class"`
Title string `json:"title"`
InitialClass string `json:"initialClass"`
InitialTitle string `json:"initialTitle"`
PID int `json:"pid"`
Xwayland bool `json:"xwayland"`
Pinned bool `json:"pinned"`
Fullscreen bool `json:"fullscreen"`
FullscreenMode int `json:"fullscreenMode"`
FakeFullscreen bool `json:"fakeFullscreen"`
Grouped []interface{} `json:"grouped"` // TODO
Swallowing string `json:"swallowing"`
FocusHistoryID int `json:"focusHistoryID"`
Address string `json:"address"`
Mapped bool `json:"mapped"`
Hidden bool `json:"hidden"`
At [2]int `json:"at"`
Size [2]int `json:"size"`
Workspace WorkspaceIdent `json:"workspace"`
Floating bool `json:"floating"`
MonitorID int `json:"monitor"`
Class string `json:"class"`
Title string `json:"title"`
InitialClass string `json:"initialClass"`
InitialTitle string `json:"initialTitle"`
PID int `json:"pid"`
Xwayland bool `json:"xwayland"`
Pinned bool `json:"pinned"`
Fullscreen FullscreenState `json:"fullscreen"`
FullscreenClient FullscreenState `json:"fullscreenClient"`
Grouped []interface{} `json:"grouped"` // TODO
Swallowing string `json:"swallowing"`
FocusHistoryID int `json:"focusHistoryID"`
}
func (w Window) String() string {
data, _ := json.MarshalIndent(w, "", "\t")
return string(data)
}
type FullscreenState int
const (
FullscreenCurrent FullscreenState = -1
FullscreenNone FullscreenState = 0
FullscreenMaximize FullscreenState = 1
FullscreenFullscreen FullscreenState = 2
FullscreenMaximizeFullscreen FullscreenState = 3
)

36
subscribe_event.go Normal file
View File

@ -0,0 +1,36 @@
package hypr
import (
"bufio"
"context"
"strings"
"git.milar.in/milarin/slices"
)
func (i *Client) Subscribe(ctx context.Context, events ...EventType) (<-chan Event, error) {
r, err := readSocketRaw(i.EventSocketPath(), strings.NewReader(""))
if err != nil {
return nil, err
}
out := make(chan Event, 10)
eventMap := slices.ToStructMap(events)
allEvents := len(events) == 0
go func() {
defer r.Close()
defer close(out)
sc := bufio.NewScanner(r)
for ctx.Err() == nil && sc.Scan() {
event := parseEvent(sc.Text())
if _, ok := eventMap[event.Type]; allEvents || ok {
out <- event
}
}
}()
return out, nil
}

73
utils.go Normal file
View File

@ -0,0 +1,73 @@
package hypr
import (
"context"
"encoding/json"
"io"
"net"
"time"
)
func readSocketRaw(socket string, body io.Reader) (io.ReadCloser, error) {
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, err
}
if _, err := io.Copy(conn, body); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
func readSocketString(socket string, body io.Reader) (string, error) {
r, err := readSocketRaw(socket, body)
if err != nil {
return "", err
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
return "", err
}
return string(data), nil
}
func readSocket[T any](socket string, body io.Reader) (T, error) {
r, err := readSocketRaw(socket, body)
if err != nil {
return *new(T), err
}
defer r.Close()
value := new(T)
if err := json.NewDecoder(r).Decode(value); err != nil {
return *new(T), err
}
return *value, nil
}
func waitFor(ctx context.Context, condition func() bool) bool {
if condition() {
return true
}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if condition() {
return true
}
case <-ctx.Done():
return false
}
}
}