mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-04 19:42:08 -04:00
feat(tailscale): add Tailscale control center widget (#1875)
* feat(tailscale): add Tailscale control center widget Full-stack Tailscale integration for DMS control center: Backend (Go): - Event-driven manager via WatchIPNBus (no polling) - Reconnects with exponential backoff when tailscaled unavailable - Typed conversion from ipnstate.Status to QML-friendly IPC types - Testable via tailscaleClient interface with mock watcher - Manager cleanup in cleanupManagers() - 19 unit tests Frontend (QML): - TailscaleService with WebSocket subscription - TailscaleWidget with peer list, filter chips, search - Copy-to-clipboard for IPs and DNS names - Daemon lifecycle handling (offline/stopped states) Dependencies: - Add tailscale.com v1.96.1 (official local API client) - Bump Go to 1.26.1 (required by tailscale.com) * cleanups --------- Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
26
core/go.mod
26
core/go.mod
@@ -1,8 +1,6 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.1
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
@@ -23,34 +21,52 @@ require (
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/image v0.39.0
|
||||
tailscale.com v1.96.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -61,7 +77,7 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -72,7 +88,7 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
|
||||
64
core/go.sum
64
core/go.sum
@@ -1,9 +1,13 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
@@ -38,18 +42,27 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
@@ -60,8 +73,14 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
@@ -72,6 +91,8 @@ github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsd
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -79,22 +100,28 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -107,6 +134,12 @@ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZ
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -119,12 +152,13 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
|
||||
@@ -144,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
@@ -160,12 +200,18 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.m
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
@@ -177,6 +223,10 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -185,3 +235,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
tailscale.com v1.96.1 h1:9+0JuyK9SSnbKSumGRQhrOdNtgZ5SgJINXgJoVpyf0Y=
|
||||
tailscale.com v1.96.1/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"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"
|
||||
@@ -110,6 +111,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")
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
@@ -65,6 +66,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
|
||||
@@ -489,6 +491,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
caps = append(caps, "tailscale")
|
||||
}
|
||||
|
||||
if dwlManager != nil {
|
||||
caps = append(caps, "dwl")
|
||||
}
|
||||
@@ -559,6 +565,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
caps = append(caps, "tailscale")
|
||||
}
|
||||
|
||||
if dwlManager != nil {
|
||||
caps = append(caps, "dwl")
|
||||
}
|
||||
@@ -1039,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
wg.Add(1)
|
||||
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
|
||||
|
||||
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")
|
||||
@@ -1409,11 +1451,22 @@ func cleanupManagers() {
|
||||
if geoClientInstance != nil {
|
||||
geoClientInstance.Close()
|
||||
}
|
||||
if tailscaleManager != nil {
|
||||
tailscaleManager.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func Start(printDocs bool) error {
|
||||
cleanupStaleSockets()
|
||||
|
||||
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
|
||||
// The capability is only advertised once tailscaled is reachable; the
|
||||
// callback wakes capability subscribers so QML clients see it transition.
|
||||
tailscaleManager = tailscale.NewManager("")
|
||||
tailscaleManager.SetAvailabilityCallback(func(bool) {
|
||||
notifyCapabilityChange()
|
||||
})
|
||||
|
||||
socketPath := GetSocketPath()
|
||||
os.Remove(socketPath)
|
||||
|
||||
|
||||
135
core/internal/server/tailscale/client.go
Normal file
135
core/internal/server/tailscale/client.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
|
||||
func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||
connected := status.BackendState == "Running"
|
||||
|
||||
state := &TailscaleState{
|
||||
Connected: connected,
|
||||
BackendState: status.BackendState,
|
||||
Version: status.Version,
|
||||
}
|
||||
|
||||
if status.CurrentTailnet != nil {
|
||||
state.TailnetName = status.CurrentTailnet.Name
|
||||
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
|
||||
}
|
||||
|
||||
if !connected {
|
||||
return state
|
||||
}
|
||||
|
||||
users := status.User
|
||||
|
||||
if status.Self != nil {
|
||||
state.Self = convertPeerStatus(status.Self, users)
|
||||
}
|
||||
|
||||
if len(status.Peer) > 0 {
|
||||
peers := make([]Peer, 0, len(status.Peer))
|
||||
for _, ps := range status.Peer {
|
||||
peers = append(peers, convertPeerStatus(ps, 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
|
||||
}
|
||||
|
||||
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
|
||||
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
|
||||
dnsName := strings.TrimSuffix(ps.DNSName, ".")
|
||||
|
||||
// DNSName first label is unique per node; OS HostName is not.
|
||||
hostname := ps.HostName
|
||||
if dnsName != "" {
|
||||
parts := strings.SplitN(dnsName, ".", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
hostname = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
peer := Peer{
|
||||
ID: string(ps.ID),
|
||||
Hostname: hostname,
|
||||
DNSName: dnsName,
|
||||
OS: ps.OS,
|
||||
Online: ps.Online,
|
||||
Active: ps.Active,
|
||||
ExitNode: ps.ExitNode,
|
||||
Relay: ps.Relay,
|
||||
RxBytes: ps.RxBytes,
|
||||
TxBytes: ps.TxBytes,
|
||||
}
|
||||
|
||||
for _, ip := range ps.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if peer.TailscaleIP == "" {
|
||||
peer.TailscaleIP = ip.String()
|
||||
}
|
||||
} else {
|
||||
if peer.TailscaleIPv6 == "" {
|
||||
peer.TailscaleIPv6 = ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ps.Tags != nil {
|
||||
peer.Tags = ps.Tags.AsSlice()
|
||||
}
|
||||
|
||||
if ps.UserID > 0 {
|
||||
if user, ok := users[ps.UserID]; ok {
|
||||
peer.Owner = user.LoginName
|
||||
}
|
||||
}
|
||||
|
||||
if !ps.LastSeen.IsZero() {
|
||||
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
223
core/internal/server/tailscale/client_test.go
Normal file
223
core/internal/server/tailscale/client_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/mem"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
func makeTestStatus() *ipnstate.Status {
|
||||
return &ipnstate.Status{
|
||||
Version: "1.94.2",
|
||||
BackendState: "Running",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||
Name: "user@example.com",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
},
|
||||
Self: &ipnstate.PeerStatus{
|
||||
ID: "node1",
|
||||
HostName: "cachyos",
|
||||
DNSName: "cachyos.example.ts.net.",
|
||||
OS: "linux",
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.85.254.40"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||
},
|
||||
Online: true,
|
||||
UserID: 12345,
|
||||
},
|
||||
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
|
||||
ID: "node2",
|
||||
HostName: "thinkpad-x390",
|
||||
DNSName: "thinkpad-x390.example.ts.net.",
|
||||
OS: "linux",
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.97.21.17"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0::2"),
|
||||
},
|
||||
Online: true,
|
||||
Active: true,
|
||||
Relay: "fra",
|
||||
RxBytes: 1024,
|
||||
TxBytes: 2048,
|
||||
UserID: 12345,
|
||||
ExitNode: false,
|
||||
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
User: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
12345: {
|
||||
ID: 12345,
|
||||
LoginName: "user@example.com",
|
||||
DisplayName: "User",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatus_Running(t *testing.T) {
|
||||
status := makeTestStatus()
|
||||
state := convertStatus(status)
|
||||
|
||||
require.NotNil(t, state)
|
||||
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
|
||||
require.Len(t, state.Peers, 1)
|
||||
peer := state.Peers[0]
|
||||
assert.Equal(t, "thinkpad-x390", peer.Hostname)
|
||||
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
|
||||
assert.Equal(t, "fra", peer.Relay)
|
||||
assert.Equal(t, "user@example.com", peer.Owner)
|
||||
assert.Equal(t, int64(1024), peer.RxBytes)
|
||||
assert.True(t, peer.Online)
|
||||
}
|
||||
|
||||
func TestConvertStatus_NotRunning(t *testing.T) {
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Stopped",
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
assert.False(t, state.Connected)
|
||||
assert.Equal(t, "Stopped", state.BackendState)
|
||||
assert.Empty(t, state.Peers)
|
||||
}
|
||||
|
||||
func TestConvertStatus_NilSelf(t *testing.T) {
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, Peer{}, state.Self)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_Tags(t *testing.T) {
|
||||
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
|
||||
ps := &ipnstate.PeerStatus{
|
||||
ID: "node3",
|
||||
HostName: "k8s-node",
|
||||
DNSName: "k8s-node.example.ts.net.",
|
||||
OS: "linux",
|
||||
Online: false,
|
||||
Tags: &tags,
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "k8s-node", peer.Hostname)
|
||||
assert.Contains(t, peer.Tags, "tag:k8s")
|
||||
assert.Contains(t, peer.Tags, "tag:server")
|
||||
assert.Equal(t, "", peer.Owner)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
|
||||
// Hostname should always be derived from DNSName, not OS HostName
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "GL-MT6000",
|
||||
DNSName: "gl-mt6000-2.example.ts.net.",
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
|
||||
// When DNSName is empty, fall back to OS HostName
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "my-device",
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "my-device", peer.Hostname)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_LastSeen(t *testing.T) {
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "recent-node",
|
||||
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.NotEmpty(t, peer.LastSeen)
|
||||
assert.Contains(t, peer.LastSeen, "minutes ago")
|
||||
}
|
||||
|
||||
func TestPeerSorting(t *testing.T) {
|
||||
b1 := make([]byte, 32)
|
||||
b2 := make([]byte, 32)
|
||||
b2[0] = 1
|
||||
b3 := make([]byte, 32)
|
||||
b3[0] = 2
|
||||
|
||||
k1 := key.NodePublicFromRaw32(mem.B(b1))
|
||||
k2 := key.NodePublicFromRaw32(mem.B(b2))
|
||||
k3 := key.NodePublicFromRaw32(mem.B(b3))
|
||||
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||
k1: {HostName: "zebra", Online: false},
|
||||
k2: {HostName: "alpha", Online: true},
|
||||
k3: {HostName: "beta", Online: true},
|
||||
},
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
|
||||
// Online peers first (alpha, beta), then offline (zebra)
|
||||
require.Len(t, state.Peers, 3)
|
||||
assert.True(t, state.Peers[0].Online)
|
||||
assert.True(t, state.Peers[1].Online)
|
||||
assert.False(t, state.Peers[2].Online)
|
||||
assert.Equal(t, "alpha", state.Peers[0].Hostname)
|
||||
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
||||
assert.Equal(t, "zebra", state.Peers[2].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)
|
||||
})
|
||||
}
|
||||
}
|
||||
30
core/internal/server/tailscale/handlers.go
Normal file
30
core/internal/server/tailscale/handlers.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
|
||||
// HandleRequest routes an IPC request to the appropriate handler.
|
||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
switch req.Method {
|
||||
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"})
|
||||
}
|
||||
97
core/internal/server/tailscale/handlers_test.go
Normal file
97
core/internal/server/tailscale/handlers_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
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 handlerTestManager() *Manager {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
m := newManager(client)
|
||||
m.RefreshState()
|
||||
return m
|
||||
}
|
||||
|
||||
func TestHandleGetStatus(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestHandleRefresh(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
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)
|
||||
}
|
||||
|
||||
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
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")
|
||||
}
|
||||
277
core/internal/server/tailscale/manager.go
Normal file
277
core/internal/server/tailscale/manager.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
const (
|
||||
statusTimeout = 3 * time.Second
|
||||
debounceWindow = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
// tailscaleClient abstracts the Tailscale local API for testing.
|
||||
type tailscaleClient interface {
|
||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
Status(ctx context.Context) (*ipnstate.Status, error)
|
||||
}
|
||||
|
||||
// ipnBusWatcher abstracts the IPN bus watcher for testing.
|
||||
type ipnBusWatcher interface {
|
||||
Next() (ipn.Notify, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
|
||||
type localClientWrapper struct {
|
||||
client *local.Client
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return w.client.WatchIPNBus(ctx, mask)
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return w.client.Status(ctx)
|
||||
}
|
||||
|
||||
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||
type Manager struct {
|
||||
state *TailscaleState
|
||||
stateMutex sync.RWMutex
|
||||
subscribers syncmap.Map[string, chan TailscaleState]
|
||||
client tailscaleClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
watchWG sync.WaitGroup
|
||||
closed atomic.Bool
|
||||
dirty chan struct{}
|
||||
available atomic.Bool
|
||||
availabilityCallback atomic.Pointer[func(bool)]
|
||||
}
|
||||
|
||||
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
|
||||
func NewManager(socketPath string) *Manager {
|
||||
lc := &local.Client{Socket: socketPath}
|
||||
return newManager(&localClientWrapper{client: lc})
|
||||
}
|
||||
|
||||
func newManager(client tailscaleClient) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{
|
||||
state: &TailscaleState{},
|
||||
client: client,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
m.watchWG.Add(2)
|
||||
go m.watchLoop(ctx)
|
||||
go m.debounceLoop(ctx)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) watchLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
backoff := time.Second
|
||||
unreachableSent := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
watcher, err := m.client.WatchIPNBus(ctx, mask)
|
||||
if err != nil {
|
||||
if !unreachableSent {
|
||||
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
|
||||
unreachableSent = true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff = min(backoff*2, 30*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
unreachableSent = false
|
||||
backoff = time.Second
|
||||
log.Info("[Tailscale] Connected to IPN bus")
|
||||
m.markAvailable()
|
||||
|
||||
for {
|
||||
notify, err := watcher.Next()
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if notify.State == nil && notify.NetMap == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
watcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// debounceLoop coalesces rapid bus notifications into a single Status RPC
|
||||
// per debounceWindow, since NetMap events can fire many times per second
|
||||
// on busy tailnets.
|
||||
func (m *Manager) debounceLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.dirty:
|
||||
}
|
||||
|
||||
timer := time.NewTimer(debounceWindow)
|
||||
collecting := true
|
||||
for collecting {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.dirty:
|
||||
case <-timer.C:
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
m.fetchAndBroadcast(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(statusCtx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
|
||||
func (m *Manager) updateState(state *TailscaleState) {
|
||||
m.stateMutex.Lock()
|
||||
m.state = state
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
m.broadcastState(*state)
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastState(state TailscaleState) {
|
||||
if m.closed.Load() {
|
||||
return
|
||||
}
|
||||
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||
select {
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
|
||||
// at least once since the manager started. False means tailscaled appears
|
||||
// to not be installed or has never been running.
|
||||
func (m *Manager) IsAvailable() bool {
|
||||
return m.available.Load()
|
||||
}
|
||||
|
||||
// SetAvailabilityCallback registers a callback fired when the manager
|
||||
// transitions from unavailable to available. Replaces any previously set
|
||||
// callback. Must be set before the manager has a chance to detect tailscaled.
|
||||
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
|
||||
m.availabilityCallback.Store(&cb)
|
||||
}
|
||||
|
||||
func (m *Manager) markAvailable() {
|
||||
if m.available.Swap(true) {
|
||||
return
|
||||
}
|
||||
if cb := m.availabilityCallback.Load(); cb != nil {
|
||||
(*cb)(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.
|
||||
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.
|
||||
func (m *Manager) Unsubscribe(clientID string) {
|
||||
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
|
||||
close(val)
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watch loop and closes all subscriber channels.
|
||||
func (m *Manager) Close() {
|
||||
m.closed.Store(true)
|
||||
m.cancel()
|
||||
m.watchWG.Wait()
|
||||
|
||||
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||
close(ch)
|
||||
m.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshState triggers an immediate status fetch and broadcasts.
|
||||
func (m *Manager) RefreshState() {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
307
core/internal/server/tailscale/manager_test.go
Normal file
307
core/internal/server/tailscale/manager_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
|
||||
type mockWatcher struct {
|
||||
events []ipn.Notify
|
||||
idx int
|
||||
err error
|
||||
done chan struct{}
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher {
|
||||
return &mockWatcher{
|
||||
events: events,
|
||||
err: err,
|
||||
done: make(chan struct{}),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *mockWatcher) Next() (ipn.Notify, error) {
|
||||
w.mu.Lock()
|
||||
if w.idx < len(w.events) {
|
||||
n := w.events[w.idx]
|
||||
w.idx++
|
||||
w.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
if w.err != nil {
|
||||
err := w.err
|
||||
w.mu.Unlock()
|
||||
return ipn.Notify{}, err
|
||||
}
|
||||
w.mu.Unlock()
|
||||
select {
|
||||
case <-w.done:
|
||||
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||
case <-w.ctx.Done():
|
||||
return ipn.Notify{}, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *mockWatcher) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.closed {
|
||||
w.closed = true
|
||||
close(w.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockClient implements tailscaleClient for testing.
|
||||
type mockClient struct {
|
||||
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
statusFn func(ctx context.Context) (*ipnstate.Status, error)
|
||||
}
|
||||
|
||||
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return c.watchFn(ctx, mask)
|
||||
}
|
||||
|
||||
func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return c.statusFn(ctx)
|
||||
}
|
||||
|
||||
func runningStatus() *ipnstate.Status {
|
||||
return &ipnstate.Status{
|
||||
Version: "1.94.2",
|
||||
BackendState: "Running",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||
Name: "user@example.com",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
},
|
||||
Self: &ipnstate.PeerStatus{
|
||||
HostName: "cachyos",
|
||||
DNSName: "cachyos.example.ts.net.",
|
||||
OS: "linux",
|
||||
Online: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchLoop_StateChange(t *testing.T) {
|
||||
stateVal := ipn.Running
|
||||
statusCalled := make(chan struct{}, 4)
|
||||
var watchCount int32
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
watchCount++
|
||||
if watchCount == 1 {
|
||||
return newMockWatcher(ctx,
|
||||
[]ipn.Notify{{State: &stateVal}},
|
||||
fmt.Errorf("done"),
|
||||
), nil
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
select {
|
||||
case statusCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(statusCalled) > 0
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
state := m.GetState()
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "Running", state.BackendState)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
|
||||
func TestWatchLoop_CoalescesNotifies(t *testing.T) {
|
||||
stateVal := ipn.Running
|
||||
var statusCalls atomic.Int32
|
||||
|
||||
notifies := make([]ipn.Notify, 0, 20)
|
||||
for range 20 {
|
||||
notifies = append(notifies, ipn.Notify{State: &stateVal})
|
||||
}
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return newMockWatcher(ctx, notifies, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
statusCalls.Add(1)
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
// Wait for the debounce window to expire plus margin so the burst settles.
|
||||
time.Sleep(debounceWindow + 100*time.Millisecond)
|
||||
|
||||
calls := statusCalls.Load()
|
||||
assert.Less(t, int(calls), 5,
|
||||
"20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls)
|
||||
assert.Greater(t, int(calls), 0, "expected at least one Status RPC")
|
||||
}
|
||||
|
||||
func TestWatchLoop_Reconnect(t *testing.T) {
|
||||
watchCalled := make(chan struct{}, 4)
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
select {
|
||||
case watchCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if len(watchCalled) <= 1 {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
state := m.GetState()
|
||||
return state.BackendState == "Unreachable"
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(watchCalled) >= 2
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestManager_Subscribe(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
ch := m.Subscribe("test-1")
|
||||
assert.NotNil(t, ch)
|
||||
|
||||
ch2 := m.Subscribe("test-2")
|
||||
assert.NotNil(t, ch2)
|
||||
|
||||
m.Unsubscribe("test-1")
|
||||
m.Unsubscribe("test-2")
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
|
||||
ch := m.Subscribe("test")
|
||||
assert.NotNil(t, ch)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
m.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Availability(t *testing.T) {
|
||||
var watchAttempts atomic.Int32
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
n := watchAttempts.Add(1)
|
||||
if n == 1 {
|
||||
return nil, fmt.Errorf("tailscaled socket not found")
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
cbFired := make(chan bool, 1)
|
||||
m.SetAvailabilityCallback(func(b bool) {
|
||||
select {
|
||||
case cbFired <- b:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
assert.False(t, m.IsAvailable())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return m.IsAvailable()
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
|
||||
select {
|
||||
case b := <-cbFired:
|
||||
assert.True(t, b)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("availability callback did not fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_RefreshState(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
m.RefreshState()
|
||||
|
||||
state := m.GetState()
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
31
core/internal/server/tailscale/types.go
Normal file
31
core/internal/server/tailscale/types.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
|
||||
Ref {
|
||||
service: TailscaleService
|
||||
}
|
||||
|
||||
ccWidgetIcon: "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.myOnlinePeers.length
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("Online", "Tailscale filter: all online devices"),
|
||||
"count": TailscaleService.onlinePeers.length
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("All", "Tailscale filter: all devices"),
|
||||
"count": TailscaleService.allPeersList.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: {
|
||||
let base;
|
||||
switch (detailRoot.filterIndex) {
|
||||
case 0:
|
||||
base = TailscaleService.myOnlinePeers;
|
||||
break;
|
||||
case 1:
|
||||
base = TailscaleService.onlinePeers;
|
||||
break;
|
||||
case 2:
|
||||
base = TailscaleService.allPeersList;
|
||||
break;
|
||||
default:
|
||||
base = [];
|
||||
}
|
||||
if (detailRoot.searchQuery.length > 0)
|
||||
return TailscaleService.searchPeers(detailRoot.searchQuery, base);
|
||||
return base;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -918,6 +918,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: {
|
||||
|
||||
@@ -10,6 +10,7 @@ QtObject {
|
||||
|
||||
property var vpnBuiltinInstance: null
|
||||
property var cupsBuiltinInstance: null
|
||||
property var tailscaleBuiltinInstance: null
|
||||
|
||||
property var vpnLoader: Loader {
|
||||
active: false
|
||||
@@ -63,6 +64,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) {
|
||||
root.log.debug("No Tailscale widget in control center, deactivating loader");
|
||||
tailscaleLoader.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var coreWidgetDefinitions: [
|
||||
{
|
||||
"id": "nightMode",
|
||||
@@ -202,6 +232,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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ Singleton {
|
||||
signal clipboardStateUpdate(var data)
|
||||
signal locationStateUpdate(var data)
|
||||
signal sysupdateStateUpdate(var data)
|
||||
signal tailscaleStateUpdate(var data)
|
||||
|
||||
property bool capsLockState: false
|
||||
property bool screensaverInhibited: false
|
||||
@@ -398,6 +399,8 @@ Singleton {
|
||||
locationStateUpdate(data);
|
||||
} else if (service === "sysupdate") {
|
||||
sysupdateStateUpdate(data);
|
||||
} else if (service === "tailscale") {
|
||||
tailscaleStateUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
181
quickshell/Services/TailscaleService.qml
Normal file
181
quickshell/Services/TailscaleService.qml
Normal file
@@ -0,0 +1,181 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("TailscaleService")
|
||||
|
||||
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 var allPeersList: {
|
||||
const result = [];
|
||||
if (selfNode)
|
||||
result.push(selfNode);
|
||||
if (peers)
|
||||
result.push(...peers);
|
||||
return result;
|
||||
}
|
||||
|
||||
readonly property var onlinePeers: allPeersList.filter(p => p.online)
|
||||
|
||||
readonly property var myPeers: {
|
||||
if (!selfNode)
|
||||
return allPeersList;
|
||||
return allPeersList.filter(p => isMine(p));
|
||||
}
|
||||
|
||||
readonly property var myOnlinePeers: {
|
||||
if (!selfNode)
|
||||
return onlinePeers;
|
||||
return allPeersList.filter(p => p.online && isMine(p));
|
||||
}
|
||||
|
||||
readonly property int onlinePeerCount: onlinePeers.length
|
||||
|
||||
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) {
|
||||
root.log.debug("Subscription update received");
|
||||
updateState(data);
|
||||
}
|
||||
|
||||
function onCapabilitiesReceived() {
|
||||
checkDMSCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
function checkDMSCapabilities() {
|
||||
if (!DMSService.isConnected)
|
||||
return;
|
||||
if (DMSService.capabilities.length === 0)
|
||||
return;
|
||||
const wasAvailable = available;
|
||||
available = DMSService.capabilities.includes("tailscale");
|
||||
|
||||
if (!available)
|
||||
return;
|
||||
if (!stateInitialized) {
|
||||
stateInitialized = true;
|
||||
getStatus();
|
||||
}
|
||||
if (!wasAvailable)
|
||||
ensureSubscription();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function isMine(peer) {
|
||||
const myOwner = selfNode ? (selfNode.owner || "") : "";
|
||||
if (peer.owner === myOwner && myOwner !== "")
|
||||
return true;
|
||||
if (peer.tags && peer.tags.length > 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function searchPeers(query, list) {
|
||||
const base = list || allPeersList;
|
||||
if (!query || query.length === 0)
|
||||
return base;
|
||||
const q = query.toLowerCase();
|
||||
return base.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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14764,5 +14764,107 @@
|
||||
"context": "↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help",
|
||||
"reference": "Modals/Clipboard/ClipboardKeyboardHints.qml:30",
|
||||
"comment": "Keyboard hints when enter-to-paste is enabled"
|
||||
},
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17225,5 +17225,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": ""
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user