commit 58313e54903b1b2e1e68007b63a3253d8fed9551 Author: Tordarus Date: Sat Mar 22 13:34:28 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a48d947 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*_test.go diff --git a/check_reachable.go b/check_reachable.go new file mode 100644 index 0000000..dac709c --- /dev/null +++ b/check_reachable.go @@ -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 + } + } +} diff --git a/dispatch.go b/dispatch.go new file mode 100644 index 0000000..c3af557 --- /dev/null +++ b/dispatch.go @@ -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 +} diff --git a/get_clients.go b/get_clients.go new file mode 100644 index 0000000..c09bdd0 --- /dev/null +++ b/get_clients.go @@ -0,0 +1,104 @@ +package hypr + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strconv" + "strings" + + "git.tordarus.net/tordarus/slices" +) + +func GetClient(signature string) (*Client, error) { + lockFilePath := fmt.Sprintf("/run/user/1000/hypr/%s/hyprland.lock", signature) + + 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 + } + + lines := strings.Split(string(data), "\n") + + pid, err := strconv.Atoi(lines[0]) + if err != nil { + return nil, err + } + + 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 + } + + 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) + } + + 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) + }) + + 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 +} diff --git a/getters.go b/getters.go new file mode 100644 index 0000000..565f5f7 --- /dev/null +++ b/getters.go @@ -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")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f344fff --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.tordarus.net/hyprland-tools/hypr + +go 1.21.6 + +require git.tordarus.net/tordarus/slices v0.0.14 + +require git.tordarus.net/tordarus/gmath v0.0.7 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..61c8a09 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +git.tordarus.net/tordarus/gmath v0.0.7 h1:tR48idt9AUL0r556ww3ZxByTKJEr6NWCTlhl2ihzYxQ= +git.tordarus.net/tordarus/gmath v0.0.7/go.mod h1:mO7aPlvNrGVE9UFXEuuACjZgMDsM63l3OcQy6xSQnoE= +git.tordarus.net/tordarus/slices v0.0.14 h1:Jy1VRMs777WewJ7mxTgjyQIMm/Zr+co18/XoQ01YZ3A= +git.tordarus.net/tordarus/slices v0.0.14/go.mod h1:RgE7A1aSAezIvPUgcbUuMHu0q4xGKoRevT+DC0eJmwI= diff --git a/model_bind.go b/model_bind.go new file mode 100644 index 0000000..0e59d70 --- /dev/null +++ b/model_bind.go @@ -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) +} diff --git a/model_event.go b/model_event.go new file mode 100644 index 0000000..73e7ea9 --- /dev/null +++ b/model_event.go @@ -0,0 +1,70 @@ +package hypr + +import ( + "encoding/json" + "strings" +) + +type Event struct { + Type EventType `json:"type"` + Data []string `json:"data"` +} + +func parseEvent(str string) Event { + data := strings.Split(str, ">>") + return Event{ + Type: EventType(data[0]), + Data: strings.Split(data[1], ","), + } +} + +func (e Event) String() string { + data, _ := json.MarshalIndent(e, "", "\t") + return string(data) +} + +type EventType = string + +const ( + EventTypeTick EventType = "tick" + 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" + EventTypeMonitorAddedV2 EventType = "monitoraddedv2" + EventTypeMonitorRemoved EventType = "monitorremoved" + EventTypeCreateWorkspace EventType = "createworkspace" + EventTypeCreateWorkspaceV2 EventType = "createworkspacev2" + EventTypeDestroyWorkspace EventType = "destroyworkspace" + EventTypeDestroyWorkspaceV2 EventType = "destroyworkspacev2" + EventTypeFullscreen EventType = "fullscreen" + 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" + EventTypeScreencast EventType = "screencast" + EventTypeRender EventType = "render" + EventTypeWindowtitle EventType = "windowtitle" + EventTypeConfigReloaded EventType = "configreloaded" + EventTypePreConfigReload EventType = "preconfigreload" + EventTypeKeyPress EventType = "keypress" +) diff --git a/model_instance.go b/model_instance.go new file mode 100644 index 0000000..a65f6f7 --- /dev/null +++ b/model_instance.go @@ -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) +} diff --git a/model_misc.go b/model_misc.go new file mode 100644 index 0000000..eae1bcc --- /dev/null +++ b/model_misc.go @@ -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) +} diff --git a/model_monitor.go b/model_monitor.go new file mode 100644 index 0000000..af4aacf --- /dev/null +++ b/model_monitor.go @@ -0,0 +1,31 @@ +package hypr + +import "encoding/json" + +type Monitor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Make string `json:"make"` + Model string `json:"model"` + Serial string `json:"serial"` + Width int `json:"width"` + Height int `json:"height"` + RefreshRate float64 `json:"refreshRate"` + X int `json:"x"` + Y int `json:"y"` + ActiveWorkspace WorkspaceIdent `json:"activeWorkspace"` + SpecialWorkspace WorkspaceIdent `json:"specialWorkspace"` + Reserved [4]int `json:"reserved"` + Scale float64 `json:"scale"` + Transform int `json:"transform"` + Focused bool `json:"focused"` + DPMSStatus bool `json:"dpmsStatus"` + VRR bool `json:"vrr"` + ActivelyTearing bool `json:"activelyTearing"` +} + +func (m Monitor) String() string { + data, _ := json.MarshalIndent(m, "", "\t") + return string(data) +} diff --git a/model_window.go b/model_window.go new file mode 100644 index 0000000..c16ecd1 --- /dev/null +++ b/model_window.go @@ -0,0 +1,41 @@ +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 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 +) diff --git a/model_workspace.go b/model_workspace.go new file mode 100644 index 0000000..1990478 --- /dev/null +++ b/model_workspace.go @@ -0,0 +1,24 @@ +package hypr + +import "encoding/json" + +type WorkspaceIdent struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type Workspace struct { + ID int `json:"id"` + Name string `json:"name"` + Monitor string `json:"monitor"` + MonitorID int `json:"monitorID"` + Windows int `json:"windows"` + HasFullscreen bool `json:"hasfullscreen"` + LastWindow string `json:"lastwindow"` + LastWindowTitle string `json:"lastwindowtitle"` +} + +func (w Workspace) String() string { + data, _ := json.MarshalIndent(w, "", "\t") + return string(data) +} diff --git a/subscribe_event.go b/subscribe_event.go new file mode 100644 index 0000000..48329b7 --- /dev/null +++ b/subscribe_event.go @@ -0,0 +1,36 @@ +package hypr + +import ( + "bufio" + "context" + "strings" + + "git.tordarus.net/tordarus/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 +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..7ab3ddc --- /dev/null +++ b/utils.go @@ -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 + } + } +}