diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 6b8c10b04..0eab2c789 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -20,6 +20,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" @@ -109,6 +110,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "tailscale.") { + if tailscaleManager == nil { + models.RespondError(conn, req.ID, "Tailscale not available") + return + } + tailscale.HandleRequest(conn, req, tailscaleManager) + return + } + if strings.HasPrefix(req.Method, "dwl.") { if dwlManager == nil { models.RespondError(conn, req.ID, "dwl manager not initialized") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 72a2c7eac..990d18b30 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -30,6 +30,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" @@ -63,6 +64,7 @@ var waylandManager *wayland.Manager var bluezManager *bluez.Manager var appPickerManager *apppicker.Manager var cupsManager *cups.Manager +var tailscaleManager *tailscale.Manager var dwlManager *dwl.Manager var extWorkspaceManager *extworkspace.Manager var brightnessManager *brightness.Manager @@ -79,6 +81,8 @@ const dbusClientID = "dms-dbus-client" var capabilitySubscribers syncmap.Map[string, chan ServerInfo] var cupsSubscribers syncmap.Map[string, bool] var cupsSubscriberCount atomic.Int32 +var tailscaleSubscribers syncmap.Map[string, bool] +var tailscaleSubscriberCount atomic.Int32 var extWorkspaceAvailable atomic.Bool var extWorkspaceInitMutex sync.Mutex @@ -249,6 +253,19 @@ func InitializeCupsManager() error { return nil } +func InitializeTailscaleManager() error { + manager, err := tailscale.NewManager("") + if err != nil { + log.Warnf("Failed to initialize tailscale manager: %v", err) + return err + } + + tailscaleManager = manager + + log.Info("Tailscale manager initialized") + return nil +} + func InitializeDwlManager() error { log.Info("Attempting to initialize DWL IPC...") @@ -459,6 +476,10 @@ func getCapabilities() Capabilities { caps = append(caps, "cups") } + if tailscaleManager != nil { + caps = append(caps, "tailscale") + } + if dwlManager != nil { caps = append(caps, "dwl") } @@ -525,6 +546,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "cups") } + if tailscaleManager != nil { + caps = append(caps, "tailscale") + } + if dwlManager != nil { caps = append(caps, "dwl") } @@ -1001,6 +1026,51 @@ func handleSubscribe(conn net.Conn, req models.Request) { } } + if shouldSubscribe("tailscale") { + tailscaleSubscribers.Store(clientID+"-tailscale", true) + tailscaleSubscriberCount.Add(1) + + if tailscaleManager != nil { + wg.Add(1) + tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale") + go func() { + defer wg.Done() + defer func() { + tailscaleManager.Unsubscribe(clientID + "-tailscale") + tailscaleSubscribers.Delete(clientID + "-tailscale") + count := tailscaleSubscriberCount.Add(-1) + + if count == 0 { + log.Info("Last tailscale subscriber disconnected, manager stays active for reconnection") + } + }() + + initialState := tailscaleManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-tailscaleChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "tailscale", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + } + if shouldSubscribe("dwl") && dwlManager != nil { wg.Add(1) dwlChan := dwlManager.Subscribe(clientID + "-dwl") @@ -1335,6 +1405,27 @@ func cleanupManagers() { func Start(printDocs bool) error { cleanupStaleSockets() + // Eagerly try to initialize Tailscale manager; if it fails (socket not + // found), start a background retry loop so the widget recovers when + // tailscaled starts later. + if err := InitializeTailscaleManager(); err != nil { + log.Warnf("Tailscale not available at startup: %v", err) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + if tailscaleManager != nil { + return + } + if err := InitializeTailscaleManager(); err == nil { + log.Info("Tailscale manager initialized after retry") + notifyCapabilityChange() + return + } + } + }() + } + socketPath := GetSocketPath() os.Remove(socketPath) diff --git a/core/internal/server/tailscale/client.go b/core/internal/server/tailscale/client.go new file mode 100644 index 000000000..e34198d84 --- /dev/null +++ b/core/internal/server/tailscale/client.go @@ -0,0 +1,208 @@ +package tailscale + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "sort" + "strings" + "time" +) + +const ( + defaultSocketPath = "/var/run/tailscale/tailscaled.sock" + statusEndpoint = "/localapi/v0/status" + fetchTimeout = 10 * time.Second +) + +// newSocketHTTPClient creates an HTTP client that communicates over a Unix socket. +func newSocketHTTPClient(socketPath string) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + Timeout: fetchTimeout, + } +} + +// fetchStatusWithClient fetches the Tailscale status from the given URL using the provided HTTP client. +func fetchStatusWithClient(client *http.Client, url string) (*TailscaleState, error) { + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch tailscale status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tailscale API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return parseStatusResponse(raw) +} + +// parseStatusResponse converts the raw JSON map from the Tailscale local API into a TailscaleState. +func parseStatusResponse(raw map[string]any) (*TailscaleState, error) { + backendState, _ := raw["BackendState"].(string) + connected := backendState == "Running" + + state := &TailscaleState{ + Connected: connected, + BackendState: backendState, + } + + if !connected { + return state, nil + } + + state.Version, _ = raw["Version"].(string) + state.MagicDNSSuffix, _ = raw["MagicDNSSuffix"].(string) + + if tailnet, ok := raw["CurrentTailnet"].(map[string]any); ok { + state.TailnetName, _ = tailnet["Name"].(string) + } + + // Build user lookup map + users := make(map[float64]string) + if userMap, ok := raw["User"].(map[string]any); ok { + for _, u := range userMap { + if user, ok := u.(map[string]any); ok { + if id, ok := user["ID"].(float64); ok { + loginName, _ := user["LoginName"].(string) + users[id] = loginName + } + } + } + } + + // Parse self + if self, ok := raw["Self"].(map[string]any); ok { + state.Self = parsePeer(self, users) + } + + // Parse peers + if peerMap, ok := raw["Peer"].(map[string]any); ok { + peers := make([]Peer, 0, len(peerMap)) + for _, p := range peerMap { + if peerData, ok := p.(map[string]any); ok { + peers = append(peers, parsePeer(peerData, users)) + } + } + sort.Slice(peers, func(i, j int) bool { + if peers[i].Online != peers[j].Online { + return peers[i].Online + } + return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname) + }) + state.Peers = peers + } + + return state, nil +} + +// parsePeer extracts a Peer from a raw JSON map, resolving user IDs to login names. +func parsePeer(data map[string]any, users map[float64]string) Peer { + peer := Peer{} + + peer.ID, _ = data["ID"].(string) + peer.Hostname, _ = data["HostName"].(string) + if dnsName, ok := data["DNSName"].(string); ok { + peer.DNSName = strings.TrimSuffix(dnsName, ".") + } + // Mobile devices report "localhost" as hostname — use DNSName instead + if (peer.Hostname == "" || peer.Hostname == "localhost") && peer.DNSName != "" { + parts := strings.SplitN(peer.DNSName, ".", 2) + if len(parts) > 0 { + peer.Hostname = parts[0] + } + } + peer.OS, _ = data["OS"].(string) + peer.Online, _ = data["Online"].(bool) + peer.Active, _ = data["Active"].(bool) + peer.ExitNode, _ = data["ExitNode"].(bool) + peer.Relay, _ = data["Relay"].(string) + + if rxBytes, ok := data["RxBytes"].(float64); ok { + peer.RxBytes = int64(rxBytes) + } + if txBytes, ok := data["TxBytes"].(float64); ok { + peer.TxBytes = int64(txBytes) + } + + if ips, ok := data["TailscaleIPs"].([]any); ok { + for _, ip := range ips { + if ipStr, ok := ip.(string); ok { + if strings.Contains(ipStr, ":") { + if peer.TailscaleIPv6 == "" { + peer.TailscaleIPv6 = ipStr + } + } else { + if peer.TailscaleIP == "" { + peer.TailscaleIP = ipStr + } + } + } + } + } + + if tags, ok := data["Tags"].([]any); ok { + for _, tag := range tags { + if tagStr, ok := tag.(string); ok { + peer.Tags = append(peer.Tags, tagStr) + } + } + } + + if userID, ok := data["UserID"].(float64); ok && userID > 0 { + peer.Owner = users[userID] + } + + if lastSeen, ok := data["LastSeen"].(string); ok && lastSeen != "" && lastSeen != "0001-01-01T00:00:00Z" { + if t, err := time.Parse(time.RFC3339, lastSeen); err == nil { + peer.LastSeen = formatRelativeTime(t) + } + } + + return peer +} + +// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago"). +func formatRelativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} diff --git a/core/internal/server/tailscale/client_test.go b/core/internal/server/tailscale/client_test.go new file mode 100644 index 000000000..9a889502c --- /dev/null +++ b/core/internal/server/tailscale/client_test.go @@ -0,0 +1,197 @@ +package tailscale + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleStatusResponse = `{ + "Version": "1.94.2", + "BackendState": "Running", + "TUN": true, + "HaveNodeKey": true, + "TailscaleIPs": ["100.85.254.40", "fd7a:115c:a1e0::1"], + "Self": { + "ID": "node1", + "HostName": "cachyos", + "DNSName": "cachyos.example.ts.net.", + "OS": "linux", + "TailscaleIPs": ["100.85.254.40", "fd7a:115c:a1e0::1"], + "Online": true, + "UserID": 12345 + }, + "MagicDNSSuffix": "example.ts.net", + "CurrentTailnet": { + "Name": "user@example.com", + "MagicDNSSuffix": "example.ts.net" + }, + "Peer": { + "key1": { + "ID": "node2", + "HostName": "thinkpad-x390", + "DNSName": "thinkpad-x390.example.ts.net.", + "OS": "linux", + "TailscaleIPs": ["100.97.21.17", "fd7a:115c:a1e0::2"], + "Online": true, + "Active": true, + "Relay": "fra", + "RxBytes": 1024, + "TxBytes": 2048, + "UserID": 12345, + "ExitNode": false, + "LastSeen": "2026-03-01T12:00:00Z" + }, + "key2": { + "ID": "node3", + "HostName": "k8s-node", + "DNSName": "k8s-node.example.ts.net.", + "OS": "linux", + "TailscaleIPs": ["100.100.100.1"], + "Online": false, + "Active": false, + "Tags": ["tag:k8s"], + "UserID": 0, + "LastSeen": "2026-02-28T10:00:00Z" + } + }, + "User": { + "12345": { + "ID": 12345, + "LoginName": "user@example.com", + "DisplayName": "User" + } + } +}` + +func TestParseStatusResponse(t *testing.T) { + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(sampleStatusResponse), &raw)) + + state, err := parseStatusResponse(raw) + require.NoError(t, err) + + assert.True(t, state.Connected) + assert.Equal(t, "1.94.2", state.Version) + assert.Equal(t, "Running", state.BackendState) + assert.Equal(t, "example.ts.net", state.MagicDNSSuffix) + assert.Equal(t, "user@example.com", state.TailnetName) + + // Self + assert.Equal(t, "cachyos", state.Self.Hostname) + assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName) + assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP) + assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6) + assert.Equal(t, "linux", state.Self.OS) + assert.True(t, state.Self.Online) + + // Peers + assert.Len(t, state.Peers, 2) + + var onlinePeer, offlinePeer Peer + for _, p := range state.Peers { + if p.Hostname == "thinkpad-x390" { + onlinePeer = p + } + if p.Hostname == "k8s-node" { + offlinePeer = p + } + } + + assert.True(t, onlinePeer.Online) + assert.Equal(t, "100.97.21.17", onlinePeer.TailscaleIP) + assert.Equal(t, "fra", onlinePeer.Relay) + assert.Equal(t, "user@example.com", onlinePeer.Owner) + assert.Equal(t, int64(1024), onlinePeer.RxBytes) + + assert.False(t, offlinePeer.Online) + assert.Equal(t, "k8s-node", offlinePeer.Hostname) + assert.Contains(t, offlinePeer.Tags, "tag:k8s") + assert.Equal(t, "", offlinePeer.Owner) +} + +func TestParseStatusResponse_NotRunning(t *testing.T) { + raw := map[string]any{ + "BackendState": "Stopped", + } + + state, err := parseStatusResponse(raw) + require.NoError(t, err) + assert.False(t, state.Connected) + assert.Empty(t, state.Peers) +} + +func TestParseStatusResponse_Empty(t *testing.T) { + raw := map[string]any{} + + state, err := parseStatusResponse(raw) + require.NoError(t, err) + assert.False(t, state.Connected) +} + +func TestFetchStatus_HTTPServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/localapi/v0/status", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + client := &http.Client{} + state, err := fetchStatusWithClient(client, server.URL+"/localapi/v0/status") + require.NoError(t, err) + assert.True(t, state.Connected) + assert.Equal(t, "cachyos", state.Self.Hostname) + assert.Len(t, state.Peers, 2) +} + +func TestFetchStatus_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := &http.Client{} + _, err := fetchStatusWithClient(client, server.URL+"/localapi/v0/status") + assert.Error(t, err) + assert.Contains(t, err.Error(), "status 500") +} + +func TestPeerSorting(t *testing.T) { + // Verify online peers come before offline, then alphabetical + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(sampleStatusResponse), &raw)) + + state, err := parseStatusResponse(raw) + require.NoError(t, err) + + // thinkpad-x390 is online, k8s-node is offline + // Online should come first + assert.Equal(t, "thinkpad-x390", state.Peers[0].Hostname) + assert.Equal(t, "k8s-node", state.Peers[1].Hostname) +} + +func TestFormatRelativeTime(t *testing.T) { + tests := []struct { + name string + duration string + contains string + }{ + {"minutes", "5m", "minutes ago"}, + {"hours", "3h", "hours ago"}, + {"days", "48h", "days ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, _ := time.ParseDuration(tt.duration) + result := formatRelativeTime(time.Now().Add(-d)) + assert.Contains(t, result, tt.contains) + }) + } +} diff --git a/core/internal/server/tailscale/handlers.go b/core/internal/server/tailscale/handlers.go new file mode 100644 index 000000000..7e6e21a1f --- /dev/null +++ b/core/internal/server/tailscale/handlers.go @@ -0,0 +1,70 @@ +package tailscale + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +// TailscaleEvent wraps a state update for streaming to IPC subscribers. +type TailscaleEvent struct { + Type string `json:"type"` + Data TailscaleState `json:"data"` +} + +// HandleRequest routes an IPC request to the appropriate handler. +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "tailscale.subscribe": + handleSubscribe(conn, req, manager) + case "tailscale.getStatus": + handleGetStatus(conn, req, manager) + case "tailscale.refresh": + handleRefresh(conn, req, manager) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) { + state := manager.GetState() + models.Respond(conn, req.ID, state) +} + +func handleRefresh(conn net.Conn, req models.Request, manager *Manager) { + manager.RefreshState() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"}) +} + +func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) { + clientID := fmt.Sprintf("client-%p", conn) + stateChan := manager.Subscribe(clientID) + defer manager.Unsubscribe(clientID) + + initialState := manager.GetState() + event := TailscaleEvent{ + Type: "state_changed", + Data: initialState, + } + + if err := json.NewEncoder(conn).Encode(models.Response[TailscaleEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + return + } + + for state := range stateChan { + event := TailscaleEvent{ + Type: "state_changed", + Data: state, + } + if err := json.NewEncoder(conn).Encode(models.Response[TailscaleEvent]{ + Result: &event, + }); err != nil { + return + } + } +} diff --git a/core/internal/server/tailscale/handlers_test.go b/core/internal/server/tailscale/handlers_test.go new file mode 100644 index 000000000..867d02e68 --- /dev/null +++ b/core/internal/server/tailscale/handlers_test.go @@ -0,0 +1,110 @@ +package tailscale + +import ( + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockConn struct { + *bytes.Buffer +} + +func (m *mockConn) Close() error { return nil } +func (m *mockConn) LocalAddr() net.Addr { return nil } +func (m *mockConn) RemoteAddr() net.Addr { return nil } +func (m *mockConn) SetDeadline(t time.Time) error { return nil } +func (m *mockConn) SetReadDeadline(t time.Time) error { return nil } +func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil } + +func TestHandleGetStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + m := newTestManager(server.URL) + defer m.Close() + + err := m.poll() + require.NoError(t, err) + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "tailscale.getStatus", + } + + handleGetStatus(conn, req, m) + + var resp models.Response[TailscaleState] + err = json.NewDecoder(buf).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, 1, resp.ID) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Connected) + assert.Equal(t, "cachyos", resp.Result.Self.Hostname) + assert.Len(t, resp.Result.Peers, 2) +} + +func TestHandleRefresh(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + m := newTestManager(server.URL) + defer m.Close() + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "tailscale.refresh", + } + + handleRefresh(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, 1, resp.ID) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "refreshed", resp.Result.Message) +} + +func TestHandleRequest_UnknownMethod(t *testing.T) { + m := newTestManager("http://localhost:0") + defer m.Close() + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "tailscale.unknownMethod", + } + + HandleRequest(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + require.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotEmpty(t, resp.Error) + assert.Contains(t, resp.Error, "unknown method") +} diff --git a/core/internal/server/tailscale/manager.go b/core/internal/server/tailscale/manager.go new file mode 100644 index 000000000..a10161b69 --- /dev/null +++ b/core/internal/server/tailscale/manager.go @@ -0,0 +1,201 @@ +package tailscale + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +const pollInterval = 30 * time.Second + +// Manager manages Tailscale state polling and subscriber notifications. +type Manager struct { + state *TailscaleState + stateMutex sync.RWMutex + subscribers syncmap.Map[string, chan TailscaleState] + stopChan chan struct{} + pollWG sync.WaitGroup + notifierWg sync.WaitGroup + socketPath string + lastState *TailscaleState + httpClient *http.Client + statusURL string +} + +// NewManager creates a new Tailscale manager. It checks that the socket exists, +// performs an initial poll, and starts background polling. +func NewManager(socketPath string) (*Manager, error) { + if socketPath == "" { + socketPath = defaultSocketPath + } + if _, err := os.Stat(socketPath); err != nil { + return nil, fmt.Errorf("tailscale socket not found at %s: %w", socketPath, err) + } + + client := newSocketHTTPClient(socketPath) + statusURL := "http://local-tailscaled.sock" + statusEndpoint + + m := &Manager{ + state: &TailscaleState{}, + socketPath: socketPath, + httpClient: client, + statusURL: statusURL, + stopChan: make(chan struct{}), + } + + if err := m.poll(); err != nil { + log.Warnf("[Tailscale] Initial poll failed: %v", err) + } + + m.pollWG.Add(1) + go m.pollLoop() + + return m, nil +} + +// newTestManager creates a manager pointing at an httptest server URL instead of a Unix socket. +func newTestManager(baseURL string) *Manager { + return &Manager{ + state: &TailscaleState{}, + httpClient: &http.Client{}, + statusURL: baseURL + statusEndpoint, + stopChan: make(chan struct{}), + } +} + +// poll fetches the current Tailscale status and updates the manager state. +func (m *Manager) poll() error { + state, err := fetchStatusWithClient(m.httpClient, m.statusURL) + if err != nil { + return err + } + + m.stateMutex.Lock() + m.state = state + m.stateMutex.Unlock() + + return nil +} + +// pollLoop runs on a ticker, polling Tailscale every pollInterval and notifying subscribers if state changed. +func (m *Manager) pollLoop() { + defer m.pollWG.Done() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-m.stopChan: + return + case <-ticker.C: + if err := m.poll(); err != nil { + log.Warnf("[Tailscale] Poll failed: %v", err) + // Mark as disconnected but keep polling so we recover + // when the daemon restarts + m.stateMutex.Lock() + m.state = &TailscaleState{Connected: false, BackendState: "Unreachable"} + m.stateMutex.Unlock() + } + m.checkAndNotify() + } + } +} + +// checkAndNotify compares the current state with the last notified state and broadcasts if changed. +func (m *Manager) checkAndNotify() { + m.stateMutex.RLock() + current := m.state + m.stateMutex.RUnlock() + + if stateChanged(m.lastState, current) { + stateCopy := *current + m.lastState = &stateCopy + m.broadcastState(*current) + } +} + +// broadcastState sends the given state to all subscriber channels. +func (m *Manager) broadcastState(state TailscaleState) { + m.subscribers.Range(func(key string, ch chan TailscaleState) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +// GetState returns a copy of the current Tailscale state. +func (m *Manager) GetState() TailscaleState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + + if m.state == nil { + return TailscaleState{} + } + return *m.state +} + +// Subscribe creates a buffered channel for the given client ID and stores it. +func (m *Manager) Subscribe(clientID string) chan TailscaleState { + ch := make(chan TailscaleState, 64) + m.subscribers.Store(clientID, ch) + return ch +} + +// Unsubscribe removes and closes the subscriber channel for the given client ID. +func (m *Manager) Unsubscribe(clientID string) { + if val, ok := m.subscribers.LoadAndDelete(clientID); ok { + close(val) + } +} + +// Close stops the polling goroutine and closes all subscriber channels. +func (m *Manager) Close() { + close(m.stopChan) + m.pollWG.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan TailscaleState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +// RefreshState triggers an immediate poll and notifies subscribers if state changed. +func (m *Manager) RefreshState() { + if err := m.poll(); err != nil { + log.Warnf("[Tailscale] Failed to refresh state: %v", err) + return + } + m.checkAndNotify() +} + +// stateChanged compares two states using JSON serialization. +func stateChanged(old, new *TailscaleState) bool { + if old == nil && new == nil { + return false + } + if old == nil || new == nil { + return true + } + + oldJSON, err := json.Marshal(old) + if err != nil { + return true + } + newJSON, err := json.Marshal(new) + if err != nil { + return true + } + + return string(oldJSON) != string(newJSON) +} diff --git a/core/internal/server/tailscale/manager_test.go b/core/internal/server/tailscale/manager_test.go new file mode 100644 index 000000000..3f12d77d0 --- /dev/null +++ b/core/internal/server/tailscale/manager_test.go @@ -0,0 +1,110 @@ +package tailscale + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewManager_SocketNotFound(t *testing.T) { + _, err := NewManager("/tmp/nonexistent-tailscale-test.sock") + require.Error(t, err) + assert.Contains(t, err.Error(), "tailscale socket not found") +} + +func TestManager_GetState(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + m := newTestManager(server.URL) + defer m.Close() + + err := m.poll() + require.NoError(t, err) + + state := m.GetState() + assert.True(t, state.Connected) + assert.Equal(t, "cachyos", state.Self.Hostname) + assert.Len(t, state.Peers, 2) + assert.Equal(t, "1.94.2", state.Version) + assert.Equal(t, "Running", state.BackendState) +} + +func TestManager_Subscribe(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + m := newTestManager(server.URL) + defer m.Close() + + err := m.poll() + require.NoError(t, err) + + ch := m.Subscribe("test-client-1") + assert.NotNil(t, ch) + + // Verify a second subscriber also works + ch2 := m.Subscribe("test-client-2") + assert.NotNil(t, ch2) + + // Unsubscribe first client + m.Unsubscribe("test-client-1") + + // Unsubscribe second client + m.Unsubscribe("test-client-2") +} + +func TestManager_Close(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(sampleStatusResponse)) + })) + defer server.Close() + + m := newTestManager(server.URL) + + // Subscribe before closing + ch := m.Subscribe("test-client") + assert.NotNil(t, ch) + + // Close should not panic + assert.NotPanics(t, func() { + m.Close() + }) +} + +func TestStateChanged(t *testing.T) { + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(sampleStatusResponse), &raw)) + + state1, err := parseStatusResponse(raw) + require.NoError(t, err) + + // nil vs state should be changed + assert.True(t, stateChanged(nil, state1)) + + // Same state should not be changed + state2 := *state1 + assert.False(t, stateChanged(state1, &state2)) + + // Modified state should be changed + state3 := *state1 + state3.BackendState = "Stopped" + state3.Connected = false + assert.True(t, stateChanged(state1, &state3)) + + // Different peer count should be changed + state4 := *state1 + state4.Peers = state4.Peers[:1] + assert.True(t, stateChanged(state1, &state4)) +} diff --git a/core/internal/server/tailscale/types.go b/core/internal/server/tailscale/types.go new file mode 100644 index 000000000..d4fe49b72 --- /dev/null +++ b/core/internal/server/tailscale/types.go @@ -0,0 +1,31 @@ +package tailscale + +// TailscaleState represents the current state of the Tailscale daemon. +type TailscaleState struct { + Connected bool `json:"connected"` + Version string `json:"version"` + BackendState string `json:"backendState"` + MagicDNSSuffix string `json:"magicDnsSuffix"` + TailnetName string `json:"tailnetName"` + Self Peer `json:"self"` + Peers []Peer `json:"peers"` +} + +// Peer represents a single node in the Tailscale network. +type Peer struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + DNSName string `json:"dnsName"` + TailscaleIP string `json:"tailscaleIp"` + TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"` + OS string `json:"os"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen,omitempty"` + ExitNode bool `json:"exitNode"` + Tags []string `json:"tags,omitempty"` + Owner string `json:"owner"` + Relay string `json:"relay,omitempty"` + Active bool `json:"active"` + RxBytes int64 `json:"rxBytes"` + TxBytes int64 `json:"txBytes"` +} diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml new file mode 100644 index 000000000..603eef23a --- /dev/null +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml @@ -0,0 +1,338 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins + +PluginComponent { + id: root + + Ref { + service: TailscaleService + } + + ccWidgetIcon: TailscaleService.connected ? "device_hub" : "device_hub" + ccWidgetPrimaryText: I18n.tr("Tailscale", "Tailscale mesh VPN widget title") + ccWidgetSecondaryText: { + if (!TailscaleService.available) + return I18n.tr("Not available", "Tailscale service not available"); + if (!TailscaleService.connected) + return I18n.tr("Disconnected", "Tailscale disconnected status"); + const count = TailscaleService.onlinePeerCount; + return I18n.tr("%1 online", "Number of online Tailscale peers").arg(count); + } + ccWidgetIsActive: TailscaleService.connected + + onCcWidgetToggled: {} + + ccDetailContent: Component { + Rectangle { + id: detailRoot + + property string searchQuery: "" + property int filterIndex: 0 // 0=My Online, 1=All Online, 2=All + property string expandedHostname: "" + + implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + + Column { + id: detailColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + // Not available state + Column { + visible: !TailscaleService.available + width: parent.width + spacing: Theme.spacingS + + Item { + width: parent.width + height: 80 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + DankIcon { + name: "vpn_key_off" + size: 36 + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + + // Connected content + Item { + visible: TailscaleService.available + width: parent.width + height: parent.height - (parent.visibleChildren[0] === this ? 0 : y) + clip: true + + Column { + id: headerColumn + width: parent.width + spacing: Theme.spacingS + + // Search bar + refresh button + RowLayout { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + Layout.fillWidth: true + placeholderText: I18n.tr("Search devices...", "Tailscale device search placeholder") + leftIconName: "search" + showClearButton: true + text: detailRoot.searchQuery + onTextEdited: detailRoot.searchQuery = text + } + + DankActionButton { + iconName: "sync" + buttonSize: 28 + iconSize: 16 + iconColor: Theme.surfaceVariantText + tooltipText: I18n.tr("Refresh", "Refresh Tailscale device status") + onClicked: TailscaleService.refresh(null) + } + } + + // Filter chips + DankFilterChips { + width: parent.width + currentIndex: detailRoot.filterIndex + showCounts: true + chipHeight: 26 + model: [ + { "label": I18n.tr("My Online", "Tailscale filter: my online devices"), "count": TailscaleService.getMyOnlinePeers().length }, + { "label": I18n.tr("Online", "Tailscale filter: all online devices"), "count": TailscaleService.getOnlinePeers().length }, + { "label": I18n.tr("All", "Tailscale filter: all devices"), "count": TailscaleService.peers.length } + ] + onSelectionChanged: index => { + detailRoot.filterIndex = index; + } + } + } + + // Scrollable peer list — fills remaining space below header + DankFlickable { + anchors.top: headerColumn.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + contentHeight: peerListColumn.implicitHeight + clip: true + + Column { + id: peerListColumn + width: parent.width + spacing: Theme.spacingXS + + property var filteredPeers: { + if (detailRoot.searchQuery.length > 0) + return TailscaleService.searchPeers(detailRoot.searchQuery) || []; + switch (detailRoot.filterIndex) { + case 0: return TailscaleService.getMyOnlinePeers() || []; + case 1: return TailscaleService.getOnlinePeers() || []; + case 2: return TailscaleService.peers || []; + default: return []; + } + } + + // Empty state + Item { + width: parent.width + height: 60 + visible: peerListColumn.filteredPeers.length === 0 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: "devices" + size: 28 + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: detailRoot.searchQuery.length > 0 ? I18n.tr("No matching devices", "No Tailscale devices match search") : I18n.tr("No peers found", "No Tailscale peers found") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + + // Peer cards + Repeater { + model: peerListColumn.filteredPeers + + delegate: Rectangle { + required property var modelData + required property int index + + width: peerListColumn.width + height: peerCardColumn.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest + + property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") + property bool isExpanded: detailRoot.expandedHostname === modelData.hostname + + Column { + id: peerCardColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingS + spacing: 2 + + RowLayout { + width: parent.width + spacing: Theme.spacingS + + Rectangle { + width: 8 + height: 8 + radius: 4 + color: modelData.online ? "#4caf50" : Theme.surfaceVariantText + Layout.alignment: Qt.AlignVCenter + } + + StyledText { + text: modelData.hostname || "" + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Bold + color: Theme.surfaceText + Layout.fillWidth: true + elide: Text.ElideRight + } + + StyledText { + visible: isSelf + text: I18n.tr("This device", "Label for the user's own device in Tailscale") + font.pixelSize: 10 + color: Theme.primary + font.weight: Font.Medium + } + } + + RowLayout { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: modelData.tailscaleIp || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + Layout.fillWidth: true + } + + DankActionButton { + iconName: "content_copy" + buttonSize: 20 + iconSize: 11 + iconColor: Theme.surfaceVariantText + tooltipText: I18n.tr("Copy", "Copy to clipboard") + onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.tailscaleIp]) + } + } + + StyledText { + text: { + const parts = []; + if (modelData.os) parts.push(modelData.os); + if (modelData.online) { + parts.push(modelData.relay ? I18n.tr("relay: %1", "Tailscale relay server name").arg(modelData.relay) : I18n.tr("direct", "Tailscale direct connection")); + } else if (modelData.lastSeen) { + parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(modelData.lastSeen)); + } + return parts.join(" \u2022 "); + } + font.pixelSize: 10 + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + } + + // Expanded: DNS name + copy, tags, owner + Column { + visible: isExpanded + width: parent.width + spacing: 2 + topPadding: 4 + + RowLayout { + width: parent.width + spacing: Theme.spacingXS + visible: (modelData.dnsName || "").length > 0 + + StyledText { + text: modelData.dnsName || "" + font.pixelSize: 10 + color: Theme.surfaceVariantText + Layout.fillWidth: true + elide: Text.ElideRight + } + + DankActionButton { + iconName: "content_copy" + buttonSize: 20 + iconSize: 11 + iconColor: Theme.surfaceVariantText + onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.dnsName]) + } + } + + StyledText { + visible: (modelData.tags || []).length > 0 + text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", ")) + font.pixelSize: 10 + color: Theme.surfaceVariantText + } + + StyledText { + visible: (modelData.owner || "").length > 0 + text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "") + font.pixelSize: 10 + color: Theme.surfaceVariantText + } + } + } + + MouseArea { + z: -1 + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: detailRoot.expandedHostname = (detailRoot.expandedHostname === modelData.hostname) ? "" : modelData.hostname + } + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml index a3b115647..79f0d0de8 100644 --- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml +++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml @@ -22,6 +22,7 @@ Item { case section === "wifi": case section === "bluetooth": case section === "builtin_vpn": + case section === "builtin_tailscale": return Math.min(350, maxAvailableHeight); case section.startsWith("brightnessSlider_"): return Math.min(400, maxAvailableHeight); @@ -128,6 +129,12 @@ Item { } builtinInstance = widgetModel.cupsBuiltinInstance; } + if (builtinId === "builtin_tailscale") { + if (widgetModel?.tailscaleLoader) { + widgetModel.tailscaleLoader.active = true; + } + builtinInstance = widgetModel.tailscaleBuiltinInstance; + } if (!builtinInstance || !builtinInstance.ccDetailContent) { return; diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index 4cdd97975..077b92940 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -841,6 +841,12 @@ Column { } builtinInstance = Qt.binding(() => root.model?.cupsBuiltinInstance); } + if (id === "builtin_tailscale") { + if (root.model?.tailscaleLoader) { + root.model.tailscaleLoader.active = true; + } + builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance); + } } sourceComponent: { diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index ac616aec6..75ecb3db2 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -9,6 +9,7 @@ QtObject { property var vpnBuiltinInstance: null property var cupsBuiltinInstance: null + property var tailscaleBuiltinInstance: null property var vpnLoader: Loader { active: false @@ -62,6 +63,35 @@ QtObject { } } + property var tailscaleLoader: Loader { + active: false + sourceComponent: Component { + TailscaleWidget {} + } + + onItemChanged: { + root.tailscaleBuiltinInstance = item; + } + + onActiveChanged: { + if (!active) { + root.tailscaleBuiltinInstance = null; + } + } + + Connections { + target: SettingsData + function onControlCenterWidgetsChanged() { + const widgets = SettingsData.controlCenterWidgets || []; + const hasTailscaleWidget = widgets.some(w => w.id === "builtin_tailscale"); + if (!hasTailscaleWidget && tailscaleLoader.active) { + console.log("TailscaleWidget: No Tailscale widget in control center, deactivating loader"); + tailscaleLoader.active = false; + } + } + } + } + readonly property var coreWidgetDefinitions: [ { "id": "nightMode", @@ -201,6 +231,16 @@ QtObject { "enabled": CupsService.available, "warning": !CupsService.available ? I18n.tr("CUPS not available") : undefined, "isBuiltinPlugin": true + }, + { + "id": "builtin_tailscale", + "text": I18n.tr("Tailscale", "Tailscale mesh VPN widget title"), + "description": I18n.tr("Tailscale Network", "Tailscale control center widget description"), + "icon": "device_hub", + "type": "builtin_plugin", + "enabled": TailscaleService.available, + "warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined, + "isBuiltinPlugin": true } ] diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 217b96a0e..dbfdf60c3 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -62,6 +62,7 @@ Singleton { signal screensaverStateUpdate(var data) signal clipboardStateUpdate(var data) signal locationStateUpdate(var data) + signal tailscaleStateUpdate(var data) property bool capsLockState: false property bool screensaverInhibited: false @@ -398,6 +399,8 @@ Singleton { clipboardStateUpdate(data); } else if (service === "location") { locationStateUpdate(data); + } else if (service === "tailscale") { + tailscaleStateUpdate(data); } } diff --git a/quickshell/Services/TailscaleService.qml b/quickshell/Services/TailscaleService.qml new file mode 100644 index 000000000..a4a6ed3fa --- /dev/null +++ b/quickshell/Services/TailscaleService.qml @@ -0,0 +1,178 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + + property int refCount: 0 + + onRefCountChanged: { + if (refCount > 0) { + ensureSubscription(); + } else if (refCount === 0 && DMSService.activeSubscriptions.includes("tailscale")) { + DMSService.removeSubscription("tailscale"); + } + } + + function ensureSubscription() { + if (refCount <= 0) + return; + if (!DMSService.isConnected) + return; + if (DMSService.activeSubscriptions.includes("tailscale")) + return; + if (DMSService.activeSubscriptions.includes("all")) + return; + DMSService.addSubscription("tailscale"); + if (available) { + getStatus(); + } + } + + property bool connected: false + property string version: "" + property string backendState: "" + property string magicDnsSuffix: "" + property string tailnetName: "" + property var selfNode: null + property var peers: [] + + property bool available: false + property bool stateInitialized: false + + readonly property int onlinePeerCount: { + if (!peers || peers.length === 0) + return 0; + let count = 0; + for (let i = 0; i < peers.length; i++) { + if (peers[i].online) + count++; + } + return count; + } + + readonly property string socketPath: Quickshell.env("DMS_SOCKET") + + Component.onCompleted: { + if (socketPath && socketPath.length > 0) { + checkDMSCapabilities(); + } + } + + Connections { + target: DMSService + + function onConnectionStateChanged() { + if (DMSService.isConnected) { + checkDMSCapabilities(); + ensureSubscription(); + } + } + } + + Connections { + target: DMSService + enabled: DMSService.isConnected + + function onTailscaleStateUpdate(data) { + console.log("TailscaleService: Subscription update received"); + updateState(data); + } + + function onCapabilitiesReceived() { + checkDMSCapabilities(); + } + } + + function checkDMSCapabilities() { + if (!DMSService.isConnected) + return; + if (DMSService.capabilities.length === 0) + return; + available = DMSService.capabilities.includes("tailscale"); + + if (available && !stateInitialized) { + stateInitialized = true; + getStatus(); + } + } + + function getStatus() { + if (!available) + return; + DMSService.sendRequest("tailscale.getStatus", null, response => { + if (response.result) { + updateState(response.result); + } + }); + } + + function updateState(data) { + if (!data) + return; + connected = data.connected || false; + version = data.version || ""; + backendState = data.backendState || ""; + magicDnsSuffix = data.magicDnsSuffix || ""; + tailnetName = data.tailnetName || ""; + selfNode = data.self || null; + peers = data.peers || []; + } + + function refresh(callback) { + if (!available) + return; + DMSService.sendRequest("tailscale.refresh", null, response => { + if (callback) + callback(response); + }); + } + + // All filter functions prepend selfNode so it appears first in lists + function allPeersWithSelf() { + if (!available) return []; + const result = []; + if (selfNode) result.push(selfNode); + if (peers) result.push(...peers); + return result; + } + + function getMyPeers() { + if (!available || !selfNode) return allPeersWithSelf(); + const myOwner = selfNode.owner || ""; + if (!myOwner) return allPeersWithSelf(); + return allPeersWithSelf().filter(p => p.owner === myOwner); + } + + function getOnlinePeers() { + return allPeersWithSelf().filter(p => p.online); + } + + function getMyOnlinePeers() { + if (!available || !selfNode) return getOnlinePeers(); + const myOwner = selfNode.owner || ""; + if (!myOwner) return getOnlinePeers(); + return allPeersWithSelf().filter(p => p.online && p.owner === myOwner); + } + + function searchPeers(query) { + const all = allPeersWithSelf(); + if (!query || query.length === 0) return all; + const q = query.toLowerCase(); + return all.filter(p => { + if (p.hostname && p.hostname.toLowerCase().includes(q)) + return true; + if (p.dnsName && p.dnsName.toLowerCase().includes(q)) + return true; + if (p.tailscaleIp && p.tailscaleIp.includes(q)) + return true; + if (p.os && p.os.toLowerCase().includes(q)) + return true; + return false; + }); + } +} diff --git a/quickshell/assets/tailscale.svg b/quickshell/assets/tailscale.svg new file mode 100644 index 000000000..4c5ff5cd6 --- /dev/null +++ b/quickshell/assets/tailscale.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/quickshell/translations/en.json b/quickshell/translations/en.json index e95414a16..9f82327ce 100644 --- a/quickshell/translations/en.json +++ b/quickshell/translations/en.json @@ -12682,5 +12682,107 @@ "context": "Keyboard hints when enter-to-paste is enabled", "reference": "Modals/Clipboard/ClipboardKeyboardHints.qml:29", "comment": "" + }, + { + "term": "Tailscale", + "context": "Tailscale mesh VPN widget title", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml, Modules/ControlCenter/Models/WidgetModel.qml", + "comment": "" + }, + { + "term": "Tailscale Network", + "context": "Tailscale control center widget description", + "reference": "Modules/ControlCenter/Models/WidgetModel.qml", + "comment": "" + }, + { + "term": "Tailscale not available", + "context": "Warning when Tailscale service is not running", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml, Modules/ControlCenter/Models/WidgetModel.qml", + "comment": "" + }, + { + "term": "%1 online", + "context": "Number of online Tailscale peers", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Search devices...", + "context": "Tailscale device search placeholder", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "This device", + "context": "Label for the user's own device in Tailscale", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Network: %1", + "context": "Tailscale network name", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Version: %1", + "context": "Tailscale version", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "No matching devices", + "context": "No Tailscale devices match search", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "No peers found", + "context": "No Tailscale peers found", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "relay: %1", + "context": "Tailscale relay server name", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "direct", + "context": "Tailscale direct connection", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "last seen %1", + "context": "Tailscale peer last seen time", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Tags: %1", + "context": "Tailscale device tags", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Owner: %1", + "context": "Tailscale device owner", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Show my online devices", + "context": "Toggle to show only online devices owned by the user", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" + }, + { + "term": "Show all devices (%1)", + "context": "Toggle to show all Tailscale devices", + "reference": "Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml", + "comment": "" } ] diff --git a/quickshell/translations/template.json b/quickshell/translations/template.json index 3ab398323..758d489a9 100644 --- a/quickshell/translations/template.json +++ b/quickshell/translations/template.json @@ -14838,5 +14838,124 @@ "context": "Keyboard hints when enter-to-paste is enabled", "reference": "", "comment": "" + }, + { + "term": "Tailscale", + "translation": "", + "context": "Tailscale mesh VPN widget title", + "reference": "", + "comment": "" + }, + { + "term": "Tailscale Network", + "translation": "", + "context": "Tailscale control center widget description", + "reference": "", + "comment": "" + }, + { + "term": "Tailscale not available", + "translation": "", + "context": "Warning when Tailscale service is not running", + "reference": "", + "comment": "" + }, + { + "term": "%1 online", + "translation": "", + "context": "Number of online Tailscale peers", + "reference": "", + "comment": "" + }, + { + "term": "Search devices...", + "translation": "", + "context": "Tailscale device search placeholder", + "reference": "", + "comment": "" + }, + { + "term": "This device", + "translation": "", + "context": "Label for the user's own device in Tailscale", + "reference": "", + "comment": "" + }, + { + "term": "Network: %1", + "translation": "", + "context": "Tailscale network name", + "reference": "", + "comment": "" + }, + { + "term": "Version: %1", + "translation": "", + "context": "Tailscale version", + "reference": "", + "comment": "" + }, + { + "term": "No matching devices", + "translation": "", + "context": "No Tailscale devices match search", + "reference": "", + "comment": "" + }, + { + "term": "No peers found", + "translation": "", + "context": "No Tailscale peers found", + "reference": "", + "comment": "" + }, + { + "term": "relay: %1", + "translation": "", + "context": "Tailscale relay server name", + "reference": "", + "comment": "" + }, + { + "term": "direct", + "translation": "", + "context": "Tailscale direct connection", + "reference": "", + "comment": "" + }, + { + "term": "last seen %1", + "translation": "", + "context": "Tailscale peer last seen time", + "reference": "", + "comment": "" + }, + { + "term": "Tags: %1", + "translation": "", + "context": "Tailscale device tags", + "reference": "", + "comment": "" + }, + { + "term": "Owner: %1", + "translation": "", + "context": "Tailscale device owner", + "reference": "", + "comment": "" + }, + { + "term": "Show my online devices", + "translation": "", + "context": "Toggle to show only online devices owned by the user", + "reference": "", + "comment": "" + }, + { + "term": "Show all devices (%1)", + "translation": "", + "context": "Toggle to show all Tailscale devices", + "reference": "", + "comment": "" } ]