Compare commits

...
5 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 935962f6da chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-11 03:24:20 +00:00
github-actions[bot]andGitHub 789cc7626f chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-10 03:23:01 +00:00
lukaszraczyloandGitHub 8bd14cce3b fix(mdns): answer hostname A queries after TTL (#96)
* fix(mdns): answer hostname A queries after TTL

Zeroconf answers only service queries. The A record for <alias>.local
expired from the OS cache after 120 seconds and lookups then failed.

Vendor zeroconf v1.0.0 with one change: it now answers A and AAAA
queries for the registered hostname. Fixes #95.

* fix(deps): update golang.org/x/net to v0.60.0

govulncheck reports several x/net advisories fixed in v0.60.0.
The Security Scan job fails on them.
2026-10-09 15:21:35 +01:00
github-actions[bot]andGitHub 7e3c112738 chore(deps): update module github.com/fxamacker/cbor/v2 to v2.9.6
Automated dependency update. All tests passed.
2026-10-09 03:24:20 +00:00
lukaszraczylo f7287b3ef6 refactor(ui): migrate to bubbletea v2 and lipgloss v2
Switch imports to charm.land/bubbletea/v2 and charm.land/lipgloss/v2.
View now returns tea.View with AltScreen set, key handling uses
KeyPressMsg, and colour fields use image/color.
2026-10-04 10:14:28 +01:00
24 changed files with 1803 additions and 263 deletions
+14 -15
View File
@@ -3,8 +3,8 @@ module github.com/lukaszraczylo/kportal
go 1.26.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
github.com/fsnotify/fsnotify v1.10.1
github.com/go-logr/logr v1.4.4
github.com/grandcat/zeroconf v1.0.0
@@ -18,18 +18,18 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/emicklei/go-restful/v3 v3.14.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/swag v0.29.2 // indirect
@@ -49,16 +49,12 @@ require (
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -66,9 +62,10 @@ require (
github.com/xo/terminfo v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
@@ -83,3 +80,5 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/grandcat/zeroconf => ./third_party/zeroconf
+28 -56
View File
@@ -1,25 +1,27 @@
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@@ -28,14 +30,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/emicklei/go-restful/v3 v3.14.0 h1:6V6Ba8/5W1Z7amPinklZeCMlHBMwj53thpfxazCLMX4=
github.com/emicklei/go-restful/v3 v3.14.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
@@ -81,8 +81,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -93,13 +91,8 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
@@ -110,16 +103,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -143,35 +130,20 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+22 -15
View File
@@ -21,13 +21,14 @@ package ui
import (
"fmt"
"image/color"
"log"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -173,7 +174,7 @@ func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
// Start starts the bubbletea application
func (ui *BubbleTeaUI) Start() error {
m := model{ui: ui}
ui.program = tea.NewProgram(m, tea.WithAltScreen())
ui.program = tea.NewProgram(m)
_, err := ui.program.Run()
return err
}
@@ -325,7 +326,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.termHeight = msg.Height
return m, nil
case tea.KeyMsg:
case tea.KeyPressMsg:
// Route based on current view mode
switch viewMode {
case ViewModeMain:
@@ -390,7 +391,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
func (m model) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m model) view() string {
m.ui.mu.RLock()
viewMode := m.ui.viewMode
deleteConfirming := m.ui.deleteConfirming
@@ -438,13 +445,13 @@ func (m model) View() string {
// mainViewColors holds the color palette for the main view
type mainViewColors struct {
header lipgloss.Color
active lipgloss.Color
warning lipgloss.Color
errorColor lipgloss.Color
muted lipgloss.Color
selectedBg lipgloss.Color
selectedFg lipgloss.Color
header color.Color
active color.Color
warning color.Color
errorColor color.Color
muted color.Color
selectedBg color.Color
selectedFg color.Color
}
// defaultMainViewColors returns the default color palette
@@ -526,7 +533,7 @@ func (m model) getTermDimensions() (width, height int) {
}
// renderTitle renders the title bar with version and optional update notification
func (m model) renderTitle(headerColor lipgloss.Color) string {
func (m model) renderTitle(headerColor color.Color) string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
@@ -552,7 +559,7 @@ func (m model) renderTitle(headerColor lipgloss.Color) string {
// renderEmptyMessage renders the message shown when no forwards are configured.
// It includes an actionable hint so a first-time user knows how to proceed.
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
func (m model) renderEmptyMessage(mutedColor color.Color) string {
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
return mutedStyle.Render("No forwards configured") + "\n\n" +
+27 -23
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
)
@@ -323,13 +323,13 @@ func (m *GenerateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.step = GenerateStepDone
return m, tea.Quit
case tea.KeyMsg:
case tea.KeyPressMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.loading {
// Allow only ctrl+c / esc while loading
switch msg.String() {
@@ -354,12 +354,12 @@ func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// ---------- Namespace step ----------
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.nsFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.nsFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.nsFilter = ""
m.recomputeNamespaceFilter()
}
@@ -370,12 +370,11 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.recomputeNamespaceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.nsFilter += string(msg.Runes)
default:
m.nsFilter += msg.Text
m.recomputeNamespaceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -391,7 +390,7 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), -10)
case "pgdown":
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), 10)
case " ":
case "space":
if len(m.nsFilteredView) > 0 {
ns := m.nsFilteredView[m.nsCursor]
m.nsSelected[ns] = !m.nsSelected[ns]
@@ -475,12 +474,12 @@ func (m *GenerateModel) buildServiceOrder() {
}
}
func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleServiceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.svcFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.svcFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.svcFilter = ""
m.recomputeServiceFilter()
}
@@ -491,12 +490,11 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.recomputeServiceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.svcFilter += string(msg.Runes)
default:
m.svcFilter += msg.Text
m.recomputeServiceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -515,7 +513,7 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), -10)
case "pgdown":
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), 10)
case " ":
case "space":
if len(m.svcFilteredView) > 0 {
c := m.svcFilteredView[m.svcCursor]
if !m.svcLocked[c.Key()] && c.Protocol == "TCP" {
@@ -593,7 +591,7 @@ func (m *GenerateModel) selectedCandidates() []ServiceCandidate {
// ---------- Port assignment step ----------
func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handlePortKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.step = GenerateStepCancelled
@@ -627,7 +625,7 @@ func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// Digit-only input
for _, r := range msg.Runes {
for _, r := range msg.Text {
if r >= '0' && r <= '9' && len(m.startingPortStr) < 5 {
m.startingPortStr += string(r)
m.portError = ""
@@ -716,7 +714,13 @@ func (m *GenerateModel) countSkippedNonTCP() int {
// ---------- View ----------
// View implements tea.Model.
func (m *GenerateModel) View() string {
func (m *GenerateModel) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m *GenerateModel) view() string {
var b strings.Builder
b.WriteString(wizardHeaderStyle.Render(fmt.Sprintf("kportal generate · context: %s", m.contextName)))
b.WriteString("\n")
@@ -975,7 +979,7 @@ func RunGenerate(
existingForwards []config.Forward,
) (GenerateResult, error) {
m := NewGenerateModel(discovery, mutator, contextName, configPath, dryRun, existingForwards)
prog := tea.NewProgram(m, tea.WithAltScreen())
prog := tea.NewProgram(m)
finalModel, err := prog.Run()
if err != nil {
return GenerateResult{}, err
+11 -11
View File
@@ -8,7 +8,7 @@ import (
"sync"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -72,22 +72,22 @@ func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.Ser
return f.servicesByNS[ns], nil
}
// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyMsg {
// keyOf builds a tea.KeyPressMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyPressMsg {
switch s {
case "enter":
return tea.KeyMsg{Type: tea.KeyEnter}
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "esc":
return tea.KeyMsg{Type: tea.KeyEsc}
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "space":
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
case "backspace":
return tea.KeyMsg{Type: tea.KeyBackspace}
return tea.KeyPressMsg{Code: tea.KeyBackspace}
}
if len(s) == 1 {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
// drainModel applies a sequence of messages and returns the final model.
@@ -169,7 +169,7 @@ func TestGenerateModel_NamespaceFilter(t *testing.T) {
t.Fatalf("expected [beta], got %v", gm.nsFilteredView)
}
// Exit filter
gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel)
gm = drainModel(t, gm, tea.KeyPressMsg{Code: tea.KeyEnter}).(*GenerateModel)
if gm.nsFiltering {
t.Fatal("expected filtering to be off after enter")
}
@@ -496,7 +496,7 @@ func TestGenerateModel_PortStepView(t *testing.T) {
m.step = GenerateStepPortAssign
m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}}
m.svcSelected[m.svcOrder[0].Key()] = true
view := m.View()
view := m.View().Content
if !contains(view, "Step 3 / 3") {
t.Fatalf("expected step header in view, got: %s", view)
}
+34 -34
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,10 +44,10 @@ func TestHandleMainViewKeys_Quit(t *testing.T) {
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := newTestModel()
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
_, cmd := m.handleMainViewKeys(tea.KeyPressMsg{Text: tt.key})
if tt.key == "ctrl+c" {
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd = m.handleMainViewKeys(keyMsg)
}
@@ -76,14 +76,14 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
tests := []struct {
name string
key string
keyType tea.KeyType
code rune
initialIndex int
expectedIndex int
}{
{"down arrow", "down", tea.KeyDown, 0, 1},
{"j key", "j", tea.KeyRunes, 0, 1},
{"j key", "j", 0, 0, 1},
{"up arrow", "up", tea.KeyUp, 2, 1},
{"k key", "k", tea.KeyRunes, 2, 1},
{"k key", "k", 0, 2, 1},
}
for _, tt := range tests {
@@ -92,11 +92,11 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
m.ui.selectedIndex = tt.initialIndex
m.ui.mu.Unlock()
var keyMsg tea.KeyMsg
if tt.keyType == tea.KeyRunes {
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
var keyMsg tea.KeyPressMsg
if tt.code == 0 {
keyMsg = tea.KeyPressMsg{Text: tt.key}
} else {
keyMsg = tea.KeyMsg{Type: tt.keyType}
keyMsg = tea.KeyPressMsg{Code: tt.code}
}
m.handleMainViewKeys(keyMsg)
@@ -123,7 +123,7 @@ func TestHandleMainViewKeys_Toggle(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Toggle with space
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleMainViewKeys(keyMsg)
// Check disabled state changed
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_NewWizard(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Without dependencies, 'n' should do nothing
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -180,7 +180,7 @@ func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' to show delete confirmation
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -204,7 +204,7 @@ func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' for first forward
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
// Change selection
@@ -234,7 +234,7 @@ func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -257,7 +257,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Navigate left to "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
keyMsg := tea.KeyPressMsg{Code: tea.KeyLeft}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -265,7 +265,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m.ui.mu.RUnlock()
// Navigate right back to "No"
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
keyMsg = tea.KeyPressMsg{Code: tea.KeyRight}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -288,7 +288,7 @@ func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Enter on "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleDeleteConfirmation(keyMsg)
// Should return a command to remove the forward
@@ -314,7 +314,7 @@ func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'y' - should confirm regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
keyMsg := tea.KeyPressMsg{Text: "y"}
_, cmd := m.handleDeleteConfirmation(keyMsg)
assert.NotNil(t, cmd)
@@ -336,7 +336,7 @@ func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'n' - should cancel regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -358,7 +358,7 @@ func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleBenchmarkKeys(keyMsg)
assert.True(t, cancelled, "Cancel function should be called")
@@ -385,7 +385,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move down
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -400,7 +400,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move up
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -422,7 +422,7 @@ func TestHandleHTTPLogKeys_Close(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -451,7 +451,7 @@ func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
m.ui.mu.RUnlock()
// Press 'f' to cycle - should skip Text mode and go to Non200
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
keyMsg := tea.KeyPressMsg{Text: "f"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -484,7 +484,7 @@ func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press '/'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
keyMsg := tea.KeyPressMsg{Text: "/"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -505,7 +505,7 @@ func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
keyMsg := tea.KeyPressMsg{Text: "c"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -869,7 +869,7 @@ func TestModel_Update_ViewModeRouting(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send a key message - should not panic
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, _ = m.Update(keyMsg)
})
}
@@ -925,7 +925,7 @@ func TestHandleRemoveWizardKeys_EscInConfirmingCancels(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// No removal command must be dispatched.
@@ -962,7 +962,7 @@ func TestHandleRemoveWizardKeys_EscNotConfirmingExits(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// Should return tea.ClearScreen command on full exit.
@@ -992,7 +992,7 @@ func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
@@ -1017,7 +1017,7 @@ func TestHandleAddWizardKeys_HToggleHTTPLog(t *testing.T) {
require.False(t, m.ui.addWizard.httpLog, "httpLog should default to false")
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.True(t, m.ui.addWizard.httpLog, "first 'h' should enable httpLog")
@@ -1042,7 +1042,7 @@ func TestHandleAddWizardKeys_HOnAliasFocusIsTextInput(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "httpLog must NOT toggle when alias has focus")
@@ -1067,7 +1067,7 @@ func TestEditPrefill_PreservesHTTPLog(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}
keyMsg := tea.KeyPressMsg{Text: "e"}
m.handleMainViewKeys(keyMsg)
require.NotNil(t, m.ui.addWizard, "wizard should be active after 'e'")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/benchmark"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
+9 -9
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -31,7 +31,7 @@ func isFilterableStep(step AddWizardStep) bool {
}
// handleMainViewKeys handles keyboard input in the main view
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleMainViewKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// If delete confirmation is showing, handle it separately
if m.ui.deleteConfirming {
return m.handleDeleteConfirmation(msg)
@@ -53,7 +53,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
m.ui.moveSelection(10)
case " ", "enter":
case "space", "enter":
m.ui.toggleSelected()
case "n": // Enter add wizard
@@ -294,7 +294,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleDeleteConfirmation handles keyboard input for delete confirmation dialog
func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleDeleteConfirmation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
switch msg.String() {
@@ -336,7 +336,7 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleAddWizardKeys handles keyboard input in the add wizard
func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleAddWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -781,7 +781,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
}
// handleRemoveWizardKeys handles keyboard input in the remove wizard
func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleRemoveWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -822,7 +822,7 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
wizard.moveCursor(10)
case " ":
case "space":
if !wizard.confirming {
wizard.toggleSelection()
}
@@ -1051,7 +1051,7 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
}
// handleBenchmarkKeys handles keyboard input in the benchmark view
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleBenchmarkKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -1192,7 +1192,7 @@ func (m model) applyBenchmarkTextInput() {
}
// handleHTTPLogKeys handles keyboard input in the HTTP log view
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleHTTPLogKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
+85 -85
View File
@@ -4,7 +4,7 @@ import (
"os"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,7 +44,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.selectedIndex)
@@ -54,7 +54,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Lock()
ui.selectedIndex = 0
ui.mu.Unlock()
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 10, ui.selectedIndex)
@@ -76,7 +76,7 @@ func TestHandleMainViewKeys_NewWizard_WithDiscovery(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
_, cmd := m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -99,7 +99,7 @@ func TestHandleMainViewKeys_NewWizard_AlreadyActive(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
// Should still be the same wizard (not replaced).
@@ -116,7 +116,7 @@ func TestHandleMainViewKeys_HttpLog_NoSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -133,7 +133,7 @@ func TestHandleMainViewKeys_HttpLog_WithSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
// Subscription should be established.
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_Benchmark(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -170,7 +170,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
prevState := ui.benchmarkState
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
// Should not have replaced the benchmark state.
@@ -184,7 +184,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -197,7 +197,7 @@ func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -209,7 +209,7 @@ func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
func TestHandleAddWizardKeys_Esc_MiddleStep_GoesBack(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -221,7 +221,7 @@ func TestHandleAddWizardKeys_Esc_ClearsSearchFilter_InsteadOfBack(t *testing.T)
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -235,7 +235,7 @@ func TestHandleAddWizardKeys_Esc_EditMode_AlwaysCancels(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.isEditing = true
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -250,11 +250,11 @@ func TestHandleAddWizardKeys_Navigation(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.contexts = []string{"ctx-a", "ctx-b", "ctx-c"}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 1, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 0, m.ui.addWizard.cursor)
}
@@ -268,11 +268,11 @@ func TestHandleAddWizardKeys_PageNavigation(t *testing.T) {
m.ui.addWizard.contexts = contexts
m.ui.addWizard.cursor = 15
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 5, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 15, m.ui.addWizard.cursor)
}
@@ -283,7 +283,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_UpFocusAlias(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus)
}
@@ -292,7 +292,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_DownFocusButtons(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
}
@@ -301,7 +301,7 @@ func TestHandleAddWizardKeys_Tab_TogglesFocus(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -316,7 +316,7 @@ func TestHandleAddWizardKeys_Backspace_TextMode(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "808"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "80", m.ui.addWizard.textInput)
}
@@ -325,7 +325,7 @@ func TestHandleAddWizardKeys_Backspace_SearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "m", m.ui.addWizard.searchFilter)
}
@@ -334,7 +334,7 @@ func TestHandleAddWizardKeys_Backspace_EmptyFilter_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = ""
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
// Must not panic.
m.handleAddWizardKeys(keyMsg)
}
@@ -345,7 +345,7 @@ func TestHandleAddWizardKeys_TypeCharAddsToSearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
// Context step is filterable in list mode.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")}
keyMsg := tea.KeyPressMsg{Text: "p"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "p", m.ui.addWizard.searchFilter)
}
@@ -356,7 +356,7 @@ func TestHandleAddWizardKeys_TypeCharInTextMode(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("8")}
keyMsg := tea.KeyPressMsg{Text: "8"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "8", m.ui.addWizard.textInput)
}
@@ -377,7 +377,7 @@ func TestHandleAddWizardEnter_SelectContext(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -393,7 +393,7 @@ func TestHandleAddWizardEnter_SelectContext_Loading_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.loading = true
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.Nil(t, cmd)
}
@@ -405,7 +405,7 @@ func TestHandleAddWizardEnter_SelectNamespace(t *testing.T) {
m.ui.addWizard.namespaces = []string{"default", "kube-system"}
m.ui.addWizard.cursor = 1 // kube-system
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepSelectResourceType, m.ui.addWizard.step)
@@ -428,7 +428,7 @@ func TestHandleAddWizardEnter_SelectResourceType_Service(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -454,7 +454,7 @@ func TestHandleAddWizardEnter_SelectResourceType_PodPrefix(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -477,7 +477,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix(t *testing.T) {
Containers: []k8s.ContainerInfo{{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}},
}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -490,7 +490,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix_EmptyInput_NoOp(t *testing
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = ""
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step) // should not advance
@@ -505,7 +505,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector(t *testing.T) {
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "my-pod"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -518,7 +518,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector_NoMatchingPods_NoOp(t *t
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = nil
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step)
@@ -535,7 +535,7 @@ func TestHandleAddWizardEnter_EnterResource_Service(t *testing.T) {
}
m.ui.addWizard.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -549,7 +549,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_ValidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "8080"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -561,7 +561,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_InvalidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "invalid"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -577,7 +577,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_SelectPort(t *testing.T) {
}
m.ui.addWizard.cursor = 1 // 8080 → TargetPort 9090
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -590,7 +590,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_ManualEntry(t *testing.T) {
m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}}
m.ui.addWizard.cursor = 1 // "Manual entry" option
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, InputModeText, m.ui.addWizard.inputMode)
@@ -612,7 +612,7 @@ func TestHandleAddWizardEnter_LocalPort_ValidPort(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// loading set, cmd generated.
@@ -627,7 +627,7 @@ func TestHandleAddWizardEnter_LocalPort_InvalidPort(t *testing.T) {
m := newModelWithWizard(StepEnterLocalPort)
m.ui.addWizard.textInput = "0" // invalid (must be > 0)
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -639,7 +639,7 @@ func TestHandleAddWizardEnter_Confirmation_FocusAlias_MoveToButtons(t *testing.T
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -650,7 +650,7 @@ func TestHandleAddWizardEnter_Confirmation_CancelButton(t *testing.T) {
m.ui.addWizard.confirmationFocus = FocusButtons
m.ui.addWizard.cursor = 1 // Cancel
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -667,7 +667,7 @@ func TestHandleAddWizardEnter_Confirmation_PortNotAvailable_Error(t *testing.T)
m.ui.addWizard.portAvailable = false
m.ui.addWizard.localPort = 8080
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -697,7 +697,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
@@ -731,7 +731,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward_WithHTTPLog(t *testin
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -767,7 +767,7 @@ func TestHandleAddWizardEnter_Confirmation_Update_EditMode(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -790,7 +790,7 @@ func TestHandleAddWizardEnter_Success_AddAnother(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd (loadContextsCmd) must be returned.
@@ -801,7 +801,7 @@ func TestHandleAddWizardEnter_Success_ReturnToMain(t *testing.T) {
m := newModelWithWizard(StepSuccess)
m.ui.addWizard.cursor = 1 // "Return to main"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd)
@@ -828,7 +828,7 @@ func TestHandleAddWizardKeys_SelectorValidation_OnChar(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Typing a char in PodSelector step should return a validation cmd.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-")}
keyMsg := tea.KeyPressMsg{Text: "-"}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd is returned (validateSelectorCmd); we don't invoke it as it needs a real cluster.
@@ -849,7 +849,7 @@ func TestHandleRemoveWizardKeys_SpaceToggle(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -874,7 +874,7 @@ func TestHandleRemoveWizardKeys_SelectAll(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -895,7 +895,7 @@ func TestHandleRemoveWizardKeys_SelectNone(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -915,7 +915,7 @@ func TestHandleRemoveWizardKeys_CtrlC_ExitsAlways(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -935,7 +935,7 @@ func TestHandleRemoveWizardKeys_Enter_NothingSelected_NoOp(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -955,7 +955,7 @@ func TestHandleRemoveWizardKeys_Enter_ShowConfirmation(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -976,7 +976,7 @@ func TestHandleRemoveWizardKeys_Enter_ConfirmNo(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -997,13 +997,13 @@ func TestHandleRemoveWizardKeys_Navigation_InSelection(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 1, ui.removeWizard.cursor)
ui.mu.RUnlock()
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.removeWizard.cursor)
@@ -1017,7 +1017,7 @@ func TestHandleBenchmarkKeys_Tab(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
@@ -1028,7 +1028,7 @@ func TestHandleBenchmarkKeys_Tab_Wraps(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 3
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
@@ -1040,7 +1040,7 @@ func TestHandleBenchmarkKeys_Backspace(t *testing.T) {
m.ui.benchmarkState.cursor = 0
m.ui.benchmarkState.textInput = "/api"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "/ap", m.ui.benchmarkState.textInput)
@@ -1053,7 +1053,7 @@ func TestHandleBenchmarkKeys_TypeChar_UpdatesField(t *testing.T) {
m.ui.benchmarkState.cursor = 1 // Method field
m.ui.benchmarkState.textInput = "GE"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("T")}
keyMsg := tea.KeyPressMsg{Text: "T"}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "GET", m.ui.benchmarkState.method)
@@ -1063,7 +1063,7 @@ func TestHandleBenchmarkKeys_Enter_ResultsStep_ReturnsToMain(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepResults
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -1077,7 +1077,7 @@ func TestHandleBenchmarkKeys_Enter_ConfigStep_StartsRunning(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
require.NotNil(t, cmd)
@@ -1157,7 +1157,7 @@ func TestHandleHTTPLogKeys_Detail_Esc(t *testing.T) {
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.showingDetail)
@@ -1169,11 +1169,11 @@ func TestHandleHTTPLogKeys_Detail_UpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 5
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 4, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
}
@@ -1184,11 +1184,11 @@ func TestHandleHTTPLogKeys_Detail_PgUpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 25
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 25, m.ui.httpLogState.detailScroll)
}
@@ -1199,7 +1199,7 @@ func TestHandleHTTPLogKeys_Detail_GoToTop(t *testing.T) {
m.ui.httpLogState.detailScroll = 99
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.detailScroll)
@@ -1213,7 +1213,7 @@ func TestHandleHTTPLogKeys_Enter_ShowDetail(t *testing.T) {
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test", StatusCode: 200}}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.showingDetail)
@@ -1227,11 +1227,11 @@ func TestHandleHTTPLogKeys_Navigate_UpDown(t *testing.T) {
}
m.ui.httpLogState.cursor = 1
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 1, m.ui.httpLogState.cursor)
}
@@ -1243,7 +1243,7 @@ func TestHandleHTTPLogKeys_Down_AtBottom_EnablesAutoScroll(t *testing.T) {
m.ui.httpLogState.cursor = 0
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1257,7 +1257,7 @@ func TestHandleHTTPLogKeys_GoToTop_G(t *testing.T) {
}
m.ui.httpLogState.cursor = 2
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
@@ -1272,7 +1272,7 @@ func TestHandleHTTPLogKeys_GoToBottom_CapitalG(t *testing.T) {
}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}
keyMsg := tea.KeyPressMsg{Text: "G"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 2, m.ui.httpLogState.cursor)
@@ -1283,7 +1283,7 @@ func TestHandleHTTPLogKeys_ToggleAutoScroll(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1301,12 +1301,12 @@ func TestHandleHTTPLogKeys_PgUpDown_List(t *testing.T) {
m.ui.httpLogState.entries = entries
m.ui.httpLogState.cursor = 25
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.cursor)
assert.False(t, m.ui.httpLogState.autoScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
// PgDown +20 from 5 = 25; index 25 is in range (30 items, indices 0-29).
assert.Equal(t, 25, m.ui.httpLogState.cursor)
@@ -1319,7 +1319,7 @@ func TestHandleHTTPLogKeys_FilterActive_Typing(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "te"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}
keyMsg := tea.KeyPressMsg{Text: "s"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "tes", m.ui.httpLogState.filterText)
@@ -1330,7 +1330,7 @@ func TestHandleHTTPLogKeys_FilterActive_Backspace(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "tes"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "te", m.ui.httpLogState.filterText)
@@ -1341,7 +1341,7 @@ func TestHandleHTTPLogKeys_FilterActive_EscClearsFilter(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "test"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
@@ -1353,7 +1353,7 @@ func TestHandleHTTPLogKeys_FilterActive_EnterConfirms(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "api"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +12,7 @@ import (
func typeKeys(t *testing.T, m model, text string) {
t.Helper()
for _, r := range text {
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string(r)})
}
}
@@ -87,7 +87,7 @@ func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.namespaceManual = true
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
m.handleAddWizardKeys(tea.KeyPressMsg{Code: tea.KeyEscape})
w := m.ui.addWizard
assert.Equal(t, StepSelectNamespace, w.step)
@@ -98,7 +98,7 @@ func TestKeysJK_StillNavigateInListMode(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.inputMode = InputModeList
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string('j')})
assert.Equal(t, 1, m.ui.addWizard.cursor)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// Color palette for wizards
+9 -9
View File
@@ -987,7 +987,7 @@ func TestRenderDeleteConfirmation_NoSelected(t *testing.T) {
func TestModelView_MainViewDefault(t *testing.T) {
m := newTestModel()
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1001,7 +1001,7 @@ func TestModelView_DeleteConfirmationOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "Delete Port Forward")
}
@@ -1013,7 +1013,7 @@ func TestModelView_AddWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1028,7 +1028,7 @@ func TestModelView_RemoveWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "alpha")
}
@@ -1040,7 +1040,7 @@ func TestModelView_BenchmarkOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1052,7 +1052,7 @@ func TestModelView_HTTPLogFullScreen(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "HTTP Traffic Log")
}
@@ -1061,7 +1061,7 @@ func TestModelView_ZeroTermSize(t *testing.T) {
m.termWidth = 0
m.termHeight = 0
// Must not panic even with zero term dims.
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1406,8 +1406,8 @@ func TestGetStatusIconAndText(t *testing.T) {
func TestDefaultMainViewColors(t *testing.T) {
colors := defaultMainViewColors()
assert.NotEmpty(t, string(colors.header))
assert.NotEmpty(t, string(colors.active))
assert.NotNil(t, colors.header)
assert.NotNil(t, colors.active)
}
func TestMainViewKeyBindings(t *testing.T) {
+11
View File
@@ -0,0 +1,11 @@
version: "2"
# Vendored upstream code; only the documented patch is ours.
linters:
default: none
enable:
- govet
settings:
govet:
disable:
- composites
+2
View File
@@ -0,0 +1,2 @@
Vendored github.com/grandcat/zeroconf v1.0.0 (MIT, see LICENSE).
Patch: server.go handleQuestion answers A/AAAA queries for the registered hostname.
+27
View File
@@ -0,0 +1,27 @@
The MIT License (MIT)
Copyright (c) 2016 Stefan Smarzly
Copyright (c) 2014 Oleksandr Lobunets
Note: Copyright for portions of project zeroconf.sd are held by Oleksandr
Lobunets, 2014, as part of project bonjour. All other copyright for
project zeroconf.sd are held by Stefan Smarzly, 2016.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+456
View File
@@ -0,0 +1,456 @@
package zeroconf
import (
"context"
"fmt"
"net"
"strings"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"time"
"github.com/cenkalti/backoff"
"github.com/miekg/dns"
)
// IPType specifies the IP traffic the client listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, often contain
// both DNS A and AAAA entries.
type IPType uint8
// Options for IPType.
const (
IPv4 = 0x01
IPv6 = 0x02
IPv4AndIPv6 = (IPv4 | IPv6) //< Default option.
)
type clientOpts struct {
listenOn IPType
ifaces []net.Interface
}
// ClientOption fills the option struct to configure intefaces, etc.
type ClientOption func(*clientOpts)
// SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this
// instance listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, may contain
// both DNS A and AAAA entries.
func SelectIPTraffic(t IPType) ClientOption {
return func(o *clientOpts) {
o.listenOn = t
}
}
// SelectIfaces selects the interfaces to query for mDNS records
func SelectIfaces(ifaces []net.Interface) ClientOption {
return func(o *clientOpts) {
o.ifaces = ifaces
}
}
// Resolver acts as entry point for service lookups and to browse the DNS-SD.
type Resolver struct {
c *client
}
// NewResolver creates a new resolver and joins the UDP multicast groups to
// listen for mDNS messages.
func NewResolver(options ...ClientOption) (*Resolver, error) {
// Apply default configuration and load supplied options.
var conf = clientOpts{
listenOn: IPv4AndIPv6,
}
for _, o := range options {
if o != nil {
o(&conf)
}
}
c, err := newClient(conf)
if err != nil {
return nil, err
}
return &Resolver{
c: c,
}, nil
}
// Browse for all services of a given type in a given domain.
func (r *Resolver) Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
if domain != "" {
params.Domain = domain
}
params.Entries = entries
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// Lookup a specific service by its name and type in a given domain.
func (r *Resolver) Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
params.Instance = instance
if domain != "" {
params.Domain = domain
}
params.Entries = entries
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
// cancel mainloop
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// defaultParams returns a default set of QueryParams.
func defaultParams(service string) *LookupParams {
return NewLookupParams("", service, "local", make(chan *ServiceEntry))
}
// Client structure encapsulates both IPv4/IPv6 UDP connections.
type client struct {
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
}
// Client structure constructor
func newClient(opts clientOpts) (*client, error) {
ifaces := opts.ifaces
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
// IPv4 interfaces
var ipv4conn *ipv4.PacketConn
if (opts.listenOn & IPv4) > 0 {
var err error
ipv4conn, err = joinUdp4Multicast(ifaces)
if err != nil {
return nil, err
}
}
// IPv6 interfaces
var ipv6conn *ipv6.PacketConn
if (opts.listenOn & IPv6) > 0 {
var err error
ipv6conn, err = joinUdp6Multicast(ifaces)
if err != nil {
return nil, err
}
}
return &client{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
}, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (c *client) mainloop(ctx context.Context, params *LookupParams) {
// start listening for responses
msgCh := make(chan *dns.Msg, 32)
if c.ipv4conn != nil {
go c.recv(ctx, c.ipv4conn, msgCh)
}
if c.ipv6conn != nil {
go c.recv(ctx, c.ipv6conn, msgCh)
}
// Iterate through channels from listeners goroutines
var entries, sentEntries map[string]*ServiceEntry
sentEntries = make(map[string]*ServiceEntry)
for {
select {
case <-ctx.Done():
// Context expired. Notify subscriber that we are done here.
params.done()
c.shutdown()
return
case msg := <-msgCh:
entries = make(map[string]*ServiceEntry)
sections := append(msg.Answer, msg.Ns...)
sections = append(sections, msg.Extra...)
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.PTR:
if params.ServiceName() != rr.Hdr.Name {
continue
}
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr {
continue
}
if _, ok := entries[rr.Ptr]; !ok {
entries[rr.Ptr] = NewServiceEntry(
trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)),
params.Service,
params.Domain)
}
entries[rr.Ptr].TTL = rr.Hdr.Ttl
case *dns.SRV:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].HostName = rr.Target
entries[rr.Hdr.Name].Port = int(rr.Port)
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
case *dns.TXT:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].Text = rr.Txt
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
}
}
// Associate IPs in a second round as other fields should be filled by now.
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.A:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A)
}
}
case *dns.AAAA:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA)
}
}
}
}
}
if len(entries) > 0 {
for k, e := range entries {
if e.TTL == 0 {
delete(entries, k)
delete(sentEntries, k)
continue
}
if _, ok := sentEntries[k]; ok {
continue
}
// If this is an DNS-SD query do not throw PTR away.
// It is expected to have only PTR for enumeration
if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() {
// Require at least one resolved IP address for ServiceEntry
// TODO: wait some more time as chances are high both will arrive.
if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 {
continue
}
}
// Submit entry to subscriber and cache it.
// This is also a point to possibly stop probing actively for a
// service entry.
params.Entries <- e
sentEntries[k] = e
params.disableProbing()
}
// reset entries
entries = make(map[string]*ServiceEntry)
}
}
}
// Shutdown client will close currently open connections and channel implicitly.
func (c *client) shutdown() {
if c.ipv4conn != nil {
c.ipv4conn.Close()
}
if c.ipv6conn != nil {
c.ipv6conn.Close()
}
}
// Data receiving routine reads from connection, unpacks packets into dns.Msg
// structures and sends them to a given msgCh channel
func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) {
var readFrom func([]byte) (n int, src net.Addr, err error)
switch pConn := l.(type) {
case *ipv6.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
case *ipv4.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
default:
return
}
buf := make([]byte, 65536)
var fatalErr error
for {
// Handles the following cases:
// - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel
// - ReadFrom aborts otherwise.
// TODO: the context check can be removed. Verify!
if ctx.Err() != nil || fatalErr != nil {
return
}
n, _, err := readFrom(buf)
if err != nil {
fatalErr = err
continue
}
msg := new(dns.Msg)
if err := msg.Unpack(buf[:n]); err != nil {
// log.Printf("[WARN] mdns: Failed to unpack packet: %v", err)
continue
}
select {
case msgCh <- msg:
// Submit decoded DNS message and continue.
case <-ctx.Done():
// Abort.
return
}
}
}
// periodicQuery sens multiple probes until a valid response is received by
// the main processing loop or some timeout/cancel fires.
// TODO: move error reporting to shutdown function as periodicQuery is called from
// go routine context.
func (c *client) periodicQuery(ctx context.Context, params *LookupParams) error {
if params.stopProbing == nil {
return nil
}
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 4 * time.Second
bo.MaxInterval = 60 * time.Second
bo.Reset()
for {
// Do periodic query.
if err := c.query(params); err != nil {
return err
}
// Backoff and cancel logic.
wait := bo.NextBackOff()
if wait == backoff.Stop {
return fmt.Errorf("periodicQuery: abort due to timeout")
}
select {
case <-time.After(wait):
// Wait for next iteration.
case <-params.stopProbing:
// Chan is closed (or happened in the past).
// Done here. Received a matching mDNS entry.
return nil
case <-ctx.Done():
return ctx.Err()
}
}
}
// Performs the actual query by service name (browse) or service instance name (lookup),
// start response listeners goroutines and loops over the entries channel.
func (c *client) query(params *LookupParams) error {
var serviceName, serviceInstanceName string
serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain))
if params.Instance != "" {
serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName)
}
// send the query
m := new(dns.Msg)
if serviceInstanceName != "" {
m.Question = []dns.Question{
dns.Question{serviceInstanceName, dns.TypeSRV, dns.ClassINET},
dns.Question{serviceInstanceName, dns.TypeTXT, dns.ClassINET},
}
m.RecursionDesired = false
} else {
m.SetQuestion(serviceName, dns.TypePTR)
m.RecursionDesired = false
}
if err := c.sendQuery(m); err != nil {
return err
}
return nil
}
// Pack the dns.Msg and write to available connections (multicast)
func (c *client) sendQuery(msg *dns.Msg) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if c.ipv4conn != nil {
var wcm ipv4.ControlMessage
for ifi := range c.ifaces {
wcm.IfIndex = c.ifaces[ifi].Index
c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
if c.ipv6conn != nil {
var wcm ipv6.ControlMessage
for ifi := range c.ifaces {
wcm.IfIndex = c.ifaces[ifi].Index
c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package zeroconf
import (
"fmt"
"net"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var (
// Multicast groups used by mDNS
mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251)
mdnsGroupIPv6 = net.ParseIP("ff02::fb")
// mDNS wildcard addresses
mdnsWildcardAddrIPv4 = &net.UDPAddr{
IP: net.ParseIP("224.0.0.0"),
Port: 5353,
}
mdnsWildcardAddrIPv6 = &net.UDPAddr{
IP: net.ParseIP("ff02::"),
// IP: net.ParseIP("fd00::12d3:26e7:48db:e7d"),
Port: 5353,
}
// mDNS endpoint addresses
ipv4Addr = &net.UDPAddr{
IP: mdnsGroupIPv4,
Port: 5353,
}
ipv6Addr = &net.UDPAddr{
IP: mdnsGroupIPv6,
Port: 5353,
}
)
func joinUdp6Multicast(interfaces []net.Interface) (*ipv6.PacketConn, error) {
udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
if err != nil {
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv6.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv6.FlagInterface, true)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
// log.Println("Udp6 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) {
udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
if err != nil {
// log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err)
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv4.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv4.FlagInterface, true)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
// log.Println("Udp4 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp4: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func listMulticastInterfaces() []net.Interface {
var interfaces []net.Interface
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for _, ifi := range ifaces {
if (ifi.Flags & net.FlagUp) == 0 {
continue
}
if (ifi.Flags & net.FlagMulticast) > 0 {
interfaces = append(interfaces, ifi)
}
}
return interfaces
}
+14
View File
@@ -0,0 +1,14 @@
// Package zeroconf is a pure Golang library that employs Multicast DNS-SD for
// browsing and resolving services in your network and registering own services
// in the local network.
//
// It basically implements aspects of the standards
// RFC 6762 (mDNS) and
// RFC 6763 (DNS-SD).
// Though it does not support all requirements yet, the aim is to provide a
// complient solution in the long-term with the community.
//
// By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and
// Apple's Bonjour (untested). Should work in the most office, home and private
// environments.
package zeroconf
+14
View File
@@ -0,0 +1,14 @@
module github.com/grandcat/zeroconf
go 1.26.0
require (
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/miekg/dns v1.1.73
golang.org/x/net v0.60.0
)
require (
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+42
View File
@@ -0,0 +1,42 @@
package zeroconf
import (
"net"
"testing"
"github.com/miekg/dns"
)
func TestHandleQuestionAnswersHostnameA(t *testing.T) {
svc := NewServiceEntry("alias", "_kportal._tcp", "local.")
svc.HostName = "alias.local."
svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)}
s := &Server{service: svc, ttl: 3200}
q := dns.Question{Name: "alias.local.", Qtype: dns.TypeA, Qclass: dns.ClassINET}
resp := new(dns.Msg)
if err := s.handleQuestion(q, resp, new(dns.Msg), 0); err != nil {
t.Fatal(err)
}
if len(resp.Answer) != 1 {
t.Fatalf("want 1 answer, got %d", len(resp.Answer))
}
a, ok := resp.Answer[0].(*dns.A)
if !ok || !a.A.Equal(net.IPv4(127, 0, 0, 1)) || a.Hdr.Name != "alias.local." {
t.Fatalf("unexpected answer: %v", resp.Answer[0])
}
}
func TestHandleQuestionIgnoresOtherTypesForHostname(t *testing.T) {
svc := NewServiceEntry("alias", "_kportal._tcp", "local.")
svc.HostName = "alias.local."
svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)}
s := &Server{service: svc, ttl: 3200}
q := dns.Question{Name: "alias.local.", Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
resp := new(dns.Msg)
_ = s.handleQuestion(q, resp, new(dns.Msg), 0)
if len(resp.Answer) != 0 {
t.Fatalf("want no answer, got %d", len(resp.Answer))
}
}
+750
View File
@@ -0,0 +1,750 @@
package zeroconf
import (
"fmt"
"log"
"math/rand"
"net"
"os"
"strings"
"sync"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"errors"
"github.com/miekg/dns"
)
const (
// Number of Multicast responses sent for a query message (default: 1 < x < 9)
multicastRepetitions = 2
)
// Register a service by given arguments. This call will take the system's hostname
// and lookup IP by that hostname.
func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
if entry.Instance == "" {
return nil, fmt.Errorf("Missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("Missing service name")
}
if entry.Domain == "" {
entry.Domain = "local."
}
if entry.Port == 0 {
return nil, fmt.Errorf("Missing port")
}
var err error
if entry.HostName == "" {
entry.HostName, err = os.Hostname()
if err != nil {
return nil, fmt.Errorf("Could not determine host")
}
}
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
for _, iface := range ifaces {
v4, v6 := addrsForInterface(&iface)
entry.AddrIPv4 = append(entry.AddrIPv4, v4...)
entry.AddrIPv6 = append(entry.AddrIPv6, v6...)
}
if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil {
return nil, fmt.Errorf("Could not determine host IP addresses")
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
// RegisterProxy registers a service proxy. This call will skip the hostname/IP lookup and
// will use the provided values.
func RegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
entry.HostName = host
if entry.Instance == "" {
return nil, fmt.Errorf("Missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("Missing service name")
}
if entry.HostName == "" {
return nil, fmt.Errorf("Missing host name")
}
if entry.Domain == "" {
entry.Domain = "local"
}
if entry.Port == 0 {
return nil, fmt.Errorf("Missing port")
}
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
for _, ip := range ips {
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
return nil, fmt.Errorf("Failed to parse given IP: %v", ip)
} else if ipv4 := ipAddr.To4(); ipv4 != nil {
entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr)
} else if ipv6 := ipAddr.To16(); ipv6 != nil {
entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr)
} else {
return nil, fmt.Errorf("The IP is neither IPv4 nor IPv6: %#v", ipAddr)
}
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
const (
qClassCacheFlush uint16 = 1 << 15
)
// Server structure encapsulates both IPv4/IPv6 UDP connections
type Server struct {
service *ServiceEntry
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
shouldShutdown chan struct{}
shutdownLock sync.Mutex
shutdownEnd sync.WaitGroup
isShutdown bool
ttl uint32
}
// Constructs server structure
func newServer(ifaces []net.Interface) (*Server, error) {
ipv4conn, err4 := joinUdp4Multicast(ifaces)
if err4 != nil {
log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error())
}
ipv6conn, err6 := joinUdp6Multicast(ifaces)
if err6 != nil {
log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error())
}
if err4 != nil && err6 != nil {
// No supported interface left.
return nil, fmt.Errorf("No supported interface")
}
s := &Server{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
ttl: 3200,
shouldShutdown: make(chan struct{}),
}
return s, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (s *Server) mainloop() {
if s.ipv4conn != nil {
go s.recv4(s.ipv4conn)
}
if s.ipv6conn != nil {
go s.recv6(s.ipv6conn)
}
}
// Shutdown closes all udp connections and unregisters the service
func (s *Server) Shutdown() {
s.shutdown()
}
// SetText updates and announces the TXT records
func (s *Server) SetText(text []string) {
s.service.Text = text
s.announceText()
}
// TTL sets the TTL for DNS replies
func (s *Server) TTL(ttl uint32) {
s.ttl = ttl
}
// Shutdown server will close currently open connections & channel
func (s *Server) shutdown() error {
s.shutdownLock.Lock()
defer s.shutdownLock.Unlock()
if s.isShutdown {
return errors.New("Server is already shutdown")
}
err := s.unregister()
if err != nil {
return err
}
close(s.shouldShutdown)
if s.ipv4conn != nil {
s.ipv4conn.Close()
}
if s.ipv6conn != nil {
s.ipv6conn.Close()
}
// Wait for connection and routines to be closed
s.shutdownEnd.Wait()
s.isShutdown = true
return nil
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv4(c *ipv4.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
if err := s.parsePacket(buf[:n], ifIndex, from); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle query v4: %v", err)
}
}
}
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv6(c *ipv6.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
if err := s.parsePacket(buf[:n], ifIndex, from); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle query v6: %v", err)
}
}
}
}
// parsePacket is used to parse an incoming packet
func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error {
var msg dns.Msg
if err := msg.Unpack(packet); err != nil {
// log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err)
return err
}
return s.handleQuery(&msg, ifIndex, from)
}
// handleQuery is used to handle an incoming query
func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error {
// Ignore questions with authoritative section for now
if len(query.Ns) > 0 {
return nil
}
// Handle each question
var err error
for _, q := range query.Question {
resp := dns.Msg{}
resp.SetReply(query)
resp.Compress = true
resp.RecursionDesired = false
resp.Authoritative = true
resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions"
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err)
continue
}
// Check if there is an answer
if len(resp.Answer) == 0 {
continue
}
if isUnicastQuestion(q) {
// Send unicast
if e := s.unicastResponse(&resp, ifIndex, from); e != nil {
err = e
}
} else {
// Send mulicast
if e := s.multicastResponse(&resp, ifIndex); e != nil {
err = e
}
}
}
return err
}
// RFC6762 7.1. Known-Answer Suppression
func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool {
if len(resp.Answer) == 0 || len(query.Answer) == 0 {
return false
}
if resp.Answer[0].Header().Rrtype != dns.TypePTR {
return false
}
answer := resp.Answer[0].(*dns.PTR)
for _, known := range query.Answer {
hdr := known.Header()
if hdr.Rrtype != answer.Hdr.Rrtype {
continue
}
ptr := known.(*dns.PTR)
if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 {
// log.Printf("skipping known answer: %v", ptr)
return true
}
}
return false
}
// handleQuestion is used to handle an incoming question
func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error {
if s.service == nil {
return nil
}
switch q.Name {
case s.service.ServiceTypeName():
s.serviceTypeName(resp, s.ttl)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceName():
s.composeBrowsingAnswers(resp, ifIndex)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceInstanceName():
s.composeLookupAnswers(resp, s.ttl, ifIndex, false)
// kportal patch: answer direct A/AAAA lookups for the registered hostname
// so the address record can be refreshed after its TTL expires.
case s.service.HostName:
if q.Qtype == dns.TypeA || q.Qtype == dns.TypeAAAA || q.Qtype == dns.TypeANY {
resp.Answer = s.appendAddrs(resp.Answer, s.ttl, ifIndex, false)
}
}
return nil
}
func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) {
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
resp.Answer = append(resp.Answer, ptr)
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
resp.Extra = append(resp.Extra, srv, txt)
resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false)
}
func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) {
// From RFC6762
// The most significant bit of the rrclass for a record in the Answer
// Section of a response message is the Multicast DNS cache-flush bit
// and is discussed in more detail below in Section 10.2, "Announcements
// to Flush Outdated Cache Entries".
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Txt: s.service.Text,
}
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd)
resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache)
}
func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) {
// From RFC6762
// 9. Service Type Enumeration
//
// For this purpose, a special meta-query is defined. A DNS query for
// PTR records with the name "_services._dns-sd._udp.<Domain>" yields a
// set of PTR records, where the rdata of each PTR record is the two-
// label <Service> name, plus the same domain, e.g.,
// "_http._tcp.<Domain>".
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, dnssd)
}
// Perform probing & announcement
//TODO: implement a proper probing & conflict resolution
func (s *Server) probe() {
q := new(dns.Msg)
q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR)
q.RecursionDesired = false
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
q.Ns = []dns.RR{srv, txt}
randomizer := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < multicastRepetitions; i++ {
if err := s.multicastResponse(q, 0); err != nil {
log.Println("[ERR] zeroconf: failed to send probe:", err.Error())
}
time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond)
}
// From RFC6762
// The Multicast DNS responder MUST send at least two unsolicited
// responses, one second apart. To provide increased robustness against
// packet loss, a responder MAY send up to eight unsolicited responses,
// provided that the interval between unsolicited responses increases by
// at least a factor of two with every response sent.
timeout := 1 * time.Second
for i := 0; i < multicastRepetitions; i++ {
for _, intf := range s.ifaces {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
// TODO: make response authoritative if we are the publisher
resp.Compress = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, s.ttl, intf.Index, true)
if err := s.multicastResponse(resp, intf.Index); err != nil {
log.Println("[ERR] zeroconf: failed to send announcement:", err.Error())
}
}
time.Sleep(timeout)
timeout *= 2
}
}
// announceText sends a Text announcement with cache flush enabled
func (s *Server) announceText() {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
resp.Answer = []dns.RR{txt}
s.multicastResponse(resp, 0)
}
func (s *Server) unregister() error {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, 0, 0, true)
return s.multicastResponse(resp, 0)
}
func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR {
v4 := s.service.AddrIPv4
v6 := s.service.AddrIPv6
if len(v4) == 0 && len(v6) == 0 {
iface, _ := net.InterfaceByIndex(ifIndex)
if iface != nil {
a4, a6 := addrsForInterface(iface)
v4 = append(v4, a4...)
v6 = append(v6, a6...)
}
}
if ttl > 0 {
// RFC6762 Section 10 says A/AAAA records SHOULD
// use TTL of 120s, to account for network interface
// and IP address changes.
ttl = 120
}
var cacheFlushBit uint16
if flushCache {
cacheFlushBit = qClassCacheFlush
}
for _, ipv4 := range v4 {
a := &dns.A{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
A: ipv4,
}
list = append(list, a)
}
for _, ipv6 := range v6 {
aaaa := &dns.AAAA{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
AAAA: ipv6,
}
list = append(list, aaaa)
}
return list
}
func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) {
var v4, v6, v6local []net.IP
addrs, _ := iface.Addrs()
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
v4 = append(v4, ipnet.IP)
} else {
switch ip := ipnet.IP.To16(); ip != nil {
case ip.IsGlobalUnicast():
v6 = append(v6, ipnet.IP)
case ip.IsLinkLocalUnicast():
v6local = append(v6local, ipnet.IP)
}
}
}
}
if len(v6) == 0 {
v6 = v6local
}
return v4, v6
}
// unicastResponse is used to send a unicast response packet
func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error {
buf, err := resp.Pack()
if err != nil {
return err
}
addr := from.(*net.UDPAddr)
if addr.IP.To4() != nil {
if ifIndex != 0 {
var wcm ipv4.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv4conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv4conn.WriteTo(buf, nil, addr)
}
return err
} else {
if ifIndex != 0 {
var wcm ipv6.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv6conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv6conn.WriteTo(buf, nil, addr)
}
return err
}
}
// multicastResponse us used to send a multicast response packet
func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if s.ipv4conn != nil {
var wcm ipv4.ControlMessage
if ifIndex != 0 {
wcm.IfIndex = ifIndex
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
} else {
for _, intf := range s.ifaces {
wcm.IfIndex = intf.Index
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
}
if s.ipv6conn != nil {
var wcm ipv6.ControlMessage
if ifIndex != 0 {
wcm.IfIndex = ifIndex
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
} else {
for _, intf := range s.ifaces {
wcm.IfIndex = intf.Index
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
}
return nil
}
func isUnicastQuestion(q dns.Question) bool {
// From RFC6762
// 18.12. Repurposing of Top Bit of qclass in Question Section
//
// In the Question Section of a Multicast DNS query, the top bit of the
// qclass field is used to indicate that unicast responses are preferred
// for this particular question. (See Section 5.4.)
return q.Qclass&qClassCacheFlush != 0
}
+109
View File
@@ -0,0 +1,109 @@
package zeroconf
import (
"fmt"
"net"
"sync"
)
// ServiceRecord contains the basic description of a service, which contains instance name, service type & domain
type ServiceRecord struct {
Instance string `json:"name"` // Instance name (e.g. "My web page")
Service string `json:"type"` // Service name (e.g. _http._tcp.)
Domain string `json:"domain"` // If blank, assumes "local"
// private variable populated on ServiceRecord creation
serviceName string
serviceInstanceName string
serviceTypeName string
}
// ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed
// of a service name (also referred as service type) and a domain.
func (s *ServiceRecord) ServiceName() string {
return s.serviceName
}
// ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.),
// which is composed from service instance name, service name and a domain.
func (s *ServiceRecord) ServiceInstanceName() string {
return s.serviceInstanceName
}
// ServiceTypeName returns the complete identifier for a DNS-SD query.
func (s *ServiceRecord) ServiceTypeName() string {
return s.serviceTypeName
}
// NewServiceRecord constructs a ServiceRecord.
func NewServiceRecord(instance, service, domain string) *ServiceRecord {
s := &ServiceRecord{
Instance: instance,
Service: service,
Domain: domain,
serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)),
}
// Cache service instance name
if instance != "" {
s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName())
}
// Cache service type name domain
typeNameDomain := "local"
if len(s.Domain) > 0 {
typeNameDomain = trimDot(s.Domain)
}
s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain)
return s
}
// LookupParams contains configurable properties to create a service discovery request
type LookupParams struct {
ServiceRecord
Entries chan<- *ServiceEntry // Entries Channel
stopProbing chan struct{}
once sync.Once
}
// NewLookupParams constructs a LookupParams.
func NewLookupParams(instance, service, domain string, entries chan<- *ServiceEntry) *LookupParams {
return &LookupParams{
ServiceRecord: *NewServiceRecord(instance, service, domain),
Entries: entries,
stopProbing: make(chan struct{}),
}
}
// Notify subscriber that no more entries will arrive. Mostly caused
// by an expired context.
func (l *LookupParams) done() {
close(l.Entries)
}
func (l *LookupParams) disableProbing() {
l.once.Do(func() { close(l.stopProbing) })
}
// ServiceEntry represents a browse/lookup result for client API.
// It is also used to configure service registration (server API), which is
// used to answer multicast queries.
type ServiceEntry struct {
ServiceRecord
HostName string `json:"hostname"` // Host machine DNS name
Port int `json:"port"` // Service Port
Text []string `json:"text"` // Service info served as a TXT record
TTL uint32 `json:"ttl"` // TTL of the service record
AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address
AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address
}
// NewServiceEntry constructs a ServiceEntry.
func NewServiceEntry(instance, service, domain string) *ServiceEntry {
return &ServiceEntry{
ServiceRecord: *NewServiceRecord(instance, service, domain),
}
}
+8
View File
@@ -0,0 +1,8 @@
package zeroconf
import "strings"
// trimDot is used to trim the dots from the start or end of a string
func trimDot(s string) string {
return strings.Trim(s, ".")
}