diff --git a/cmd/trayscale/default.pgo b/cmd/trayscale/default.pgo index f4666aa..f258dff 100644 Binary files a/cmd/trayscale/default.pgo and b/cmd/trayscale/default.pgo differ diff --git a/dev.deedles.Trayscale.metainfo.xml b/dev.deedles.Trayscale.metainfo.xml index 6d16ef8..989c3dc 100644 --- a/dev.deedles.Trayscale.metainfo.xml +++ b/dev.deedles.Trayscale.metainfo.xml @@ -54,6 +54,16 @@ + + +
    +
  • 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 @@ - ToastOverlay 600 @@ -30,28 +29,7 @@ open-menu-symbolic - - -
    - - peer.copyFQDN - _Copy FQDN - -
    -
    - - peer.sendFile - Send _file... - file - - - peer.sendFile - Send _directory... - dir - -
    -
    -
    + PageMenu
    @@ -91,30 +69,7 @@ open-menu-symbolic - - -
    - - app.change_control_server - Change Control _Server - - - app.preferences - _Preferences - -
    -
    - - app.about - _About - - - app.quit - _Quit - -
    -
    -
    + MainMenu True
    diff --git a/internal/ui/menu.ui b/internal/ui/menu.ui new file mode 100644 index 0000000..a450f90 --- /dev/null +++ b/internal/ui/menu.ui @@ -0,0 +1,48 @@ + + + + + + +
    + + app.change_control_server + Change Control _Server + + + app.preferences + _Preferences + +
    +
    + + app.about + _About + + + app.quit + _Quit + +
    +
    + +
    + + peer.copyFQDN + _Copy FQDN + +
    +
    + + peer.sendFile + Send _file... + file + + + peer.sendFile + Send _directory... + dir + +
    +
    +
    diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index d601ba9..b942e49 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -13,7 +13,6 @@ import ( "github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" - "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/util/set" ) @@ -34,7 +33,7 @@ type MullvadPage struct { exitNodes map[tailcfg.StableNodeID]*mullvadExitNodeRow } -func NewMullvadPage(a *App, status *tsutil.Status) *MullvadPage { +func NewMullvadPage(a *App, status *tsutil.IPNStatus) *MullvadPage { page := MullvadPage{ app: a, locations: make(map[string]*adw.ExpanderRow), @@ -64,8 +63,16 @@ func (page *MullvadPage) Init(row *PageRow) { row.SetTitle(mullvadPageBaseName) } -func (page *MullvadPage) Update(status *tsutil.Status) bool { - if !tsutil.CanMullvad(status.Status.Self) { +func (page *MullvadPage) Update(s tsutil.Status) bool { + status, ok := s.(*tsutil.IPNStatus) + if !ok { + return true + } + if !status.Online() { + return false + } + + if !tsutil.CanMullvad(status.NetMap.SelfNode) { return false } @@ -73,28 +80,31 @@ func (page *MullvadPage) Update(status *tsutil.Status) bool { icon := "network-workgroup-symbolic" var exitNodeID tailcfg.StableNodeID - if status.Status.ExitNodeStatus != nil { - exitNodeID = status.Status.ExitNodeStatus.ID + if exitNode := status.ExitNode(); exitNode.Valid() { + exitNodeID = exitNode.StableID() } var exitNodeCountryCode string found := make(set.Set[tailcfg.StableNodeID]) - for _, peer := range status.Status.Peer { + for id, peer := range status.Peers { if tsutil.IsMullvad(peer) { - found.Add(peer.ID) - exitNode := peer.ID == exitNodeID + found.Add(id) + exitNode := id == exitNodeID row := page.getExitNodeRow(peer) sw := row.row.ActivatableWidget().(*gtk.Switch) sw.SetState(exitNode) sw.SetActive(exitNode) + loc := peer.Hostinfo().Location() + countryCode := loc.CountryCode() + page.locations[countryCode].SetSubtitle("") + if exitNode { - subtitle = mullvadLongLocationName(peer.Location) + subtitle = mullvadLongLocationName(loc) icon = "network-vpn-symbolic" - exitNodeCountryCode = peer.Location.CountryCode + exitNodeCountryCode = countryCode } - page.locations[peer.Location.CountryCode].SetSubtitle("") } } for id, row := range page.exitNodes { @@ -119,8 +129,8 @@ func (page *MullvadPage) Update(status *tsutil.Status) bool { return true } -func (page *MullvadPage) getLocationRow(loc *tailcfg.Location) *adw.ExpanderRow { - if row, ok := page.locations[loc.CountryCode]; ok { +func (page *MullvadPage) getLocationRow(loc tailcfg.LocationView) *adw.ExpanderRow { + if row, ok := page.locations[loc.CountryCode()]; ok { return row } @@ -138,19 +148,21 @@ func (page *MullvadPage) getLocationRow(loc *tailcfg.Location) *adw.ExpanderRow ) }) - page.locations[loc.CountryCode] = row + page.locations[loc.CountryCode()] = row page.LocationList.Append(row) return row } -func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitNodeRow { - if row, ok := page.exitNodes[peer.ID]; ok { +func (page *MullvadPage) getExitNodeRow(peer tailcfg.NodeView) *mullvadExitNodeRow { + if row, ok := page.exitNodes[peer.StableID()]; ok { return row } + info := peer.Hostinfo() + row := adw.NewSwitchRow() - row.SetTitle(peer.Location.City) - row.SetSubtitle(peer.HostName) + row.SetTitle(info.Location().City()) + row.SetSubtitle(info.Hostname()) sw := row.ActivatableWidget().(*gtk.Switch) sw.SetMarginTop(12) @@ -168,9 +180,9 @@ func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitN } } - var node *ipnstate.PeerStatus + var node tailcfg.StableNodeID if s { - node = peer + node = peer.StableID() } err := tsutil.ExitNode(context.TODO(), node) if err != nil { @@ -178,17 +190,16 @@ func (page *MullvadPage) getExitNodeRow(peer *ipnstate.PeerStatus) *mullvadExitN sw.SetActive(!s) return true } - page.app.poller.Poll() <- struct{}{} return true }) - page.getLocationRow(peer.Location).AddRow(row) + page.getLocationRow(info.Location()).AddRow(row) exitNodeRow := mullvadExitNodeRow{ - country: peer.Location.CountryCode, + country: info.Location().CountryCode(), row: row, } - page.exitNodes[peer.ID] = &exitNodeRow + page.exitNodes[peer.StableID()] = &exitNodeRow return &exitNodeRow } @@ -197,20 +208,20 @@ type mullvadExitNodeRow struct { row *adw.SwitchRow } -func mullvadLongLocationName(loc *tailcfg.Location) string { +func mullvadLongLocationName(loc tailcfg.LocationView) string { return fmt.Sprintf( "%v %v, %v", - countryCodeToFlag(loc.CountryCode), - loc.City, - loc.Country, + countryCodeToFlag(loc.CountryCode()), + loc.City(), + loc.Country(), ) } -func mullvadLocationName(loc *tailcfg.Location) string { +func mullvadLocationName(loc tailcfg.LocationView) string { return fmt.Sprintf( "%v %v", - countryCodeToFlag(loc.CountryCode), - loc.Country, + countryCodeToFlag(loc.CountryCode()), + loc.Country(), ) } diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go new file mode 100644 index 0000000..bacd913 --- /dev/null +++ b/internal/ui/offlinepage.go @@ -0,0 +1,47 @@ +package ui + +import ( + _ "embed" + + "deedles.dev/trayscale/internal/tsutil" + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +//go:embed offlinepage.ui +var offlinePageXML string + +type OfflinePage struct { + app *App + + Page *adw.StatusPage + NeedsAuthGroup *adw.PreferencesGroup +} + +func NewOfflinePage(app *App) *OfflinePage { + page := OfflinePage{app: app} + fillFromBuilder(&page, offlinePageXML) + return &page +} + +func (page *OfflinePage) Widget() gtk.Widgetter { + return page.Page +} + +func (page *OfflinePage) Actions() gio.ActionGrouper { + return nil +} + +func (page *OfflinePage) Init(row *PageRow) { + row.SetTitle(page.Page.Title()) + row.SetIconName(page.Page.IconName()) +} + +func (page *OfflinePage) Update(status tsutil.Status) bool { + if status, ok := status.(*tsutil.IPNStatus); ok { + page.NeedsAuthGroup.SetVisible(status.NeedsAuth()) + return !status.Online() + } + return true +} diff --git a/internal/ui/offlinepage.ui b/internal/ui/offlinepage.ui new file mode 100644 index 0000000..075b759 --- /dev/null +++ b/internal/ui/offlinepage.ui @@ -0,0 +1,26 @@ + + + + + + + + + + Tailscale must be authenticated before it can connect. + Login Required + + + app.login + Open Browser to Login + + + + + + + Tailscale is not connected + network-offline-symbolic + Not Connected + + diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 2cffd0d..3ed117a 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -20,6 +20,8 @@ import ( "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" ) //go:embed peerpage.ui @@ -28,7 +30,7 @@ var peerPageXML string type PeerPage struct { app *App row *PageRow - peer *ipnstate.PeerStatus + peer tailcfg.NodeView actions *gio.SimpleActionGroup Page *adw.StatusPage @@ -62,8 +64,6 @@ type PeerPage struct { LastSeen *gtk.Label CreatedRow *adw.ActionRow Created *gtk.Label - LastWriteRow *adw.ActionRow - LastWrite *gtk.Label LastHandshakeRow *adw.ActionRow LastHandshake *gtk.Label RxBytesRow *adw.ActionRow @@ -74,18 +74,20 @@ type PeerPage struct { SendDirButton *adw.ButtonRow DropTarget *gtk.DropTarget + sendFileAction *gio.SimpleAction + addrModel *gioutil.ListModel[netip.Addr] routeModel *gioutil.ListModel[netip.Prefix] } -func NewPeerPage(a *App, status *tsutil.Status, peer *ipnstate.PeerStatus) *PeerPage { +func NewPeerPage(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeView) *PeerPage { var page PeerPage fillFromBuilder(&page, peerPageXML) page.init(a, status, peer) return &page } -func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerStatus) { +func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeView) { page.app = a page.peer = peer @@ -93,13 +95,13 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta copyFQDNAction := gio.NewSimpleAction("copyFQDN", nil) copyFQDNAction.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(copyFQDNAction) - sendFileAction := gio.NewSimpleAction("sendFile", glib.NewVariantType("s")) - sendFileAction.ConnectActivate(func(p *glib.Variant) { + page.sendFileAction = gio.NewSimpleAction("sendFile", glib.NewVariantType("s")) + page.sendFileAction.ConnectActivate(func(p *glib.Variant) { dialog := gtk.NewFileDialog() dialog.SetModal(true) @@ -109,7 +111,7 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta open, finish = dialog.SelectMultipleFolders, dialog.SelectMultipleFoldersFinish } - dialog.SetTitle(fmt.Sprintf("Select %v(s) to send to %v", mode, page.peer.HostName)) + dialog.SetTitle(fmt.Sprintf("Select %v(s) to send to %v", mode, page.peer.Hostinfo().Hostname())) open(context.TODO(), &a.win.MainWindow.Window, func(res gio.AsyncResulter) { files, err := finish(res) @@ -121,11 +123,11 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta } for _, file := range listmodels.Values[gio.Filer](files) { - go a.pushFile(context.TODO(), page.peer.ID, file) + go a.pushFile(context.TODO(), page.peer.StableID(), file) } }) }) - page.actions.AddAction(sendFileAction) + page.actions.AddAction(page.sendFileAction) page.Page.AddController(page.DropTarget) page.DropTarget.SetGTypes([]glib.Type{gio.GTypeFile}) @@ -134,7 +136,7 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta if !ok { return true } - go a.pushFile(context.TODO(), page.peer.ID, file) + go a.pushFile(context.TODO(), page.peer.StableID(), file) return true }) @@ -188,7 +190,6 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta slog.Error("advertise routes", "err", err) return } - a.poller.Poll() <- struct{}{} }) row := adw.NewActionRow() @@ -217,9 +218,9 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta } } - var node *ipnstate.PeerStatus + var node tailcfg.StableNodeID if s { - node = page.peer + node = page.peer.StableID() } err := tsutil.ExitNode(context.TODO(), node) if err != nil { @@ -227,7 +228,6 @@ func (page *PeerPage) init(a *App, status *tsutil.Status, peer *ipnstate.PeerSta page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(!s) return true } - a.poller.Poll() <- struct{}{} return true }) } @@ -244,36 +244,51 @@ func (page *PeerPage) Init(row *PageRow) { page.row = row } -func (page *PeerPage) Update(status *tsutil.Status) bool { - page.peer = status.Status.Peer[page.peer.PublicKey] - if page.peer == nil { +func (page *PeerPage) Update(s tsutil.Status) bool { + status, ok := s.(*tsutil.IPNStatus) + if !ok { + return true + } + if !status.Online() { return false } - page.row.SetTitle(peerName(status, page.peer)) - page.row.SetSubtitle(peerSubtitle(page.peer)) - page.row.SetIconName(peerIcon(page.peer)) - - page.Page.SetTitle(page.peer.HostName) - page.Page.SetDescription(page.peer.DNSName) - - page.ExitNodeRow.SetVisible(page.peer.ExitNodeOption) - page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(page.peer.ExitNode) - page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(page.peer.ExitNode) - page.RxBytes.SetText(strconv.FormatInt(page.peer.RxBytes, 10)) - page.TxBytes.SetText(strconv.FormatInt(page.peer.TxBytes, 10)) - page.Created.SetText(formatTime(page.peer.Created)) - page.LastSeen.SetText(formatTime(page.peer.LastSeen)) - page.LastSeenRow.SetVisible(!page.peer.Online) - page.LastWrite.SetText(formatTime(page.peer.LastWrite)) - page.LastHandshake.SetText(formatTime(page.peer.LastHandshake)) - page.Online.SetFromIconName(boolIcon(page.peer.Online)) + page.peer = status.Peers[page.peer.StableID()] + if !page.peer.Valid() { + return false + } + + page.sendFileAction.SetEnabled(status.FileTargets.Contains(page.peer.StableID())) + + online := page.peer.Online().Get() + exitNodeOption := tsaddr.ContainsExitRoutes(page.peer.AllowedIPs()) + exitNode := page.peer.Equal(status.ExitNode()) + + var enginePeer ipnstate.PeerStatusLite + if status.Engine != nil { + enginePeer = status.Engine.LivePeers[page.peer.Key()] + } + + page.row.SetTitle(peerName(page.peer)) + page.row.SetSubtitle(peerSubtitle(exitNodeOption, exitNode)) + page.row.SetIconName(peerIcon(online, exitNodeOption, exitNode)) + + page.Page.SetTitle(page.peer.Hostinfo().Hostname()) + page.Page.SetDescription(page.peer.Name()) + + page.ExitNodeRow.SetVisible(exitNodeOption) + page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetState(exitNode) + page.ExitNodeRow.ActivatableWidget().(*gtk.Switch).SetActive(exitNode) + page.RxBytes.SetText(strconv.FormatInt(enginePeer.RxBytes, 10)) + page.TxBytes.SetText(strconv.FormatInt(enginePeer.TxBytes, 10)) + page.Created.SetText(formatTime(page.peer.Created())) + page.LastSeen.SetText(formatTime(page.peer.LastSeen().Get())) + page.LastSeenRow.SetVisible(!online) + page.LastHandshake.SetText(formatTime(enginePeer.LastHandshake)) + page.Online.SetFromIconName(boolIcon(online)) routes := func(yield func(netip.Prefix) bool) { - if page.peer.PrimaryRoutes == nil { - return - } - for _, r := range page.peer.PrimaryRoutes.All() { + for _, r := range page.peer.PrimaryRoutes().All() { if r.Bits() == 0 { continue } @@ -283,37 +298,37 @@ func (page *PeerPage) Update(status *tsutil.Status) bool { } } - listmodels.Update(page.addrModel, slices.Values(page.peer.TailscaleIPs)) + listmodels.Update(page.addrModel, xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr)) listmodels.Update(page.routeModel, routes) return true } -func peerName(status *tsutil.Status, peer *ipnstate.PeerStatus) string { - return tsutil.DNSOrQuoteHostname(status.Status, peer) +func peerName(peer tailcfg.NodeView) string { + return peer.DisplayName(true) } -func peerSubtitle(peer *ipnstate.PeerStatus) string { - if peer.ExitNode { +func peerSubtitle(exitNodeOption, exitNode bool) string { + if exitNode { return "Current exit node" } - if peer.ExitNodeOption { + if exitNodeOption { return "Exit node option" } return "" } -func peerIcon(peer *ipnstate.PeerStatus) string { - if peer.ExitNode { - if !peer.Online { +func peerIcon(online bool, exitNodeOption, exitNode bool) string { + if exitNode { + if !online { return "network-vpn-acquiring-symbolic" } return "network-vpn-symbolic" } - if !peer.Online { + if !online { return "network-wired-offline-symbolic" } - if peer.ExitNodeOption { + if exitNodeOption { return "folder-remote-symbolic" } diff --git a/internal/ui/peerpage.ui b/internal/ui/peerpage.ui index f23845b..71f0aee 100644 --- a/internal/ui/peerpage.ui +++ b/internal/ui/peerpage.ui @@ -54,14 +54,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 {