Overhaul internal polling mechanism. Most time-sensative updates now use the IPN bus watcher instead of polling manually. The UI should now update based on changing daemon state a lot faster.
+
Fix logging in via a browser.
+
Add login button to offline page when not logged in.
+
Update Tailscale client to v1.84.0.
+
+
+
diff --git a/go.mod b/go.mod
index 4bc600c..39cc518 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
github.com/klauspost/compress v1.18.0
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.40.0
- tailscale.com v1.82.5
+ tailscale.com v1.84.0
)
require (
@@ -21,35 +21,61 @@ require (
github.com/KarpelesLab/weak v0.1.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
+ github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
+ github.com/aws/smithy-go v1.22.3 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e // indirect
+ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
+ github.com/gaissmai/bart v0.20.4 // indirect
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+ github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/nftables v0.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/csrf v1.7.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
+ github.com/illarion/gonotify/v3 v3.0.2 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
+ github.com/mdlayher/sdnotify v1.0.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/miekg/dns v1.1.66 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/peterbourgon/ff/v3 v3.4.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/safchain/ethtool v0.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
+ github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403 // indirect
+ github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 // indirect
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
+ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
+ github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 // indirect
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -63,12 +89,15 @@ require (
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
+ golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.33.0 // indirect
+ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
honnef.co/go/tools v0.6.1 // indirect
k8s.io/client-go v0.33.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index 182424d..890381d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
+9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
deedles.dev/mk v0.1.0 h1:xrvuJA3+R/j6/6AZPc+o31I1rotdKLrAYJxhZJwdOuc=
deedles.dev/mk v0.1.0/go.mod h1:TSFsz0T+BvhNqJae0yrj+KadkN4elx248PCpq2Ol4ME=
deedles.dev/tray v0.1.9 h1:xPYh0tOpYkSQN2Pjt9t72qtMEPgOEi9jmjUZ5ZR0yhw=
@@ -16,12 +18,46 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
+github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
+github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
+github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 h1:KWArCwA/WkuHWKfygkNz0B6YS6OvdgoJUaJHX0Qby1s=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8=
+github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
+github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
+github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
+github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
+github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
+github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
+github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
+github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
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-20240820181039-f2b84150679e h1:L+XrFvD0vBIBm+Wf9sFN6aU395t7JROoai0qXZraA4U=
@@ -30,16 +66,26 @@ github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250310094704-65bb91d1403f h1
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250310094704-65bb91d1403f/go.mod h1:fkvdR7MYO1sI0ex07VYLTc+YK87v24aRFYyMJQ/xAeA=
github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg=
github.com/diamondburned/gotk4/pkg v0.3.1/go.mod h1:DqeOW+MxSZFg9OO+esk4JgQk0TiUJJUBfMltKhG+ub4=
+github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
+github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
+github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
+github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
+github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
+github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
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/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
-github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
-github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
+github.com/gaissmai/bart v0.20.4 h1:Ik47r1fy3jRVU+1eYzKSW3ho2UgBVTVnUS8O993584U=
+github.com/gaissmai/bart v0.20.4/go.mod h1:cEed+ge8dalcbpi8wtS9x9m2hn/fNJH5suhdGQOHnYk=
+github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
+github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
+github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM=
github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@@ -49,6 +95,8 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
+github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
@@ -65,12 +113,20 @@ github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeV
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
+github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
+github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
+github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
+github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
+github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -86,34 +142,64 @@ github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy5
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
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/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
+github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
+github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
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/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
+github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/safchain/ethtool v0.6.0 h1:38VicU4p9ewEQFLemCFiGsknSMn7S3xXEzxaGYTjcn4=
+github.com/safchain/ethtool v0.6.0/go.mod h1:JzoNbG8xeg/BeVeVoMCtCb3UPWoppZZbFpA+1WFh+M0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
+github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
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/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
+github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403 h1:tB2UwtefWtWjIOp5UjU2eHPdP1EY3JZyAkes6WOsvIo=
github.com/tailscale/goupnp v1.0.1-0.20210804030727-66b27ba4e403/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
+github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33 h1:idh63uw+gsG05HwjZsAENCG4KZfyvjK03bpjxa5qRRk=
+github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
+github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
+github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
-github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
-github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
+github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
+github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
+github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U=
+github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
+github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
+github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
+github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
+github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
+github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
+github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
+github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
+github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
@@ -131,6 +217,8 @@ golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 h1:VI4qDpTkfFaCXEPrbojidLgVQhj2x4nzTccG0hjaLlU=
golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
+golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
+golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
@@ -142,9 +230,12 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
@@ -172,5 +263,5 @@ sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
-tailscale.com v1.82.5 h1:p5owmyPoPM1tFVHR3LjquFuLfpZLzafvhe5kjVavHtE=
-tailscale.com v1.82.5/go.mod h1:iU6kohVzG+bP0/5XjqBAnW8/6nSG/Du++bO+x7VJZD0=
+tailscale.com v1.84.0 h1:WzelL3/TXAAN+Vv5UyK0n0JCOL9n0qpjRL4tjVEA1Ok=
+tailscale.com v1.84.0/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=
diff --git a/internal/tray/tray.go b/internal/tray/tray.go
index 4e3554e..f58b7f3 100644
--- a/internal/tray/tray.go
+++ b/internal/tray/tray.go
@@ -56,7 +56,7 @@ type Tray struct {
quitItem *tray.MenuItem
}
-func (t *Tray) Start(s *tsutil.Status) error {
+func (t *Tray) Start(s *tsutil.IPNStatus) error {
if t.item != nil {
return nil
}
@@ -100,18 +100,18 @@ func (t *Tray) Close() error {
return err
}
-func (t *Tray) Update(s *tsutil.Status) {
+func (t *Tray) Update(status *tsutil.IPNStatus) {
if t == nil || t.item == nil {
return
}
- selfTitle, connected := selfTitle(s)
+ selfTitle, connected := selfTitle(status)
- t.updateStatusIcon(s)
+ t.updateStatusIcon(status)
- t.connToggleItem.SetProps(tray.MenuItemLabel(connToggleText(s.Online())))
+ t.connToggleItem.SetProps(tray.MenuItemLabel(connToggleText(status.Online())))
t.exitToggleItem.SetProps(
- tray.MenuItemLabel(exitToggleText(s)),
+ tray.MenuItemLabel(exitToggleText(status)),
tray.MenuItemEnabled(connected),
)
t.selfNodeItem.SetProps(
@@ -120,7 +120,7 @@ func (t *Tray) Update(s *tsutil.Status) {
)
}
-func (t *Tray) updateStatusIcon(s *tsutil.Status) {
+func (t *Tray) updateStatusIcon(s *tsutil.IPNStatus) {
newIcon := statusIcon(s)
if newIcon == t.icon {
return
@@ -130,26 +130,23 @@ func (t *Tray) updateStatusIcon(s *tsutil.Status) {
t.item.SetProps(tray.ItemIconPixmap(newIcon))
}
-func statusIcon(s *tsutil.Status) *tray.Pixmap {
+func statusIcon(s *tsutil.IPNStatus) *tray.Pixmap {
if !s.Online() {
return &statusIconInactive
}
- if s.Status.ExitNodeStatus != nil {
+ if s.ExitNodeActive() {
return &statusIconExitNode
}
return &statusIconActive
}
-func selfTitle(s *tsutil.Status) (string, bool) {
- addr, ok := s.SelfAddr()
- if !ok {
- if len(s.Status.Self.TailscaleIPs) == 0 {
- return "Address unknown", false
- }
+func selfTitle(s *tsutil.IPNStatus) (string, bool) {
+ addr := s.SelfAddr()
+ if !addr.IsValid() {
return "Not connected", false
}
- return fmt.Sprintf("%v (%v)", tsutil.DNSOrQuoteHostname(s.Status, s.Status.Self), addr), true
+ return fmt.Sprintf("%v (%v)", s.NetMap.SelfNode.DisplayName(true), addr), true
}
func connToggleText(online bool) string {
@@ -160,8 +157,8 @@ func connToggleText(online bool) string {
return "Connect"
}
-func exitToggleText(s *tsutil.Status) string {
- if s.Status != nil && s.Status.ExitNodeStatus != nil {
+func exitToggleText(s *tsutil.IPNStatus) string {
+ if s.ExitNodeActive() {
// TODO: Show some actual information about the current exit node?
return "Disable exit node"
}
diff --git a/internal/tsutil/client.go b/internal/tsutil/client.go
index bafb12d..6e39548 100644
--- a/internal/tsutil/client.go
+++ b/internal/tsutil/client.go
@@ -17,10 +17,12 @@ import (
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
+ "tailscale.com/util/eventbus"
)
var (
localClient local.Client
+ bus = eventbus.New()
monitor = initMonitor()
netcheckClient = netcheck.Client{
NetMon: monitor,
@@ -29,7 +31,7 @@ var (
)
func initMonitor() *netmon.Monitor {
- monitor, err := netmon.New(logger.Discard)
+ monitor, err := netmon.New(bus, logger.Discard)
if err != nil {
slog.Error("init netmon monitor", "err", err)
}
@@ -63,9 +65,9 @@ func Stop(ctx context.Context) error {
}
// ExitNode uses the specified peer as an exit node, or unsets
-// an existing exit node if peer is nil.
-func ExitNode(ctx context.Context, peer *ipnstate.PeerStatus) error {
- if peer == nil {
+// an existing exit node if peer is an empty string.
+func ExitNode(ctx context.Context, peer tailcfg.StableNodeID) error {
+ if peer == "" {
var prefs ipn.Prefs
prefs.ClearExitNode()
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
@@ -79,17 +81,12 @@ func ExitNode(ctx context.Context, peer *ipnstate.PeerStatus) error {
return nil
}
- status, err := localClient.Status(ctx)
- if err != nil {
- return fmt.Errorf("get status: %w", err)
+ prefs := ipn.Prefs{
+ ExitNodeID: peer,
}
-
- var prefs ipn.Prefs
- prefs.SetExitNodeIP(peer.TailscaleIPs[0].String(), status)
- _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
+ _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: prefs,
ExitNodeIDSet: true,
- ExitNodeIPSet: true,
})
if err != nil {
return fmt.Errorf("edit prefs: %w", err)
@@ -247,10 +244,18 @@ func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
return localClient.AwaitWaitingFiles(ctx, time.Second)
}
-func ProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile, error) {
+func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
+ return localClient.FileTargets(ctx)
+}
+
+func GetProfileStatus(ctx context.Context) (ipn.LoginProfile, []ipn.LoginProfile, error) {
return localClient.ProfileStatus(ctx)
}
func SwitchProfile(ctx context.Context, id ipn.ProfileID) error {
return localClient.SwitchProfile(ctx, id)
}
+
+func StartLogin(ctx context.Context) error {
+ return localClient.StartLoginInteractive(ctx)
+}
diff --git a/internal/tsutil/poller.go b/internal/tsutil/poller.go
index 1879fae..b7a7ff6 100644
--- a/internal/tsutil/poller.go
+++ b/internal/tsutil/poller.go
@@ -3,7 +3,9 @@ package tsutil
import (
"context"
"errors"
+ "io"
"log/slog"
+ "maps"
"net/netip"
"os/user"
"slices"
@@ -11,11 +13,13 @@ import (
"time"
"deedles.dev/mk"
+ "deedles.dev/trayscale/internal/xnetip"
"tailscale.com/client/tailscale/apitype"
+ "tailscale.com/feature/taildrop"
"tailscale.com/ipn"
- "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
- "tailscale.com/taildrop"
+ "tailscale.com/types/netmap"
+ "tailscale.com/util/set"
)
// A Poller gets the latest Tailscale status at regular intervals or
@@ -33,18 +37,21 @@ type Poller struct {
// If non-nil, New will be called when a new status is received from
// Tailscale.
- New func(*Status)
+ New func(Status)
+
+ once sync.Once
- once sync.Once
poll chan struct{}
- get chan *Status
+ getIPN chan *IPNStatus
+ nextIPN chan *IPNStatus
interval chan time.Duration
}
func (p *Poller) init() {
p.once.Do(func() {
mk.Chan(&p.poll, 0)
- mk.Chan(&p.get, 0)
+ mk.Chan(&p.getIPN, 0)
+ mk.Chan(&p.nextIPN, 0)
mk.Chan(&p.interval, 0)
})
}
@@ -57,124 +64,210 @@ func (p *Poller) init() {
func (p *Poller) Run(ctx context.Context) {
p.init()
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ n := newNotifier()
+ go p.watchIPN(ctx)
+ go p.watchFiles(ctx, n)
+ go p.watchProfiles(ctx, n)
+
interval := p.Interval
if interval < 0 {
interval = 5 * time.Second
}
- retry := interval
check := time.NewTicker(interval)
defer check.Stop()
for {
- status, err := GetStatus(ctx)
- if err != nil {
- if ctx.Err() != nil {
- return
- }
- slog.Error("get Tailscale status", "err", err)
+ select {
+ case <-ctx.Done():
+ return
+ case p.poll <- struct{}{}:
+ n = n.Notify()
+ check.Reset(interval)
+ case interval = <-p.interval:
+ n = n.Notify()
+ check.Reset(interval)
+ case <-check.C:
+ n = n.Notify()
+ }
+ }
+}
+
+func (p *Poller) watchIPN(ctx context.Context) {
+ const watcherOpts = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyNoPrivateKeys | ipn.NotifyWatchEngineUpdates
+
+watch:
+ watcher, err := localClient.WatchIPNBus(ctx, watcherOpts)
+ if err != nil {
+ slog.Error("start IPN bus watcher", "err", err)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(5 * time.Second):
+ goto watch
+ }
+ }
+ defer watcher.Close()
+
+ set := make(chan *IPNStatus)
+ go func() {
+ var get chan *IPNStatus
+ var s *IPNStatus
+ for {
select {
case <-ctx.Done():
return
- case <-time.After(retry):
- if retry < 30*time.Second {
- retry *= 2
- }
- continue
+ case s = <-set:
+ get = p.getIPN
+ p.New(s)
+ case get <- s:
}
}
+ }()
- prefs, err := Prefs(ctx)
+ var s IPNStatus
+ for {
+ notify, err := watcher.Next()
if err != nil {
if ctx.Err() != nil {
return
}
- slog.Error("get Tailscale prefs", "err", err)
- select {
- case <-ctx.Done():
- return
- case <-time.After(retry):
- if retry < 30*time.Second {
- retry *= 2
- }
- continue
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ goto watch
}
+ slog.Error("get next IPN bus notification", "err", err)
+ continue
}
- profile, profiles, err := ProfileStatus(ctx)
- if err != nil {
- if ctx.Err() != nil {
- return
+ if notify.ErrMessage != nil {
+ var state ipn.State
+ if notify.State != nil {
+ state = *notify.State
}
- slog.Error("get profile status", "err", err)
- select {
- case <-ctx.Done():
+ slog.Error("watcher got error message", "state", state, "err", notify.ErrMessage)
+ }
+
+ var dirty bool
+ if notify.State != nil {
+ s.State = *notify.State
+ dirty = true
+ }
+ if notify.Prefs != nil && notify.Prefs.Valid() {
+ s.Prefs = *notify.Prefs
+ dirty = true
+ }
+ if notify.NetMap != nil {
+ s.NetMap = notify.NetMap
+ s.rebuildPeers(ctx)
+ dirty = true
+ }
+ if notify.Engine != nil {
+ s.Engine = notify.Engine
+ dirty = true
+ }
+ if notify.BrowseToURL != nil {
+ s.BrowseToURL = *notify.BrowseToURL
+ dirty = true
+ }
+ // TODO: Handle health warnings.
+ if !dirty {
+ continue
+ }
+
+ select {
+ case <-ctx.Done():
+ return
+ case <-p.poll:
+ }
+
+ c := s.copy()
+ select {
+ case <-ctx.Done():
+ return
+ case set <- c:
+ }
+ select {
+ case p.nextIPN <- c:
+ default:
+ }
+ }
+}
+
+func (p *Poller) watchFiles(ctx context.Context, n *notifier) {
+ for {
+ files, err := WaitingFiles(ctx)
+ if err != nil && !errors.Is(err, taildrop.ErrNoTaildrop) {
+ if ctx.Err() != nil {
return
- case <-time.After(retry):
- if retry < 30*time.Second {
- retry *= 2
- }
- continue
}
+ slog.Error("get waiting files", "err", err)
+ goto wait
}
- retry = interval
+ p.New(&FileStatus{Files: files})
- var files []apitype.WaitingFile
- if status.Self.HasCap(tailcfg.CapabilityFileSharing) {
- files, err = WaitingFiles(ctx)
- if err != nil && !errors.Is(err, taildrop.ErrNoTaildrop) {
- if ctx.Err() != nil {
- return
- }
- slog.Error("get waiting files", "err", err)
- }
+ wait:
+ select {
+ case <-ctx.Done():
+ return
+ case <-n.notify:
+ n = n.next
}
+ }
+}
- s := &Status{Status: status, Prefs: prefs, Files: files, Profile: profile, Profiles: profiles}
- if p.New != nil {
- // TODO: Only call this if the status changed from the previous
- // poll? Is that remotely feasible?
- p.New(s)
+func (p *Poller) watchProfiles(ctx context.Context, n *notifier) {
+ for {
+ profile, profiles, err := GetProfileStatus(ctx)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ slog.Error("get profile status", "err", err)
+ goto wait
}
- send:
+ p.New(&ProfileStatus{Profile: profile, Profiles: profiles})
+
+ wait:
select {
case <-ctx.Done():
return
- case <-check.C:
- case <-p.poll:
- check.Reset(interval)
- case interval = <-p.interval:
- check.Reset(interval)
- goto send
- case p.get <- s:
- goto send // I've never used a goto before.
+ case <-n.notify:
+ n = n.next
}
}
}
-// Poll returns a channel that, when sent to, causes a new status to
-// be fetched from Tailscale. A send to the channel does not resolve
-// until the poller begins to fetch the status, meaning that a send to
-// Poll followed immediately by a receive from Get will always result
-// in the new Status.
-//
-// Do not close the returned channel. Doing so will result in
-// undefined behavior.
-func (p *Poller) Poll() chan<- struct{} {
+// Poll returns a channel that, when received from, causes a new
+// status to be fetched from Tailscale.
+func (p *Poller) Poll() <-chan struct{} {
p.init()
return p.poll
}
-// Get returns a channel that will yield the latest Status fetched. If
-// a new Status is in the process of being fetched, it will wait for
-// that to finish and then yield that.
-func (p *Poller) Get() <-chan *Status {
+// GetIPN returns a channel that yields the most recently fetched
+// network status. It will block until the network status has been
+// fetched successfully once.
+func (p *Poller) GetIPN() <-chan *IPNStatus {
p.init()
- return p.get
+ return p.getIPN
+}
+
+// NextIPN returns a channel that is sent the new IPNStatus each time
+// it is available if anyone is receiving from it. Unlike [GetIPN],
+// this channel does not yield the previous status, so it is useful if
+// an update is expected to arrive soon. Most usages should use
+// [GetIPN] instead as it significantly faster.
+func (p *Poller) NextIPN() <-chan *IPNStatus {
+ p.init()
+
+ return p.nextIPN
}
// SetInterval returns a channel that modifies the polling interval of
@@ -186,46 +279,118 @@ func (p *Poller) SetInterval() chan<- time.Duration {
return p.interval
}
-// Status is a type that wraps various status-related types that
-// Tailscale provides.
-type Status struct {
- Status *ipnstate.Status
- Prefs *ipn.Prefs
- Files []apitype.WaitingFile
- Profile ipn.LoginProfile
- Profiles []ipn.LoginProfile
+type Status any
+
+type IPNStatus struct {
+ State ipn.State
+ Prefs ipn.PrefsView
+ NetMap *netmap.NetworkMap
+ Peers map[tailcfg.StableNodeID]tailcfg.NodeView
+ FileTargets set.Set[tailcfg.StableNodeID]
+ Engine *ipn.EngineStatus
+ BrowseToURL string
+}
+
+func (s IPNStatus) copy() *IPNStatus {
+ s.Peers = maps.Clone(s.Peers)
+ s.FileTargets = maps.Clone(s.FileTargets)
+ return &s
+}
+
+func (s *IPNStatus) rebuildPeers(ctx context.Context) {
+ // This is a lot longer than it probably should be. It's basically
+ // just to make sure that the poller doesn't get completely stuck. If
+ // this is getting hit, though, the UI is going to be updating
+ // horribly slow.
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ if s.Peers == nil {
+ mk.Map(&s.Peers, 0)
+ }
+ clear(s.Peers)
+ for _, peer := range s.NetMap.Peers {
+ s.Peers[peer.StableID()] = peer
+ }
+
+ targets, err := FileTargets(ctx)
+ if err != nil {
+ slog.Error("failed to get file targets", "err", err)
+ return
+ }
+ s.FileTargets.Make()
+ clear(s.FileTargets)
+ for _, target := range targets {
+ s.FileTargets.Add(target.Node.StableID)
+ }
}
// Online returns true if s indicates that the local node is online
// and connected to the tailnet.
-func (s *Status) Online() bool {
- return (s.Status != nil) && (s.Status.BackendState == ipn.Running.String())
+func (s *IPNStatus) Online() bool {
+ return s.State == ipn.Running
+}
+
+func (s *IPNStatus) NeedsAuth() bool {
+ return s.State == ipn.NeedsLogin
}
-func (s *Status) NeedsAuth() bool {
- return (s.Status != nil) && (s.Status.BackendState == ipn.NeedsLogin.String())
+func (s *IPNStatus) ExitNodeActive() bool {
+ return s.Prefs.ExitNodeID() != "" || s.Prefs.ExitNodeIP().IsValid()
}
-func (s *Status) OperatorIsCurrent() bool {
+func (s *IPNStatus) ExitNode() tailcfg.NodeView {
+ if node, ok := s.Peers[s.Prefs.ExitNodeID()]; ok {
+ return node
+ }
+ if addr := s.Prefs.ExitNodeIP(); addr.IsValid() {
+ peer, _ := s.NetMap.PeerByTailscaleIP(addr)
+ return peer
+ }
+ return tailcfg.NodeView{}
+}
+
+func (s *IPNStatus) OperatorIsCurrent() bool {
current, err := user.Current()
if err != nil {
slog.Error("get current user", "err", err)
return false
}
- return s.Prefs.OperatorUser == current.Username
+ return s.Prefs.OperatorUser() == current.Username
}
-func (s *Status) SelfAddr() (netip.Addr, bool) {
- if s.Status == nil {
- return netip.Addr{}, false
- }
- if s.Status.Self == nil {
- return netip.Addr{}, false
+func (s *IPNStatus) SelfAddr() netip.Addr {
+ if s.NetMap == nil || s.NetMap.SelfNode.Addresses().Len() == 0 {
+ return netip.Addr{}
}
- if len(s.Status.Self.TailscaleIPs) == 0 {
- return netip.Addr{}, false
+
+ // TODO: Don't copy the slice.
+ return slices.MinFunc(s.NetMap.SelfNode.Addresses().AsSlice(), xnetip.ComparePrefixes).Addr()
+}
+
+type FileStatus struct {
+ Files []apitype.WaitingFile
+}
+
+type ProfileStatus struct {
+ Profile ipn.LoginProfile
+ Profiles []ipn.LoginProfile
+}
+
+type notifier struct {
+ notify chan struct{}
+ next *notifier
+}
+
+func newNotifier() *notifier {
+ return ¬ifier{
+ notify: make(chan struct{}),
}
+}
- return slices.MinFunc(s.Status.Self.TailscaleIPs, netip.Addr.Compare), true
+func (n *notifier) Notify() *notifier {
+ n.next = newNotifier()
+ close(n.notify)
+ return n.next
}
diff --git a/internal/tsutil/tsutil.go b/internal/tsutil/tsutil.go
index 3a268d2..68a17ae 100644
--- a/internal/tsutil/tsutil.go
+++ b/internal/tsutil/tsutil.go
@@ -2,53 +2,48 @@ package tsutil
import (
"cmp"
- "fmt"
- "strings"
- "golang.org/x/net/idna"
"tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
- "tailscale.com/util/dnsname"
)
// DNSOrQuoteHostname returns a nicely printable version of a peer's name. The function is copied from
// https://github.com/tailscale/tailscale/blob/b0ed863d55d6b51569ce5c6bd0b7021338ce6a82/cmd/tailscale/cli/status.go#L285
-func DNSOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
- baseName := ps.DNSName
- if st.CurrentTailnet != nil {
- baseName = dnsname.TrimSuffix(baseName, st.CurrentTailnet.MagicDNSSuffix)
- }
- if baseName != "" {
- if strings.HasPrefix(baseName, "xn-") {
- if u, err := idna.ToUnicode(baseName); err == nil {
- return fmt.Sprintf("%s (%s)", baseName, u)
- }
- }
- return baseName
- }
- return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName))
-}
+//func DNSOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
+// baseName := ps.DNSName
+// if st.CurrentTailnet != nil {
+// baseName = dnsname.TrimSuffix(baseName, st.CurrentTailnet.MagicDNSSuffix)
+// }
+// if baseName != "" {
+// if strings.HasPrefix(baseName, "xn-") {
+// if u, err := idna.ToUnicode(baseName); err == nil {
+// return fmt.Sprintf("%s (%s)", baseName, u)
+// }
+// }
+// return baseName
+// }
+// return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName))
+//}
// IsMullvad returns true if peer is a Mullvad exit node.
-func IsMullvad(peer *ipnstate.PeerStatus) bool {
- return (peer.Tags != nil) && peer.Tags.ContainsFunc(func(tag string) bool {
+func IsMullvad(peer tailcfg.NodeView) bool {
+ return peer.Tags().ContainsFunc(func(tag string) bool {
return tag == "tag:mullvad-exit-node"
})
}
// CanMullvad returns true if peer is allowed to access Mullvad exit
// nodes.
-func CanMullvad(peer *ipnstate.PeerStatus) bool {
+func CanMullvad(peer tailcfg.NodeView) bool {
return peer.HasCap("mullvad")
}
// CompareLocations alphabestically compares the countries and then,
// if necessary, cities of two Locations.
-func CompareLocations(loc1, loc2 *tailcfg.Location) int {
+func CompareLocations(loc1, loc2 tailcfg.LocationView) int {
return cmp.Or(
- cmp.Compare(loc1.Country, loc2.Country),
- cmp.Compare(loc1.City, loc2.City),
+ cmp.Compare(loc1.Country(), loc2.Country()),
+ cmp.Compare(loc1.City(), loc2.City()),
)
}
@@ -57,15 +52,18 @@ func CompareLocations(loc1, loc2 *tailcfg.Location) int {
// deterministic order if their locations or hostnames are identical,
// so the result of calling this is never 0. To determine if peers are
// the same, compare their IDs manually.
-func ComparePeers(p1, p2 *ipnstate.PeerStatus) int {
+func ComparePeers(p1, p2 tailcfg.NodeView) int {
+ i1 := p1.Hostinfo()
+ i2 := p2.Hostinfo()
+
loc := 0
- if p1.Location != nil && p2.Location != nil {
- loc = CompareLocations(p1.Location, p2.Location)
+ if i1.Location().Valid() && i2.Location().Valid() {
+ loc = CompareLocations(i1.Location(), i2.Location())
}
return cmp.Or(
loc,
- cmp.Compare(p1.HostName, p2.HostName),
- cmp.Compare(p1.ID, p2.ID),
+ cmp.Compare(i1.Hostname(), i2.Hostname()),
+ cmp.Compare(p1.ID(), p2.ID()),
)
}
@@ -80,6 +78,6 @@ func CompareWaitingFiles(f1, f2 apitype.WaitingFile) int {
// CanReceiveFiles returns true if peer can be sent files via
// Taildrop.
-func CanReceiveFiles(peer *ipnstate.PeerStatus) bool {
- return peer.NoFileSharingReason == ""
-}
+//func CanReceiveFiles(peer tailcfg.NodeView) bool {
+// return peer.NoFileSharingReason == ""
+//}
diff --git a/internal/ui/app.go b/internal/ui/app.go
index a373bfb..24cf005 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -20,8 +20,7 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/inhies/go-bytesize"
"tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn"
- "tailscale.com/ipn/ipnstate"
+ "tailscale.com/tailcfg"
)
//go:embed app.css
@@ -40,7 +39,6 @@ type App struct {
spinnum int
operatorCheck bool
- profiles []ipn.LoginProfile
files *[]apitype.WaitingFile
}
@@ -78,44 +76,57 @@ func (a *App) stopSpin() {
})
}
-func (a *App) update(s *tsutil.Status) {
- online := s.Online()
- a.tray.Update(s)
- if a.online != online {
- a.online = online
-
- body := "Tailscale is not connected."
- if online {
- body = "Tailscale is connected."
+func (a *App) update(status tsutil.Status) {
+ switch status := status.(type) {
+ case *tsutil.IPNStatus:
+ online := status.Online()
+ a.tray.Update(status)
+ if a.online != online {
+ a.online = online
+
+ body := "Tailscale is not connected."
+ if online {
+ body = "Tailscale is connected."
+ }
+ a.notify("Tailscale Status", body) // TODO: Notify on startup if not connected?
}
- a.notify("Tailscale Status", body) // TODO: Notify on startup if not connected?
- }
- if a.files != nil {
- for _, file := range s.Files {
- if !slices.Contains(*a.files, file) {
- body := fmt.Sprintf("%v (%v)", file.Name, bytesize.ByteSize(file.Size))
- a.notify("New Incoming File", body)
+ if online && !a.operatorCheck {
+ a.operatorCheck = true
+ if !status.OperatorIsCurrent() {
+ Info{
+ Heading: "User is not Tailscale Operator",
+ Body: "Some functionality may not work as expected. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.",
+ }.Show(a, nil)
}
}
- }
- a.files = &s.Files
- a.profiles = s.Profiles
+ if !online {
+ a.files = nil
+ }
- if a.win == nil {
- return
- }
+ if a.win != nil {
+ a.win.Update(status)
+ }
- a.win.Update(s)
+ case *tsutil.FileStatus:
+ if a.files != nil {
+ for _, file := range status.Files {
+ if !slices.Contains(*a.files, file) {
+ body := fmt.Sprintf("%v (%v)", file.Name, bytesize.ByteSize(file.Size))
+ a.notify("New Incoming File", body)
+ }
+ }
+ }
+ a.files = &status.Files
- if a.online && !a.operatorCheck {
- a.operatorCheck = true
- if !s.OperatorIsCurrent() {
- Info{
- Heading: "User is not Tailscale Operator",
- Body: "Some functionality may not work as expected. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.",
- }.Show(a, nil)
+ if a.win != nil {
+ a.win.Update(status)
+ }
+
+ case *tsutil.ProfileStatus:
+ if a.win != nil {
+ a.win.Update(status)
}
}
}
@@ -159,7 +170,7 @@ func (a *App) init(ctx context.Context) {
}
func (a *App) startTS(ctx context.Context) error {
- status := <-a.poller.Get()
+ status := <-a.poller.GetIPN()
if status.NeedsAuth() {
Confirmation{
Heading: "Login Required",
@@ -168,7 +179,7 @@ func (a *App) startTS(ctx context.Context) error {
Reject: "_Cancel",
}.Show(a, func(accept bool) {
if accept {
- gtk.NewURILauncher(status.Status.AuthURL).Launch(ctx, &a.win.MainWindow.Window, nil)
+ a.app.ActivateAction("login", nil)
}
})
return nil
@@ -178,7 +189,7 @@ func (a *App) startTS(ctx context.Context) error {
if err != nil {
return err
}
- a.poller.Poll() <- struct{}{}
+ <-a.poller.Poll()
return nil
}
@@ -187,25 +198,25 @@ func (a *App) stopTS(ctx context.Context) error {
if err != nil {
return err
}
- a.poller.Poll() <- struct{}{}
+ <-a.poller.Poll()
return nil
}
func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) {
- type selectOption = SelectOption[*ipnstate.PeerStatus]
+ type selectOption = SelectOption[tailcfg.NodeView]
- s := <-a.poller.Get()
+ s := <-a.poller.GetIPN()
if !s.Online() {
return
}
options := func(yield func(selectOption) bool) {
- for _, peer := range s.Status.Peer {
- if tsutil.IsMullvad(peer) || !tsutil.CanReceiveFiles(peer) {
+ for _, peer := range s.Peers {
+ if !s.FileTargets.Contains(peer.StableID()) || tsutil.IsMullvad(peer) {
continue
}
option := selectOption{
- Title: tsutil.DNSOrQuoteHostname(s.Status, peer),
+ Title: peer.DisplayName(true),
Value: peer,
}
if !yield(option) {
@@ -214,7 +225,7 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) {
}
}
- Select[*ipnstate.PeerStatus]{
+ Select[tailcfg.NodeView]{
Heading: "Send file(s) to...",
Options: slices.SortedFunc(options, func(o1, o2 selectOption) int {
return cmp.Compare(o1.Title, o2.Title)
@@ -223,7 +234,7 @@ func (a *App) onAppOpen(ctx context.Context, files []gio.Filer) {
for _, option := range options {
a.notify("Taildrop", fmt.Sprintf("Sending %v file(s) to %v...", len(files), option.Title))
for _, file := range files {
- go a.pushFile(ctx, option.Value.ID, file)
+ go a.pushFile(ctx, option.Value.StableID(), file)
}
}
})
@@ -252,71 +263,63 @@ func (a *App) onAppActivate(ctx context.Context) {
a.app.AddAction(quitAction)
a.app.SetAccelsForAction("app.quit", []string{"q"})
- a.win = NewMainWindow(a)
-
- a.win.StatusSwitch.ConnectStateSet(func(s bool) bool {
- if s == a.win.StatusSwitch.State() {
- return false
- }
-
- // TODO: Handle this, and other switches, asynchrounously instead
- // of freezing the entire UI.
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
-
- f := a.stopTS
- if s {
- f = a.startTS
- }
-
- err := f(ctx)
- if err != nil {
- slog.Error("set Tailscale status", "err", err)
- a.win.StatusSwitch.SetActive(!s)
- return true
- }
- return true
- })
-
- a.win.ProfileDropDown.NotifyProperty("selected-item", func() {
- item := a.win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject).String()
- index := slices.IndexFunc(a.profiles, func(p ipn.LoginProfile) bool {
- // TODO: Find a reasonable way to do this by profile ID instead.
- return p.Name == item
- })
- if index < 0 {
- slog.Error("selected unknown profile", "name", item)
+ loginAction := gio.NewSimpleAction("login", nil)
+ loginAction.ConnectActivate(func(p *glib.Variant) {
+ status := <-a.poller.GetIPN()
+ if !status.OperatorIsCurrent() {
+ Info{
+ Heading: "User is not Tailscale Operator",
+ Body: "Login via Trayscale is not possible unless the current user is set as the operator. To resolve, run\nsudo tailscale set --operator=$USER\nin the command-line.",
+ }.Show(a, nil)
return
}
- profile := a.profiles[index]
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- err := tsutil.SwitchProfile(ctx, profile.ID)
+ err := tsutil.StartLogin(ctx)
if err != nil {
- slog.Error("failed to switch profiles", "err", err, "id", profile.ID, "name", profile.Name)
+ slog.Error("failed to start login", "err", err)
+ if a.win != nil {
+ a.win.Toast("Failed to start login")
+ }
return
}
- a.poller.Poll() <- struct{}{}
- })
- contentVariant := glib.NewVariantString("content")
- a.win.PeersStack.NotifyProperty("visible-child", func() {
- a.win.SplitView.ActivateAction("navigation.push", contentVariant)
+ for {
+ select {
+ case <-ctx.Done():
+ if a.win != nil {
+ a.win.Toast("Failed to start login")
+ }
+ return
+ case status := <-a.poller.NextIPN():
+ if status.BrowseToURL != "" {
+ gtk.NewURILauncher(status.BrowseToURL).Launch(ctx, a.window(), nil)
+ return
+ }
+ }
+ }
})
+ a.app.AddAction(loginAction)
+ a.win = NewMainWindow(a)
a.win.MainWindow.ConnectCloseRequest(func() bool {
a.win = nil
return false
})
- a.poller.Poll() <- struct{}{}
+
+ <-a.poller.Poll()
a.win.MainWindow.Present()
+
+ glib.IdleAdd(func() {
+ a.update(<-a.poller.GetIPN())
+ })
}
func (a *App) initTray(ctx context.Context) {
if a.tray != nil {
- err := a.tray.Start(<-a.poller.Get())
+ err := a.tray.Start(<-a.poller.GetIPN())
if err != nil {
slog.Error("failed to start tray icon", "err", err)
}
@@ -355,18 +358,14 @@ func (a *App) initTray(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
- s := <-a.poller.Get()
- if s.Status == nil {
- return
- }
- toggle := s.Status.ExitNodeStatus == nil
+ s := <-a.poller.GetIPN()
+ toggle := !s.ExitNodeActive()
err := tsutil.SetUseExitNode(ctx, toggle)
if err != nil {
a.notify("Toggle exit node", err.Error())
slog.Error("toggle exit node from tray", "err", err)
return
}
- a.poller.Poll() <- struct{}{}
if toggle {
a.notify("Exit node", "Enabled")
@@ -378,9 +377,9 @@ func (a *App) initTray(ctx context.Context) {
OnSelfNode: func() {
glib.IdleAdd(func() {
- s := <-a.poller.Get()
- addr, ok := s.SelfAddr()
- if !ok {
+ s := <-a.poller.GetIPN()
+ addr := s.SelfAddr()
+ if !addr.IsValid() {
return
}
a.clip(glib.NewValue(addr.String()))
@@ -395,7 +394,7 @@ func (a *App) initTray(ctx context.Context) {
},
}
- err := a.tray.Start(<-a.poller.Get())
+ err := a.tray.Start(<-a.poller.GetIPN())
if err != nil {
slog.Error("failed to start tray icon", "err", err)
}
@@ -424,7 +423,7 @@ func (a *App) Run(ctx context.Context) {
a.poller = &tsutil.Poller{
Interval: a.getInterval(),
- New: func(s *tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) },
+ New: func(s tsutil.Status) { glib.IdleAdd(func() { a.update(s) }) },
}
go a.poller.Run(ctx)
diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go
index 745d45e..65075e7 100644
--- a/internal/ui/dialogs.go
+++ b/internal/ui/dialogs.go
@@ -5,7 +5,7 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
-func (a *App) window() gtk.Widgetter {
+func (a *App) window() *gtk.Window {
if a == nil {
return nil
}
@@ -13,7 +13,7 @@ func (a *App) window() gtk.Widgetter {
return nil
}
- return a.win.MainWindow
+ return &a.win.MainWindow.Window
}
type Confirmation struct {
@@ -35,13 +35,15 @@ func (d Confirmation) Show(a *App, res func(bool)) {
res(response == "accept")
})
- dialog.Present(a.window())
+ dialog.Present(pointerToWidgetter(a.window()))
}
type Prompt struct {
- Heading string
- Body string
- Responses []PromptResponse
+ Heading string
+ Body string
+ Placeholder string
+ Purpose gtk.InputPurpose
+ Responses []PromptResponse
}
type PromptResponse struct {
@@ -52,10 +54,10 @@ type PromptResponse struct {
}
func (d Prompt) Show(a *App, initialValue string, res func(response, val string)) {
- input := gtk.NewText()
- if initialValue != "" {
- input.Buffer().SetText(initialValue, len(initialValue))
- }
+ input := gtk.NewEntry()
+ input.SetText(initialValue)
+ input.SetInputPurpose(d.Purpose)
+ input.SetPlaceholderText(d.Placeholder)
dialog := adw.NewAlertDialog(d.Heading, d.Body)
dialog.SetExtraChild(input)
@@ -71,14 +73,14 @@ func (d Prompt) Show(a *App, initialValue string, res func(response, val string)
}
dialog.ConnectResponse(func(response string) {
- res(response, input.Buffer().Text())
+ res(response, input.Text())
})
input.ConnectActivate(func() {
defer dialog.Close()
- res(def, input.Buffer().Text())
+ res(def, input.Text())
})
- dialog.Present(a.window())
+ dialog.Present(pointerToWidgetter(a.window()))
}
type Info struct {
@@ -98,7 +100,7 @@ func (d Info) Show(a *App, closed func()) {
})
}
- dialog.Present(a.window())
+ dialog.Present(pointerToWidgetter(a.window()))
}
type Select[T any] struct {
@@ -154,5 +156,5 @@ func (d Select[T]) Show(a *App, res func([]SelectOption[T])) {
res(selected)
})
- dialog.Present(a.window())
+ dialog.Present(pointerToWidgetter(a.window()))
}
diff --git a/internal/ui/io.go b/internal/ui/io.go
index 8a5971c..d927949 100644
--- a/internal/ui/io.go
+++ b/internal/ui/io.go
@@ -68,6 +68,6 @@ func (a *App) saveFile(ctx context.Context, name string, file gio.Filer) {
return
}
- a.poller.Poll() <- struct{}{}
+ <-a.poller.Poll()
slog.Info("done saving file")
}
diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go
index 1fb836f..5d9098d 100644
--- a/internal/ui/mainwindow.go
+++ b/internal/ui/mainwindow.go
@@ -1,19 +1,30 @@
package ui
import (
+ "context"
_ "embed"
+ "log/slog"
+ "slices"
"strings"
+ "time"
"deedles.dev/trayscale/internal/listmodels"
"deedles.dev/trayscale/internal/metadata"
"deedles.dev/trayscale/internal/tsutil"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gio/v2"
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "tailscale.com/ipn"
)
-//go:embed mainwindow.ui
-var mainWindowXML string
+var (
+ //go:embed mainwindow.ui
+ mainWindowXML string
+
+ //go:embed menu.ui
+ menuXML string
+)
type MainWindow struct {
app *App
@@ -29,11 +40,11 @@ type MainWindow struct {
ProfileDropDown *gtk.DropDown
PageMenuButton *gtk.MenuButton
- pages map[string]Page
- statusPage *adw.StatusPage
+ pages map[string]Page
- ProfileModel *gtk.StringList
- ProfileSortModel *gtk.SortListModel
+ profiles []ipn.LoginProfile
+ profileModel *gtk.StringList
+ profileSortModel *gtk.SortListModel
}
func NewMainWindow(app *App) *MainWindow {
@@ -41,15 +52,10 @@ func NewMainWindow(app *App) *MainWindow {
app: app,
pages: make(map[string]Page),
}
- fillFromBuilder(&win, mainWindowXML)
+ fillFromBuilder(&win, menuXML, mainWindowXML)
win.MainWindow.SetApplication(&app.app.Application)
- win.statusPage = adw.NewStatusPage()
- win.statusPage.SetTitle("Not Connected")
- win.statusPage.SetIconName("network-offline-symbolic")
- win.statusPage.SetDescription("Tailscale is not connected")
-
win.PeersStack.NotifyProperty("visible-child-name", func() {
page := win.pages[win.PeersStack.VisibleChildName()]
@@ -115,9 +121,66 @@ func NewMainWindow(app *App) *MainWindow {
win.PeersStack.SetVisibleChildName(name)
})
- win.ProfileModel = gtk.NewStringList(nil)
- win.ProfileSortModel = gtk.NewSortListModel(win.ProfileModel, &stringListSorter.Sorter)
- win.ProfileDropDown.SetModel(win.ProfileSortModel)
+ win.profileModel = gtk.NewStringList(nil)
+ win.profileSortModel = gtk.NewSortListModel(win.profileModel, &stringListSorter.Sorter)
+ win.ProfileDropDown.SetModel(win.profileSortModel)
+
+ win.StatusSwitch.ConnectStateSet(func(s bool) bool {
+ if s == win.StatusSwitch.State() {
+ return false
+ }
+
+ // TODO: Handle this, and other switches, asynchrounously instead
+ // of freezing the entire UI.
+ ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second)
+ defer cancel()
+
+ f := app.stopTS
+ if s {
+ f = app.startTS
+ }
+
+ err := f(ctx)
+ if err != nil {
+ slog.Error("set Tailscale status", "err", err)
+ win.StatusSwitch.SetActive(!s)
+ return true
+ }
+ return true
+ })
+
+ win.ProfileDropDown.NotifyProperty("selected-item", func() {
+ obj, ok := win.ProfileDropDown.SelectedItem().Cast().(*gtk.StringObject)
+ if !ok {
+ return
+ }
+
+ item := obj.String()
+ index := slices.IndexFunc(win.profiles, func(p ipn.LoginProfile) bool {
+ // TODO: Find a reasonable way to do this by profile ID instead.
+ return p.Name == item
+ })
+ if index < 0 {
+ slog.Error("selected unknown profile", "name", item)
+ return
+ }
+ profile := win.profiles[index]
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ err := tsutil.SwitchProfile(ctx, profile.ID)
+ if err != nil {
+ slog.Error("failed to switch profiles", "err", err, "id", profile.ID, "name", profile.Name)
+ return
+ }
+ <-app.poller.Poll()
+ })
+
+ contentVariant := glib.NewVariantString("content")
+ win.PeersStack.NotifyProperty("visible-child", func() {
+ win.SplitView.ActivateAction("navigation.push", contentVariant)
+ })
return &win
}
@@ -128,58 +191,60 @@ func (win *MainWindow) addPage(name string, page Page) *adw.ViewStackPage {
}
func (win *MainWindow) removePage(name string, page Page) {
+ var reselect bool
+ if win.PeersStack.VisibleChildName() == name {
+ reselect = true
+ }
+
delete(win.pages, name)
win.PeersStack.Remove(page.Widget())
+
+ if reselect {
+ win.PeersList.SelectRow(win.PeersList.RowAtIndex(0))
+ }
}
-func (win *MainWindow) Update(status *tsutil.Status) {
- online := status.Online()
- win.StatusSwitch.SetState(online)
- win.StatusSwitch.SetActive(online)
+func (win *MainWindow) Update(status tsutil.Status) {
+ switch status := status.(type) {
+ case *tsutil.IPNStatus:
+ online := status.Online()
+ win.StatusSwitch.SetState(online)
+ win.StatusSwitch.SetActive(online)
- win.updateProfiles(status)
- win.updatePeers(status)
-}
+ win.updatePeers(status)
-func (win *MainWindow) updatePeersOffline() {
- var found bool
- for name, page := range win.pages {
- if name == "status" {
- found = true
- continue
+ case *tsutil.FileStatus:
+ if self, ok := win.pages["self"].(*SelfPage); ok {
+ self.UpdateFiles(status)
}
- win.removePage(name, page)
- }
- if !found {
- vp := win.PeersStack.AddTitled(win.statusPage, "status", "Not Connected")
- vp.SetIconName("network-offline-symbolic")
+ case *tsutil.ProfileStatus:
+ win.updateProfiles(status)
}
}
-func (win *MainWindow) updatePeers(status *tsutil.Status) {
+func (win *MainWindow) updatePeers(status *tsutil.IPNStatus) {
if !status.Online() {
- win.updatePeersOffline()
+ if _, ok := win.pages["offline"]; !ok {
+ win.addPage("offline", NewOfflinePage(win.app))
+ }
+ win.updatePages(status)
return
}
- if win.PeersStack.ChildByName("status") != nil {
- win.PeersStack.Remove(win.statusPage)
- }
-
if _, ok := win.pages["self"]; !ok {
win.addPage("self", NewSelfPage(win.app, status))
}
- if _, ok := win.pages["mullvad"]; !ok && tsutil.CanMullvad(status.Status.Self) {
+ if _, ok := win.pages["mullvad"]; !ok && tsutil.CanMullvad(status.NetMap.SelfNode) {
win.addPage("mullvad", NewMullvadPage(win.app, status))
}
- for _, peer := range status.Status.Peer {
+ for id, peer := range status.Peers {
if tsutil.IsMullvad(peer) {
continue
}
- name := string(peer.ID)
+ name := string(id)
if _, ok := win.pages[name]; ok {
continue
}
@@ -187,6 +252,10 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) {
win.addPage(name, NewPeerPage(win.app, status, peer))
}
+ win.updatePages(status)
+}
+
+func (win *MainWindow) updatePages(status *tsutil.IPNStatus) {
var remove []string
for name, page := range win.pages {
ok := page.Update(status)
@@ -201,9 +270,10 @@ func (win *MainWindow) updatePeers(status *tsutil.Status) {
win.PeersList.InvalidateSort()
}
-func (win *MainWindow) updateProfiles(s *tsutil.Status) {
- listmodels.UpdateStrings(win.ProfileModel, func(yield func(string) bool) {
- for _, profile := range s.Profiles {
+func (win *MainWindow) updateProfiles(status *tsutil.ProfileStatus) {
+ win.profiles = status.Profiles
+ listmodels.UpdateStrings(win.profileModel, func(yield func(string) bool) {
+ for _, profile := range status.Profiles {
name := profile.Name
if metadata.Private {
name = "profile@example.com"
@@ -214,8 +284,8 @@ func (win *MainWindow) updateProfiles(s *tsutil.Status) {
}
})
- profileIndex, ok := listmodels.Index(win.ProfileSortModel, func(obj *gtk.StringObject) bool {
- return obj.String() == s.Profile.Name
+ profileIndex, ok := listmodels.Index(win.profileSortModel, func(obj *gtk.StringObject) bool {
+ return obj.String() == status.Profile.Name
})
if ok {
win.ProfileDropDown.SetSelected(uint(profileIndex))
diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui
index 874e2b6..fd2a105 100644
--- a/internal/ui/mainwindow.ui
+++ b/internal/ui/mainwindow.ui
@@ -4,7 +4,6 @@
-
-
-
- Last write
-
-
-
-
- Last handshake
diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui
index 75ea636..f598f39 100644
--- a/internal/ui/preferences.ui
+++ b/internal/ui/preferences.ui
@@ -1,5 +1,5 @@
-
+
diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go
index 8d85924..c1a930c 100644
--- a/internal/ui/selfpage.go
+++ b/internal/ui/selfpage.go
@@ -21,7 +21,7 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/inhies/go-bytesize"
"tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn/ipnstate"
+ "tailscale.com/tailcfg"
)
//go:embed selfpage.ui
@@ -30,7 +30,7 @@ var selfPageXML string
type SelfPage struct {
app *App
row *PageRow
- peer *ipnstate.PeerStatus
+ peer tailcfg.NodeView
actions *gio.SimpleActionGroup
Page *adw.StatusPage
@@ -71,22 +71,22 @@ type SelfPage struct {
fileModel *gioutil.ListModel[apitype.WaitingFile]
}
-func NewSelfPage(a *App, status *tsutil.Status) *SelfPage {
+func NewSelfPage(a *App, status *tsutil.IPNStatus) *SelfPage {
var page SelfPage
fillFromBuilder(&page, selfPageXML)
page.init(a, status)
return &page
}
-func (page *SelfPage) init(a *App, status *tsutil.Status) {
+func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) {
page.app = a
- page.peer = status.Status.Self
+ page.peer = status.NetMap.SelfNode
page.actions = gio.NewSimpleActionGroup()
copyFQDN := gio.NewSimpleAction("copyFQDN", nil)
copyFQDN.ConnectActivate(func(p *glib.Variant) {
- a.clip(glib.NewValue(strings.TrimSuffix(page.peer.DNSName, ".")))
+ a.clip(glib.NewValue(strings.TrimSuffix(page.peer.Name(), ".")))
a.win.Toast("Copied FQDN to clipboard")
})
page.actions.AddAction(copyFQDN)
@@ -141,7 +141,6 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
slog.Error("advertise routes", "err", err)
return
}
- a.poller.Poll() <- struct{}{}
})
row := adw.NewActionRow()
@@ -202,7 +201,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
slog.Error("delete file", "err", err)
return
}
- a.poller.Poll() <- struct{}{}
+ <-a.poller.Poll()
}
})
})
@@ -227,7 +226,7 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
}
if s {
- err := tsutil.ExitNode(context.TODO(), nil)
+ err := tsutil.ExitNode(context.TODO(), "")
if err != nil {
slog.Error("disable existing exit node", "err", err)
// Continue anyways.
@@ -240,7 +239,6 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(!s)
return true
}
- a.poller.Poll() <- struct{}{}
return true
})
@@ -255,7 +253,6 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(!s)
return true
}
- a.poller.Poll() <- struct{}{}
return true
})
@@ -270,14 +267,13 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(!s)
return true
}
- a.poller.Poll() <- struct{}{}
return true
})
page.AdvertiseRouteButton.ConnectClicked(func() {
Prompt{
- Heading: "Add IP",
- Body: "IP prefix to advertise",
+ Heading: "Add IP Prefix",
+ Placeholder: "10.0.0.0/24",
Responses: []PromptResponse{
{ID: "cancel", Label: "_Cancel"},
{ID: "add", Label: "_Add", Appearance: adw.ResponseSuggested, Default: true},
@@ -307,8 +303,6 @@ func (page *SelfPage) init(a *App, status *tsutil.Status) {
slog.Error("advertise routes", "err", err)
return
}
-
- a.poller.Poll() <- struct{}{}
})
})
@@ -390,24 +384,39 @@ func (page *SelfPage) Init(row *PageRow) {
row.SetSubtitle("This machine")
}
-func (page *SelfPage) Update(status *tsutil.Status) bool {
- page.peer = status.Status.Self
+func (page *SelfPage) Update(status tsutil.Status) bool {
+ switch status := status.(type) {
+ case *tsutil.IPNStatus:
+ return page.UpdateIPN(status)
+ case *tsutil.FileStatus:
+ return page.UpdateFiles(status)
+ default:
+ return true
+ }
+}
+
+func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool {
+ if !status.Online() {
+ return false
+ }
+
+ page.peer = status.NetMap.SelfNode
- page.row.SetTitle(peerName(status, page.peer))
+ page.row.SetTitle(peerName(page.peer))
page.row.SetIconName("computer-symbolic")
- page.Page.SetTitle(page.peer.HostName)
- page.Page.SetDescription(page.peer.DNSName)
+ page.Page.SetTitle(page.peer.Hostinfo().Hostname())
+ page.Page.SetDescription(page.peer.Name())
page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.AdvertisesExitNode())
page.AdvertiseExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.AdvertisesExitNode())
- page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.ExitNodeAllowLANAccess)
- page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.ExitNodeAllowLANAccess)
- page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.RouteAll)
- page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.RouteAll)
+ page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.ExitNodeAllowLANAccess())
+ page.AllowLANAccessRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.ExitNodeAllowLANAccess())
+ page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetState(status.Prefs.RouteAll())
+ page.AcceptRoutesRow.ActivatableWidget().(*gtk.Switch).SetActive(status.Prefs.RouteAll())
routes := func(yield func(netip.Prefix) bool) {
- for _, r := range status.Prefs.AdvertiseRoutes {
+ for _, r := range status.Prefs.AdvertiseRoutes().All() {
if r.Bits() != 0 {
if !yield(r) {
return
@@ -416,9 +425,13 @@ func (page *SelfPage) Update(status *tsutil.Status) bool {
}
}
- listmodels.Update(page.addrModel, slices.Values(page.peer.TailscaleIPs))
- listmodels.Update(page.fileModel, slices.Values(status.Files))
+ listmodels.Update(page.addrModel, xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr))
listmodels.Update(page.routeModel, routes)
return true
}
+
+func (page *SelfPage) UpdateFiles(status *tsutil.FileStatus) bool {
+ listmodels.Update(page.fileModel, slices.Values(status.Files))
+ return true
+}
diff --git a/internal/ui/settings.go b/internal/ui/settings.go
index 10246ce..d1bbf20 100644
--- a/internal/ui/settings.go
+++ b/internal/ui/settings.go
@@ -57,16 +57,17 @@ func (a *App) runSettings(ctx context.Context) {
}
func (a *App) showChangeControlServer() {
- status := <-a.poller.Get()
+ status := <-a.poller.GetIPN()
Prompt{
Heading: "Control Server URL",
+ Purpose: gtk.InputPurposeURL,
Responses: []PromptResponse{
{ID: "cancel", Label: "_Cancel"},
{ID: "default", Label: "Use _Default"},
{ID: "set", Label: "_Set URL", Appearance: adw.ResponseSuggested, Default: true},
},
- }.Show(a, status.Prefs.ControlURL, func(response, val string) {
+ }.Show(a, status.Prefs.ControlURL(), func(response, val string) {
switch response {
case "default":
val = ipn.DefaultControlURL
@@ -81,7 +82,7 @@ func (a *App) showChangeControlServer() {
a.win.Toast(fmt.Sprintf("Error setting control URL: %v", err))
return
}
- a.poller.Poll() <- struct{}{}
+ <-a.poller.Poll()
}
})
}
diff --git a/internal/ui/trayscale.cmb b/internal/ui/trayscale.cmb
index 6e4f4e2..9617871 100644
--- a/internal/ui/trayscale.cmb
+++ b/internal/ui/trayscale.cmb
@@ -2,9 +2,11 @@
-
-
-
+
+
+
+
+
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 268c967..dd44309 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -153,6 +153,16 @@ func expanderRowListBox(row *adw.ExpanderRow) *gtk.ListBox {
panic("ExpanderRow ListBox not found")
}
+func pointerToWidgetter[T any, P interface {
+ gtk.Widgetter
+ *T
+}](p P) gtk.Widgetter {
+ if p == nil {
+ return nil
+ }
+ return p
+}
+
func NewObjectComparer[T any](f func(T, T) int) glib.CompareDataFunc {
return glib.NewObjectComparer(func(o1, o2 *glib.Object) int {
v1 := listmodels.Convert[T](o1)
@@ -168,7 +178,7 @@ type Page interface {
Actions() gio.ActionGrouper
Init(*PageRow)
- Update(*tsutil.Status) bool
+ Update(tsutil.Status) bool
}
type PageRow struct {