Skip to content

Commit b9124ec

Browse files
authored
Merge pull request #3 from icyleaf/feat/switch-to-hyprland-ipc
Use hyprland ipc socket to realtime watch event
2 parents ac4d964 + 1345340 commit b9124ec

File tree

2 files changed

+290
-36
lines changed

2 files changed

+290
-36
lines changed

internal/app/app.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,6 @@ func (app *Application) Run(configPath string, watchConfig bool) error {
5353
// Register config change callback
5454
app.configManager.AddCallback(app.onConfigChanged)
5555

56-
logger.Info("Starting Hyprland input method switcher...")
57-
58-
// Show which config file is being used
59-
logger.Infof("Using config file: %s", app.configManager.GetConfigPath())
60-
6156
// Start config file watching if enabled
6257
if watchConfig {
6358
if err := app.configManager.StartWatching(); err != nil {

internal/inputmethod/switcher.go

Lines changed: 290 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package inputmethod
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"fmt"
8+
"net"
9+
"os"
710
"os/exec"
11+
"path/filepath"
812
"regexp"
913
"strings"
1014
"time"
@@ -21,7 +25,7 @@ type Switcher struct {
2125
rime *Rime
2226
notifier interface {
2327
ShowInputMethodSwitch(inputMethod string, clientInfo *config.WindowInfo)
24-
} // Add notifier interface
28+
}
2529
}
2630

2731
type ClientInfo struct {
@@ -56,21 +60,212 @@ func (s *Switcher) SetNotifier(notifier interface {
5660
func (s *Switcher) MonitorAndSwitch(ctx context.Context) error {
5761
logger.Info("Starting Hyprland input method switcher...")
5862

59-
ticker := time.NewTicker(200 * time.Millisecond)
60-
defer ticker.Stop()
63+
// Process initial window
64+
if err := s.processCurrentWindow(); err != nil {
65+
logger.Debugf("Error processing initial window: %v", err)
66+
}
67+
68+
// Start IPC event monitoring
69+
return s.monitorHyprlandEvents(ctx)
70+
}
71+
72+
func (s *Switcher) monitorHyprlandEvents(ctx context.Context) error {
73+
// Get Hyprland IPC socket path
74+
socketPath := s.getHyprlandEventSocket()
75+
if socketPath == "" {
76+
return fmt.Errorf("failed to get Hyprland event socket path")
77+
}
78+
79+
logger.Infof("Connecting to Hyprland event socket: %s", socketPath)
6180

6281
for {
6382
select {
6483
case <-ctx.Done():
6584
logger.Info("Stopping input method switcher...")
6685
return nil
86+
default:
87+
}
88+
89+
// Connect to socket
90+
conn, err := net.Dial("unix", socketPath)
91+
if err != nil {
92+
logger.Errorf("Failed to connect to Hyprland event socket: %v", err)
93+
// Retry after delay
94+
select {
95+
case <-ctx.Done():
96+
return nil
97+
case <-time.After(5 * time.Second):
98+
continue
99+
}
100+
}
101+
102+
logger.Info("Connected to Hyprland event socket")
103+
104+
// Monitor events
105+
err = s.handleEvents(ctx, conn)
106+
conn.Close()
107+
108+
if err != nil && err != context.Canceled {
109+
logger.Errorf("Event monitoring error: %v", err)
110+
// Retry after delay
111+
select {
112+
case <-ctx.Done():
113+
return nil
114+
case <-time.After(5 * time.Second):
115+
continue
116+
}
117+
}
118+
119+
if ctx.Err() != nil {
120+
return nil
121+
}
122+
}
123+
}
124+
125+
func (s *Switcher) handleEvents(ctx context.Context, conn net.Conn) error {
126+
scanner := bufio.NewScanner(conn)
127+
128+
for scanner.Scan() {
129+
select {
130+
case <-ctx.Done():
131+
return context.Canceled
132+
default:
133+
}
134+
135+
line := scanner.Text()
136+
if line == "" {
137+
continue
138+
}
139+
140+
// Parse event
141+
parts := strings.SplitN(line, ">>", 2)
142+
if len(parts) != 2 {
143+
continue
144+
}
145+
146+
eventType := parts[0]
147+
eventData := parts[1]
148+
149+
logger.Debugf("Received event: %s >> %s", eventType, eventData)
150+
151+
// Handle window focus events - prefer activewindowv2 for better info
152+
switch eventType {
153+
case "activewindowv2":
154+
if err := s.handleActiveWindowV2Event(eventData); err != nil {
155+
logger.Debugf("Error handling activewindowv2 event: %v", err)
156+
}
157+
case "activewindow":
158+
// Fallback for older Hyprland versions
159+
if err := s.handleActiveWindowEvent(eventData); err != nil {
160+
logger.Debugf("Error handling activewindow event: %v", err)
161+
}
162+
}
163+
}
164+
165+
return scanner.Err()
166+
}
167+
168+
func (s *Switcher) handleActiveWindowV2Event(eventData string) error {
169+
// eventData format: "windowaddress" (hex address like 0x12345678)
170+
windowAddress := strings.TrimSpace(eventData)
171+
if windowAddress == "" {
172+
logger.Debugf("Empty activewindowv2 event data")
173+
return nil
174+
}
175+
176+
logger.Debugf("Active window changed to address: %s", windowAddress)
177+
178+
// Check if this is the same window we're already tracking
179+
if windowAddress == s.currentClient.Address {
180+
logger.Debugf("Same window address, skipping: %s", windowAddress)
181+
return nil
182+
}
183+
184+
// Get full client info for the active window
185+
clientInfo, err := s.getCurrentClient()
186+
if err != nil {
187+
return fmt.Errorf("failed to get current client: %w", err)
188+
}
189+
190+
// Verify the event matches current window address
191+
if clientInfo.Address != windowAddress {
192+
logger.Debugf("Event address mismatch: got %s, expected %s", windowAddress, clientInfo.Address)
193+
return nil
194+
}
195+
196+
// Process window change
197+
return s.processWindowChange(clientInfo)
198+
}
199+
200+
func (s *Switcher) handleActiveWindowEvent(eventData string) error {
201+
// eventData format: "class,title"
202+
parts := strings.SplitN(eventData, ",", 2)
203+
if len(parts) < 2 {
204+
logger.Debugf("Invalid activewindow event data: %s", eventData)
205+
return nil
206+
}
207+
208+
class := parts[0]
209+
title := parts[1]
210+
211+
logger.Debugf("Active window changed: class=%s, title=%s", class, title)
212+
213+
// Get full client info for the active window
214+
clientInfo, err := s.getCurrentClient()
215+
if err != nil {
216+
return fmt.Errorf("failed to get current client: %w", err)
217+
}
218+
219+
// Verify the event matches current window
220+
if clientInfo.Class != class {
221+
logger.Debugf("Event class mismatch: got %s, expected %s", class, clientInfo.Class)
222+
return nil
223+
}
224+
225+
// Check if this is the same window we're already tracking
226+
if clientInfo.Address == s.currentClient.Address {
227+
logger.Debugf("Same window address, skipping: %s", clientInfo.Address)
228+
return nil
229+
}
230+
231+
// Process window change
232+
return s.processWindowChange(clientInfo)
233+
}
234+
235+
func (s *Switcher) processWindowChange(clientInfo *ClientInfo) error {
236+
// Update current client info
237+
s.currentClient = clientInfo
238+
239+
// Get current input method status
240+
currentIM := s.GetCurrent()
241+
242+
// Determine target input method
243+
targetIM := s.getTargetInputMethod(clientInfo)
244+
245+
logger.Infof("Window changed: %s - %s (address: %s)", clientInfo.Class, clientInfo.Title, clientInfo.Address)
246+
logger.Infof("Current IM: %s -> Target IM: %s", currentIM, targetIM)
67247

68-
case <-ticker.C:
69-
if err := s.processCurrentWindow(); err != nil {
70-
logger.Debugf("Error processing current window: %v", err)
248+
// If input method needs to be switched
249+
if currentIM != targetIM && currentIM != "unknown" {
250+
if err := s.Switch(targetIM); err != nil {
251+
return fmt.Errorf("failed to switch input method to %s: %w", targetIM, err)
252+
}
253+
254+
logger.Infof("Switched input method to: %s", targetIM)
255+
s.currentIM = targetIM
256+
257+
// Show notification if notifier is available and enabled
258+
if s.notifier != nil && s.config.Notifications.ShowOnSwitch {
259+
// Convert ClientInfo to config.WindowInfo
260+
windowInfo := &config.WindowInfo{
261+
Class: clientInfo.Class,
262+
Title: clientInfo.Title,
71263
}
264+
s.notifier.ShowInputMethodSwitch(targetIM, windowInfo)
72265
}
73266
}
267+
268+
return nil
74269
}
75270

76271
func (s *Switcher) processCurrentWindow() error {
@@ -79,41 +274,105 @@ func (s *Switcher) processCurrentWindow() error {
79274
return fmt.Errorf("failed to get current client: %w", err)
80275
}
81276

82-
// If window changed (different address means different window)
83-
if clientInfo.Address != s.currentClient.Address {
84-
s.currentClient = clientInfo
277+
return s.processWindowChange(clientInfo)
278+
}
279+
280+
func (s *Switcher) getHyprlandEventSocket() string {
281+
logger.Debugf("Searching for Hyprland IPC socket...")
85282

86-
// Get current input method status
87-
currentIM := s.GetCurrent()
283+
// Get XDG_RUNTIME_DIR
284+
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
285+
if runtimeDir == "" {
286+
logger.Debugf("XDG_RUNTIME_DIR not set, falling back to /tmp")
287+
runtimeDir = "/tmp"
288+
}
289+
logger.Debugf("Using runtime directory: %s", runtimeDir)
290+
291+
// Try environment variables first
292+
if hyprInstance := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"); hyprInstance != "" {
293+
logger.Debugf("Found HYPRLAND_INSTANCE_SIGNATURE: %s", hyprInstance)
294+
socketPath := fmt.Sprintf("%s/hypr/%s/.socket2.sock", runtimeDir, hyprInstance)
295+
logger.Debugf("Checking socket path: %s", socketPath)
296+
if _, err := os.Stat(socketPath); err == nil {
297+
logger.Infof("Found Hyprland IPC socket via environment: %s", socketPath)
298+
return socketPath
299+
} else {
300+
logger.Debugf("Hyprland IPC Socket not found via environment: %v", err)
301+
}
302+
} else {
303+
logger.Debugf("HYPRLAND_INSTANCE_SIGNATURE not set")
304+
}
88305

89-
// Determine target input method
90-
targetIM := s.getTargetInputMethod(clientInfo)
306+
// Check if hypr directory exists in runtime dir
307+
hyprDir := fmt.Sprintf("%s/hypr", runtimeDir)
308+
if _, err := os.Stat(hyprDir); os.IsNotExist(err) {
309+
logger.Errorf("Hyprland directory %s does not exist. Is Hyprland running?", hyprDir)
310+
return ""
311+
}
91312

92-
logger.Infof("Window changed: %s - %s (address: %s)", clientInfo.Class, clientInfo.Title, clientInfo.Address)
93-
logger.Infof("Current IM: %s -> Target IM: %s", currentIM, targetIM)
313+
// List all directories in runtime/hypr/
314+
entries, err := os.ReadDir(hyprDir)
315+
if err != nil {
316+
logger.Errorf("Failed to read Hyprland directory %s: %v", hyprDir, err)
317+
return ""
318+
}
94319

95-
// If input method needs to be switched
96-
if currentIM != targetIM && currentIM != "unknown" {
97-
if err := s.Switch(targetIM); err != nil {
98-
return fmt.Errorf("failed to switch input method to %s: %w", targetIM, err)
320+
logger.Debugf("Found %d entries in %s", len(entries), hyprDir)
321+
for _, entry := range entries {
322+
if entry.IsDir() {
323+
socketPath := fmt.Sprintf("%s/%s/.socket2.sock", hyprDir, entry.Name())
324+
logger.Debugf("Checking socket: %s", socketPath)
325+
if _, err := os.Stat(socketPath); err == nil {
326+
logger.Infof("Found Hyprland event socket: %s", socketPath)
327+
return socketPath
328+
} else {
329+
logger.Debugf("Socket not found: %v", err)
99330
}
331+
}
332+
}
333+
334+
// Fallback: try to find socket using glob pattern in runtime dir
335+
globPattern := fmt.Sprintf("%s/hypr/*/.socket2.sock", runtimeDir)
336+
logger.Debugf("Trying glob pattern: %s", globPattern)
337+
matches, err := filepath.Glob(globPattern)
338+
if err != nil {
339+
logger.Errorf("Glob pattern failed: %v", err)
340+
return ""
341+
}
100342

101-
logger.Infof("Switched input method to: %s", targetIM)
102-
s.currentIM = targetIM
103-
104-
// Show notification if notifier is available and enabled
105-
if s.notifier != nil && s.config.Notifications.ShowOnSwitch {
106-
// Convert ClientInfo to config.WindowInfo
107-
windowInfo := &config.WindowInfo{
108-
Class: clientInfo.Class,
109-
Title: clientInfo.Title,
110-
}
111-
s.notifier.ShowInputMethodSwitch(targetIM, windowInfo)
343+
logger.Debugf("Glob found %d matches", len(matches))
344+
for _, match := range matches {
345+
logger.Debugf("Glob match: %s", match)
346+
}
347+
348+
if len(matches) == 0 {
349+
logger.Error("No Hyprland event sockets found. Please check:")
350+
logger.Error("1. Is Hyprland running?")
351+
logger.Error("2. Are you running this inside a Hyprland session?")
352+
logger.Errorf("3. Check if %s/hypr directory exists and contains instance directories", runtimeDir)
353+
354+
// List what's actually in runtime/hypr if it exists
355+
if entries, err := os.ReadDir(hyprDir); err == nil {
356+
logger.Errorf("Contents of %s:", hyprDir)
357+
for _, entry := range entries {
358+
logger.Errorf(" - %s (dir: %v)", entry.Name(), entry.IsDir())
112359
}
113360
}
361+
362+
// Also check for legacy /tmp/hypr path
363+
logger.Debugf("Checking legacy /tmp/hypr path...")
364+
legacyPattern := "/tmp/hypr/*/.socket2.sock"
365+
if legacyMatches, err := filepath.Glob(legacyPattern); err == nil && len(legacyMatches) > 0 {
366+
logger.Infof("Found legacy socket: %s", legacyMatches[0])
367+
return legacyMatches[0]
368+
}
369+
370+
return ""
114371
}
115372

116-
return nil
373+
// Use the first available socket
374+
logger.Infof("Using first available socket: %s", matches[0])
375+
return matches[0]
117376
}
118377

119378
func (s *Switcher) getCurrentClient() (*ClientInfo, error) {

0 commit comments

Comments
 (0)