From d56e685ff6c9b5f2b4423e275de8e83f720e411b Mon Sep 17 00:00:00 2001 From: Prakersh Maheshwari Date: Mon, 9 Mar 2026 21:43:32 +0530 Subject: [PATCH 01/19] feat(menubar): add companion runtime and backend APIs --- go.mod | 33 ++ go.sum | 110 ++++- internal/menubar/assets.go | 57 +++ internal/menubar/companion_darwin.go | 195 +++++++++ internal/menubar/components/.gitkeep | 1 + internal/menubar/config.go | 165 +++++++ internal/menubar/frontend/assets/.gitkeep | 1 + internal/menubar/frontend/index.html | 15 + internal/menubar/frontend/menubar.css | 472 ++++++++++++++++++++ internal/menubar/frontend/menubar.js | 296 +++++++++++++ internal/menubar/icon.go | 41 ++ internal/menubar/menubar_darwin.go | 33 ++ internal/menubar/menubar_stub.go | 15 + internal/menubar/menubar_test.go | 55 +++ internal/menubar/runtime_state.go | 57 +++ internal/menubar/views/.gitkeep | 1 + internal/store/menubar_test.go | 77 ++++ internal/store/store.go | 34 ++ internal/web/handlers.go | 25 ++ internal/web/menubar.go | 509 ++++++++++++++++++++++ internal/web/menubar_test.go | 197 +++++++++ internal/web/server.go | 3 + main.go | 23 + menubar_runtime.go | 177 ++++++++ 24 files changed, 2586 insertions(+), 6 deletions(-) create mode 100644 internal/menubar/assets.go create mode 100644 internal/menubar/companion_darwin.go create mode 100644 internal/menubar/components/.gitkeep create mode 100644 internal/menubar/config.go create mode 100644 internal/menubar/frontend/assets/.gitkeep create mode 100644 internal/menubar/frontend/index.html create mode 100644 internal/menubar/frontend/menubar.css create mode 100644 internal/menubar/frontend/menubar.js create mode 100644 internal/menubar/icon.go create mode 100644 internal/menubar/menubar_darwin.go create mode 100644 internal/menubar/menubar_stub.go create mode 100644 internal/menubar/menubar_test.go create mode 100644 internal/menubar/runtime_state.go create mode 100644 internal/menubar/views/.gitkeep create mode 100644 internal/store/menubar_test.go create mode 100644 internal/web/menubar.go create mode 100644 internal/web/menubar_test.go create mode 100644 menubar_runtime.go diff --git a/go.mod b/go.mod index bf02cb2..62478b7 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,52 @@ module github.com/onllm-dev/onwatch/v2 go 1.25.7 require ( + github.com/getlantern/systray v1.2.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/wailsapp/wails/v2 v2.11.0 golang.org/x/crypto v0.47.0 modernc.org/sqlite v1.44.3 ) require ( + github.com/bep/debounce v1.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 445a34f..d1ccf94 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,130 @@ +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/internal/menubar/assets.go b/internal/menubar/assets.go new file mode 100644 index 0000000..3154355 --- /dev/null +++ b/internal/menubar/assets.go @@ -0,0 +1,57 @@ +package menubar + +import ( + "encoding/json" + "embed" + "fmt" + "io/fs" + "strings" +) + +//go:embed frontend/index.html frontend/menubar.css frontend/menubar.js +var frontendFS embed.FS + +// FrontendAsset returns a named frontend asset. +func FrontendAsset(name string) ([]byte, error) { + return frontendFS.ReadFile("frontend/" + name) +} + +// FrontendSubFS exposes the embedded frontend directory. +func FrontendSubFS() (fs.FS, error) { + return fs.Sub(frontendFS, "frontend") +} + +// HTML returns the embedded index document. +func HTML() (string, error) { + data, err := FrontendAsset("index.html") + if err != nil { + return "", err + } + return string(data), nil +} + +// InlineHTML renders the menubar HTML with inline CSS and JS for the browser test page. +func InlineHTML(view ViewType, settings *Settings) (string, error) { + indexHTML, err := HTML() + if err != nil { + return "", err + } + css, err := FrontendAsset("menubar.css") + if err != nil { + return "", err + } + js, err := FrontendAsset("menubar.js") + if err != nil { + return "", err + } + normalized := settings.Normalize() + normalized.DefaultView = view + payload, err := json.Marshal(normalized) + if err != nil { + return "", fmt.Errorf("menubar.InlineHTML: %w", err) + } + bootstrap := fmt.Sprintf(``, view, payload) + indexHTML = strings.Replace(indexHTML, ``, "", 1) + indexHTML = strings.Replace(indexHTML, ``, bootstrap+"", 1) + return indexHTML, nil +} diff --git a/internal/menubar/companion_darwin.go b/internal/menubar/companion_darwin.go new file mode 100644 index 0000000..1dc8cf6 --- /dev/null +++ b/internal/menubar/companion_darwin.go @@ -0,0 +1,195 @@ +//go:build menubar && darwin + +package menubar + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/getlantern/systray" + "github.com/pkg/browser" + "github.com/wailsapp/wails/v2" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + macoptions "github.com/wailsapp/wails/v2/pkg/options/mac" +) + +type appBridge struct { + ctx context.Context + cfg *Config +} + +func (a *appBridge) startup(ctx context.Context) { + a.ctx = ctx +} + +func (a *appBridge) GetSnapshot() (*Snapshot, error) { + if a.cfg == nil || a.cfg.SnapshotProvider == nil { + return nil, errors.New("snapshot provider not configured") + } + return a.cfg.SnapshotProvider() +} + +func (a *appBridge) GetSettings() (*Settings, error) { + if a.cfg == nil { + return DefaultSettings(), nil + } + settings := &Settings{ + Enabled: a.cfg.Enabled, + DefaultView: a.cfg.DefaultView, + RefreshSeconds: a.cfg.RefreshSeconds, + ProvidersOrder: append([]string(nil), a.cfg.ProvidersOrder...), + WarningPercent: a.cfg.WarningPercent, + CriticalPercent: a.cfg.CriticalPercent, + } + return settings.Normalize(), nil +} + +func (a *appBridge) Refresh() (*Snapshot, error) { + return a.GetSnapshot() +} + +func (a *appBridge) OpenExternal(rawURL string) error { + return browser.OpenURL(rawURL) +} + +var ( + quitOnce sync.Once + quitFn func() +) + +func runCompanion(cfg *Config) error { + assets, err := FrontendSubFS() + if err != nil { + return err + } + quitOnce = sync.Once{} + quitFn = nil + app := &appBridge{cfg: cfg} + showWindowCh := make(chan struct{}, 1) + refreshCh := make(chan struct{}, 1) + + go initTray(cfg.Port, showWindowCh, refreshCh) + + return wails.Run(&options.App{ + Title: "onWatch Menubar", + Width: widthForView(cfg.DefaultView), + Height: heightForView(cfg.DefaultView), + MinWidth: 240, + MinHeight: 120, + MaxWidth: 420, + MaxHeight: 720, + Frameless: true, + AlwaysOnTop: true, + StartHidden: true, + HideWindowOnClose: true, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + Mac: &macoptions.Options{ + TitleBar: &macoptions.TitleBar{ + HideTitleBar: true, + HideToolbarSeparator: true, + UseToolbar: false, + }, + Appearance: macoptions.NSAppearanceNameDarkAqua, + }, + OnDomReady: func(ctx context.Context) { + quitFn = func() { + wailsruntime.Quit(ctx) + } + go func() { + for { + select { + case <-showWindowCh: + wailsruntime.Show(ctx) + case <-refreshCh: + wailsruntime.WindowReload(ctx) + } + } + }() + }, + OnShutdown: func(ctx context.Context) { + quitFn = nil + systray.Quit() + }, + }) +} + +func stopCompanion() error { + quitOnce.Do(func() { + if quitFn != nil { + quitFn() + } + systray.Quit() + }) + return nil +} + +func initTray(port int, showWindowCh chan<- struct{}, refreshCh chan<- struct{}) { + systray.Run(func() { + templateIcon, regularIcon := trayIcons() + if len(templateIcon) > 0 && len(regularIcon) > 0 { + systray.SetTemplateIcon(templateIcon, regularIcon) + } + systray.SetTitle("onWatch") + systray.SetTooltip("onWatch menubar companion") + + openItem := systray.AddMenuItem("Show Snapshot", "Show the menubar snapshot window") + refreshItem := systray.AddMenuItem("Refresh Snapshot", "Refresh the menubar snapshot") + dashboardItem := systray.AddMenuItem("Open Dashboard", "Open the local onWatch dashboard") + systray.AddSeparator() + quitItem := systray.AddMenuItem("Quit Menubar", "Quit the menubar companion") + + go func() { + for { + select { + case <-openItem.ClickedCh: + select { + case showWindowCh <- struct{}{}: + default: + } + case <-refreshItem.ClickedCh: + select { + case refreshCh <- struct{}{}: + default: + } + case <-dashboardItem.ClickedCh: + _ = browser.OpenURL(fmt.Sprintf("http://localhost:%d", port)) + case <-quitItem.ClickedCh: + _ = stopCompanion() + return + } + } + }() + }, func() {}) +} + +func widthForView(view ViewType) int { + switch view { + case ViewMinimal: + return 240 + case ViewDetailed: + return 400 + default: + return 320 + } +} + +func heightForView(view ViewType) int { + switch view { + case ViewMinimal: + return 160 + case ViewDetailed: + return 620 + default: + return 420 + } +} diff --git a/internal/menubar/components/.gitkeep b/internal/menubar/components/.gitkeep new file mode 100644 index 0000000..0e66bf1 --- /dev/null +++ b/internal/menubar/components/.gitkeep @@ -0,0 +1 @@ +# Keeps the planned menubar component package directory tracked. diff --git a/internal/menubar/config.go b/internal/menubar/config.go new file mode 100644 index 0000000..1e10378 --- /dev/null +++ b/internal/menubar/config.go @@ -0,0 +1,165 @@ +package menubar + +import "time" + +// SnapshotProvider returns the latest menubar snapshot. +type SnapshotProvider func() (*Snapshot, error) + +// Config holds runtime configuration for the menubar companion. +type Config struct { + Port int + Enabled bool + DefaultView ViewType + RefreshSeconds int + ProvidersOrder []string + WarningPercent int + CriticalPercent int + BinaryPath string + TestMode bool + SnapshotProvider SnapshotProvider +} + +// Settings holds persisted menubar preferences. +type Settings struct { + Enabled bool `json:"enabled"` + DefaultView ViewType `json:"default_view"` + RefreshSeconds int `json:"refresh_seconds"` + ProvidersOrder []string `json:"providers_order"` + WarningPercent int `json:"warning_percent"` + CriticalPercent int `json:"critical_percent"` +} + +// ViewType controls which preset layout is rendered. +type ViewType string + +const ( + ViewMinimal ViewType = "minimal" + ViewStandard ViewType = "standard" + ViewDetailed ViewType = "detailed" +) + +// Snapshot is the normalized UI contract shared by the desktop app and the +// browser-testable menubar page. +type Snapshot struct { + GeneratedAt time.Time `json:"generated_at"` + UpdatedAgo string `json:"updated_ago"` + Aggregate Aggregate `json:"aggregate"` + Providers []ProviderCard `json:"providers"` +} + +// Aggregate summarizes the overall health across all visible providers. +type Aggregate struct { + ProviderCount int `json:"provider_count"` + WarningCount int `json:"warning_count"` + CriticalCount int `json:"critical_count"` + HighestPercent float64 `json:"highest_percent"` + Status string `json:"status"` + Label string `json:"label"` +} + +// ProviderCard is the top-level card rendered for each provider. +type ProviderCard struct { + ID string `json:"id"` + BaseProvider string `json:"base_provider"` + Label string `json:"label"` + Subtitle string `json:"subtitle,omitempty"` + Status string `json:"status"` + HighestPercent float64 `json:"highest_percent"` + UpdatedAt string `json:"updated_at,omitempty"` + Quotas []QuotaMeter `json:"quotas"` + Trends []TrendSeries `json:"trends,omitempty"` +} + +// QuotaMeter represents one circular quota meter inside a provider card. +type QuotaMeter struct { + Key string `json:"key"` + Label string `json:"label"` + DisplayValue string `json:"display_value"` + Percent float64 `json:"percent"` + Status string `json:"status"` + Used float64 `json:"used,omitempty"` + Limit float64 `json:"limit,omitempty"` + ResetAt string `json:"reset_at,omitempty"` + TimeUntilReset string `json:"time_until_reset,omitempty"` + ProjectedValue float64 `json:"projected_value,omitempty"` + CurrentRate float64 `json:"current_rate,omitempty"` + SparklinePoints []float64 `json:"sparkline_points,omitempty"` +} + +// TrendSeries groups sparkline points for a provider-level detailed view. +type TrendSeries struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` + Points []float64 `json:"points"` +} + +// DefaultConfig returns runtime defaults aligned with the existing app. +func DefaultConfig() *Config { + settings := DefaultSettings() + return &Config{ + Port: 9211, + Enabled: settings.Enabled, + DefaultView: settings.DefaultView, + RefreshSeconds: settings.RefreshSeconds, + ProvidersOrder: append([]string(nil), settings.ProvidersOrder...), + WarningPercent: settings.WarningPercent, + CriticalPercent: settings.CriticalPercent, + } +} + +// DefaultSettings returns persisted defaults for a new install. +func DefaultSettings() *Settings { + return &Settings{ + Enabled: true, + DefaultView: ViewStandard, + RefreshSeconds: 60, + ProvidersOrder: []string{}, + WarningPercent: 70, + CriticalPercent: 90, + } +} + +// Normalize fills invalid or missing settings with safe defaults. +func (s *Settings) Normalize() *Settings { + defaults := DefaultSettings() + if s == nil { + return defaults + } + out := *s + if out.DefaultView == "" { + out.DefaultView = defaults.DefaultView + } + if out.RefreshSeconds < 10 { + out.RefreshSeconds = defaults.RefreshSeconds + } + if out.WarningPercent < 1 || out.WarningPercent > 99 { + out.WarningPercent = defaults.WarningPercent + } + if out.CriticalPercent < 1 || out.CriticalPercent > 100 { + out.CriticalPercent = defaults.CriticalPercent + } + if out.WarningPercent >= out.CriticalPercent { + out.WarningPercent = defaults.WarningPercent + out.CriticalPercent = defaults.CriticalPercent + } + if out.ProvidersOrder == nil { + out.ProvidersOrder = []string{} + } + return &out +} + +// ToConfig converts persisted settings into runtime config values. +func (s *Settings) ToConfig(port int, snapshotProvider SnapshotProvider) *Config { + normalized := s.Normalize() + cfg := DefaultConfig() + cfg.Port = port + cfg.Enabled = normalized.Enabled + cfg.DefaultView = normalized.DefaultView + cfg.RefreshSeconds = normalized.RefreshSeconds + cfg.ProvidersOrder = append([]string(nil), normalized.ProvidersOrder...) + cfg.WarningPercent = normalized.WarningPercent + cfg.CriticalPercent = normalized.CriticalPercent + cfg.SnapshotProvider = snapshotProvider + return cfg +} diff --git a/internal/menubar/frontend/assets/.gitkeep b/internal/menubar/frontend/assets/.gitkeep new file mode 100644 index 0000000..34c2dee --- /dev/null +++ b/internal/menubar/frontend/assets/.gitkeep @@ -0,0 +1 @@ +# Keeps the planned menubar asset directory tracked. diff --git a/internal/menubar/frontend/index.html b/internal/menubar/frontend/index.html new file mode 100644 index 0000000..94faf4c --- /dev/null +++ b/internal/menubar/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + onWatch Menubar + + + + + + + diff --git a/internal/menubar/frontend/menubar.css b/internal/menubar/frontend/menubar.css new file mode 100644 index 0000000..ffe221a --- /dev/null +++ b/internal/menubar/frontend/menubar.css @@ -0,0 +1,472 @@ +:root { + --mb-bg: #09111f; + --mb-surface: rgba(15, 23, 42, 0.92); + --mb-surface-strong: rgba(18, 28, 48, 0.98); + --mb-border: rgba(148, 163, 184, 0.18); + --mb-text: #e5edf8; + --mb-muted: #9fb0c7; + --mb-success: #34d399; + --mb-warning: #fbbf24; + --mb-danger: #fb7185; + --mb-accent: #60a5fa; + --mb-shadow: 0 22px 48px rgba(2, 8, 23, 0.45); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at top right, rgba(96, 165, 250, 0.16), transparent 32%), + radial-gradient(circle at bottom left, rgba(52, 211, 153, 0.08), transparent 28%), + var(--mb-bg); + color: var(--mb-text); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +body { + padding: 12px; +} + +.menubar-shell { + display: flex; + flex-direction: column; + gap: 12px; + min-height: calc(100vh - 24px); +} + +.menubar-panel { + border: 1px solid var(--mb-border); + border-radius: 18px; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(9, 17, 31, 0.98)); + box-shadow: var(--mb-shadow); + backdrop-filter: blur(18px); + overflow: hidden; +} + +.menubar-header, +.menubar-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; +} + +.menubar-header { + border-bottom: 1px solid var(--mb-border); +} + +.menubar-title { + font-size: 16px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.menubar-subtitle, +.provider-meta, +.provider-empty, +.menubar-loading, +.menubar-error { + color: var(--mb-muted); +} + +.menubar-status-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + font-size: 12px; + font-weight: 600; +} + +.menubar-status-dot { + width: 8px; + height: 8px; + border-radius: 999px; +} + +.status-healthy { color: var(--mb-success); } +.status-warning { color: var(--mb-warning); } +.status-danger, +.status-critical { color: var(--mb-danger); } + +.status-healthy .menubar-status-dot, +.status-healthy .meter-ring { + color: var(--mb-success); +} + +.status-warning .menubar-status-dot, +.status-warning .meter-ring { + color: var(--mb-warning); +} + +.status-danger .menubar-status-dot, +.status-danger .meter-ring, +.status-critical .menubar-status-dot, +.status-critical .meter-ring { + color: var(--mb-danger); +} + +.menubar-summary { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--mb-border); +} + +.aggregate-card, +.aggregate-stats { + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 14px; + background: rgba(15, 23, 42, 0.62); + padding: 14px; +} + +.aggregate-percent { + font-size: 32px; + font-weight: 700; + line-height: 1; + margin-bottom: 6px; +} + +.aggregate-label { + font-size: 13px; + color: var(--mb-muted); +} + +.aggregate-status { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.aggregate-stat-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.aggregate-stat { + display: flex; + flex-direction: column; + gap: 3px; +} + +.aggregate-stat strong { + font-size: 17px; +} + +.provider-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; +} + +.provider-empty-state { + border: 1px dashed rgba(148, 163, 184, 0.24); + border-radius: 16px; + padding: 24px 18px; + text-align: center; + color: var(--mb-muted); + background: rgba(10, 18, 34, 0.52); +} + +.provider-card { + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 16px; + background: rgba(10, 18, 34, 0.8); + overflow: hidden; +} + +.provider-card summary { + list-style: none; + cursor: pointer; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 14px 16px; +} + +.provider-card summary::-webkit-details-marker { + display: none; +} + +.provider-card summary:focus-visible { + box-shadow: inset 0 0 0 2px rgba(96, 165, 250, 0.32); +} + +.provider-name-row { + display: flex; + align-items: center; + gap: 10px; +} + +.provider-name { + font-size: 15px; + font-weight: 700; +} + +.provider-percent { + font-size: 13px; + font-weight: 700; +} + +.provider-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 16px 16px; +} + +.provider-quotas { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.provider-card[data-view="minimal"] .provider-body { + display: none; +} + +.provider-card[data-view="minimal"] .provider-quotas { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.provider-card[data-view="detailed"] .provider-quotas { + grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); +} + +.quota-meter { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-width: 0; +} + +.meter-shell { + position: relative; + width: 78px; + height: 78px; +} + +.meter-svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.meter-track { + fill: none; + stroke: rgba(148, 163, 184, 0.16); + stroke-width: 8; +} + +.meter-ring { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 8; + transition: stroke-dashoffset 180ms ease, stroke 180ms ease; +} + +.meter-value { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; +} + +.meter-label { + max-width: 100%; + text-align: center; + font-size: 11px; + line-height: 1.35; + color: var(--mb-muted); +} + +.provider-trends { + display: grid; + gap: 8px; +} + +.trend-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 80px; + align-items: center; + gap: 10px; +} + +.trend-line { + width: 100%; + height: 26px; +} + +.trend-line polyline { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.trend-label { + font-size: 11px; + color: var(--mb-muted); +} + +.menubar-footer { + border-top: 1px solid var(--mb-border); +} + +.footer-links { + display: flex; + align-items: center; + gap: 10px; +} + +.footer-links a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 999px; + color: var(--mb-text); + text-decoration: none; + background: rgba(255, 255, 255, 0.04); + transition: transform 180ms ease, background-color 180ms ease, color 180ms ease; +} + +.footer-links a:hover, +.footer-links a:focus-visible, +.provider-card summary:focus-visible { + outline: none; + background: rgba(96, 165, 250, 0.16); + transform: translateY(-1px); +} + +.footer-links svg { + width: 16px; + height: 16px; +} + +.menubar-loading, +.menubar-error { + display: grid; + place-items: center; + min-height: 160px; + padding: 24px; + text-align: center; +} + +.menubar-error button { + margin-top: 12px; + min-width: 44px; + min-height: 44px; + border: 1px solid var(--mb-border); + border-radius: 999px; + background: rgba(96, 165, 250, 0.14); + color: var(--mb-text); + cursor: pointer; +} + +.minimal-view { + display: grid; + place-items: center; + gap: 10px; + min-height: 192px; + padding: 20px 18px 16px; + text-align: center; +} + +.aggregate-circle { + width: 88px; + height: 88px; + border-radius: 999px; + display: grid; + place-items: center; + border: 2px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 10px rgba(255, 255, 255, 0.02); +} + +.aggregate-circle .aggregate-percent { + margin: 0; + font-size: 26px; +} + +.aggregate-circle.status-healthy { + border-color: rgba(52, 211, 153, 0.48); + box-shadow: inset 0 0 0 10px rgba(52, 211, 153, 0.08); +} + +.aggregate-circle.status-warning { + border-color: rgba(251, 191, 36, 0.48); + box-shadow: inset 0 0 0 10px rgba(251, 191, 36, 0.08); +} + +.aggregate-circle.status-danger, +.aggregate-circle.status-critical { + border-color: rgba(251, 113, 133, 0.48); + box-shadow: inset 0 0 0 10px rgba(251, 113, 133, 0.08); +} + +.minimal-stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + color: var(--mb-muted); + font-size: 12px; +} + +.minimal-stats span { + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); +} + +.menubar-view-minimal .menubar-header { + padding-bottom: 12px; +} + +.menubar-view-minimal .menubar-footer { + padding-top: 12px; +} + +@media (max-width: 480px) { + body { + padding: 8px; + } + + .menubar-summary { + grid-template-columns: 1fr; + } + + .provider-quotas { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} diff --git a/internal/menubar/frontend/menubar.js b/internal/menubar/frontend/menubar.js new file mode 100644 index 0000000..4e82764 --- /dev/null +++ b/internal/menubar/frontend/menubar.js @@ -0,0 +1,296 @@ +(function () { + const bridge = createBridge(); + + function createBridge() { + if (window.go && window.go.menubar && window.go.menubar.App) { + const app = window.go.menubar.App; + return { + mode: 'wails', + getSnapshot: () => app.GetSnapshot(), + getSettings: () => app.GetSettings(), + openExternal: (url) => app.OpenExternal(url), + refresh: () => app.Refresh(), + }; + } + + const browserBridge = window.__ONWATCH_MENUBAR_BRIDGE__ || {}; + return { + mode: browserBridge.mode || 'browser', + requestedView: browserBridge.view || '', + getSettings: async () => { + const settings = Object.assign({}, browserBridge.settings || {}); + if (browserBridge.view) { + settings.default_view = browserBridge.view; + } + return settings; + }, + getSnapshot: async () => { + const view = encodeURIComponent(browserBridge.view || 'standard'); + const resp = await fetch(`/api/menubar/summary?view=${view}`, { credentials: 'same-origin' }); + if (!resp.ok) { + const err = new Error(`menubar summary failed: ${resp.status}`); + err.status = resp.status; + throw err; + } + return resp.json(); + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + refresh: async () => {}, + }; + } + + const icons = { + github: '', + support: '', + globe: '' + }; + + function escapeHTML(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function severityClass(status) { + return status || 'healthy'; + } + + function circumference(radius) { + return 2 * Math.PI * radius; + } + + function meterMarkup(quota) { + const radius = 24; + const length = circumference(radius); + const percent = Math.max(0, Math.min(100, Number(quota.percent || 0))); + const dashOffset = length - (length * percent / 100); + return ` +
+
+ +
${escapeHTML(quota.display_value || `${percent.toFixed(0)}%`)}
+
+
${escapeHTML(quota.label)}
+
+ `; + } + + function trendMarkup(series) { + const points = Array.isArray(series.points) ? series.points : []; + if (!points.length) { + return ''; + } + const max = Math.max(...points, 100); + const min = Math.min(...points, 0); + const range = Math.max(max - min, 1); + const coords = points.map((value, index) => { + const x = points.length === 1 ? 0 : (index / (points.length - 1)) * 100; + const y = 22 - (((value - min) / range) * 22); + return `${x},${y.toFixed(2)}`; + }).join(' '); + return ` +
+
${escapeHTML(series.label)}
+ +
+ `; + } + + function providerCardMarkup(provider, view) { + const quotas = (provider.quotas || []).map(meterMarkup).join(''); + const showTrends = view === 'detailed'; + const trends = showTrends ? (provider.trends || []).map(trendMarkup).join('') : ''; + const percent = Number(provider.highest_percent || 0).toFixed(0); + const meta = [ + provider.subtitle, + provider.updated_at ? `Updated ${provider.updated_at}` : '', + ].filter(Boolean).join(' · '); + return ` +
+ +
+
+ + ${escapeHTML(provider.label)} +
+ ${meta ? `
${escapeHTML(meta)}
` : ''} +
+
${percent}%
+
+
+
${quotas || '
No quota data available yet.
'}
+ ${trends ? `` : ''} +
+
+ `; + } + + function aggregateLabel(snapshot) { + if (snapshot.aggregate && snapshot.aggregate.label) { + return snapshot.aggregate.label; + } + return 'Watching your active providers'; + } + + function summaryMarkup(snapshot, providers) { + const status = severityClass(snapshot.aggregate && snapshot.aggregate.status); + const aggregate = snapshot.aggregate || {}; + return ` + + `; + } + + function minimalMarkup(snapshot, providers) { + const aggregate = snapshot.aggregate || {}; + const status = severityClass(aggregate.status); + return ` +
+
+ ${Number(aggregate.highest_percent || 0).toFixed(0)}% +
+
${escapeHTML(snapshot.updated_ago || 'Waiting for quota data')}
+
${escapeHTML(aggregate.label || 'All Good')}
+
+ ${escapeHTML(String(aggregate.provider_count || providers.length || 0))} providers + ${escapeHTML(String(aggregate.warning_count || 0))} warnings + ${escapeHTML(String(aggregate.critical_count || 0))} critical +
+
+ `; + } + + function providerListMarkup(providers, view) { + if (!providers.length) { + return '
No provider quota data is available yet.
'; + } + return ` +
+ ${providers.map((provider) => providerCardMarkup(provider, view)).join('')} +
+ `; + } + + function footerMarkup() { + return ` + + `; + } + + function render(snapshot, settings) { + const root = document.getElementById('menubar-root'); + const status = severityClass(snapshot.aggregate && snapshot.aggregate.status); + const providers = Array.isArray(snapshot.providers) ? snapshot.providers : []; + const view = settings.default_view || bridge.requestedView || 'standard'; + let bodyMarkup = ''; + if (view === 'minimal') { + bodyMarkup = minimalMarkup(snapshot, providers); + } else if (view === 'detailed') { + bodyMarkup = summaryMarkup(snapshot, providers) + providerListMarkup(providers, view); + } else { + bodyMarkup = summaryMarkup(snapshot, providers) + providerListMarkup(providers, view); + } + + root.innerHTML = ` + + `; + + root.querySelectorAll('[data-external="true"]').forEach((el) => { + el.addEventListener('click', (event) => { + event.preventDefault(); + bridge.openExternal(el.dataset.url); + }); + }); + } + + function renderError(error) { + const root = document.getElementById('menubar-root'); + root.innerHTML = ` + + `; + const retry = document.getElementById('menubar-retry'); + if (retry) { + retry.addEventListener('click', () => init()); + } + } + + let refreshTimer = null; + + async function init() { + const settings = await bridge.getSettings(); + try { + const snapshot = await bridge.getSnapshot(); + render(snapshot, settings || {}); + const intervalSeconds = Number(settings && settings.refresh_seconds ? settings.refresh_seconds : 60); + if (refreshTimer) { + clearInterval(refreshTimer); + } + refreshTimer = setInterval(async () => { + try { + const nextSnapshot = await bridge.getSnapshot(); + render(nextSnapshot, settings || {}); + } catch (error) { + renderError(error); + } + }, Math.max(intervalSeconds, 10) * 1000); + } catch (error) { + renderError(error); + } + } + + document.addEventListener('DOMContentLoaded', init); +}()); diff --git a/internal/menubar/icon.go b/internal/menubar/icon.go new file mode 100644 index 0000000..ac6ec8d --- /dev/null +++ b/internal/menubar/icon.go @@ -0,0 +1,41 @@ +package menubar + +import ( + "bytes" + "image" + "image/color" + "image/png" +) + +func trayIcons() ([]byte, []byte) { + return renderTrayIcon(true), renderTrayIcon(false) +} + +func renderTrayIcon(template bool) []byte { + img := image.NewNRGBA(image.Rect(0, 0, 18, 18)) + active := color.NRGBA{R: 96, G: 165, B: 250, A: 255} + warn := color.NRGBA{R: 52, G: 211, B: 153, A: 255} + templateColor := color.NRGBA{R: 0, G: 0, B: 0, A: 255} + if template { + active = templateColor + warn = templateColor + } + + fillRect(img, 3, 3, 15, 5, active) + fillRect(img, 3, 8, 12, 10, warn) + fillRect(img, 3, 13, 9, 15, active) + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil + } + return buf.Bytes() +} + +func fillRect(img *image.NRGBA, x0, y0, x1, y1 int, c color.NRGBA) { + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + img.SetNRGBA(x, y, c) + } + } +} diff --git a/internal/menubar/menubar_darwin.go b/internal/menubar/menubar_darwin.go new file mode 100644 index 0000000..5cf2d70 --- /dev/null +++ b/internal/menubar/menubar_darwin.go @@ -0,0 +1,33 @@ +//go:build menubar && darwin + +package menubar + +import "sync/atomic" + +var running atomic.Bool + +// Init starts the real menubar companion. The implementation lives in +// companion_darwin.go to keep Wails-heavy code isolated. +func Init(cfg *Config) error { + if cfg == nil { + cfg = DefaultConfig() + } + if err := runCompanion(cfg); err != nil { + running.Store(false) + return err + } + running.Store(true) + return nil +} + +// Stop requests the menubar companion to exit. +func Stop() error { + running.Store(false) + return stopCompanion() +} + +// IsSupported reports whether this build can run the real menubar companion. +func IsSupported() bool { return true } + +// IsRunning reports whether the companion is marked as active. +func IsRunning() bool { return running.Load() || companionProcessRunning() } diff --git a/internal/menubar/menubar_stub.go b/internal/menubar/menubar_stub.go new file mode 100644 index 0000000..98edae3 --- /dev/null +++ b/internal/menubar/menubar_stub.go @@ -0,0 +1,15 @@ +//go:build !menubar || !darwin + +package menubar + +// Init is a no-op when the menubar companion is not compiled in. +func Init(cfg *Config) error { return nil } + +// Stop is a no-op when the menubar companion is not compiled in. +func Stop() error { return nil } + +// IsSupported reports whether the current build supports the menubar companion. +func IsSupported() bool { return false } + +// IsRunning reports whether the menubar companion is currently running. +func IsRunning() bool { return false } diff --git a/internal/menubar/menubar_test.go b/internal/menubar/menubar_test.go new file mode 100644 index 0000000..589b049 --- /dev/null +++ b/internal/menubar/menubar_test.go @@ -0,0 +1,55 @@ +package menubar + +import ( + "strings" + "testing" +) + +func TestDefaultConfigUsesRepoDefaults(t *testing.T) { + cfg := DefaultConfig() + if cfg.Port != 9211 { + t.Fatalf("expected port 9211, got %d", cfg.Port) + } + if cfg.DefaultView != ViewStandard { + t.Fatalf("expected standard view, got %s", cfg.DefaultView) + } + if cfg.RefreshSeconds != 60 { + t.Fatalf("expected refresh 60, got %d", cfg.RefreshSeconds) + } +} + +func TestSettingsNormalizeRepairsInvalidValues(t *testing.T) { + settings := (&Settings{ + DefaultView: "", + RefreshSeconds: 5, + WarningPercent: 99, + CriticalPercent: 60, + }).Normalize() + + if settings.DefaultView != ViewStandard { + t.Fatalf("expected standard view, got %s", settings.DefaultView) + } + if settings.RefreshSeconds != 60 { + t.Fatalf("expected refresh 60, got %d", settings.RefreshSeconds) + } + if settings.WarningPercent != 70 || settings.CriticalPercent != 90 { + t.Fatalf("expected fallback thresholds 70/90, got %d/%d", settings.WarningPercent, settings.CriticalPercent) + } + if settings.ProvidersOrder == nil { + t.Fatal("expected providers order to be initialized") + } +} + +func TestInlineHTMLUsesRequestedView(t *testing.T) { + html, err := InlineHTML(ViewMinimal, DefaultSettings()) + if err != nil { + t.Fatalf("InlineHTML returned error: %v", err) + } + if !strings.Contains(html, `"default_view":"minimal"`) { + t.Fatalf("expected minimal default view in inline html, got: %s", html) + } +} + +func TestIsSupportedSmoke(t *testing.T) { + t.Logf("menubar supported: %v", IsSupported()) +} diff --git a/internal/menubar/runtime_state.go b/internal/menubar/runtime_state.go new file mode 100644 index 0000000..6f05f41 --- /dev/null +++ b/internal/menubar/runtime_state.go @@ -0,0 +1,57 @@ +package menubar + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" +) + +func companionProcessRunning() bool { + for _, path := range []string{companionPIDPath(false), companionPIDPath(true)} { + pid := readPID(path) + if pid <= 0 { + continue + } + proc, err := os.FindProcess(pid) + if err == nil && proc.Signal(syscall.Signal(0)) == nil { + return true + } + _ = os.Remove(path) + } + return false +} + +func companionPIDPath(testMode bool) string { + name := "onwatch-menubar.pid" + if testMode { + name = "onwatch-menubar-test.pid" + } + return filepath.Join(defaultCompanionPIDDir(), name) +} + +func defaultCompanionPIDDir() string { + if runtime.GOOS == "windows" { + if dir := os.Getenv("LOCALAPPDATA"); dir != "" { + return filepath.Join(dir, "onwatch") + } + return filepath.Join(os.Getenv("USERPROFILE"), ".onwatch") + } + return filepath.Join(os.Getenv("HOME"), ".onwatch") +} + +func readPID(path string) int { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + return pid +} + +func companionPIDEnvValue(testMode bool) string { + return fmt.Sprintf("%t:%s", testMode, companionPIDPath(testMode)) +} diff --git a/internal/menubar/views/.gitkeep b/internal/menubar/views/.gitkeep new file mode 100644 index 0000000..e3c9cdd --- /dev/null +++ b/internal/menubar/views/.gitkeep @@ -0,0 +1 @@ +# Keeps the planned menubar view package directory tracked. diff --git a/internal/store/menubar_test.go b/internal/store/menubar_test.go new file mode 100644 index 0000000..75d2f12 --- /dev/null +++ b/internal/store/menubar_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/onllm-dev/onwatch/v2/internal/menubar" +) + +func TestStoreMenubarSettingsRoundTrip(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "onwatch.db") + + s, err := New(dbPath) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + settings := &menubar.Settings{ + Enabled: false, + DefaultView: menubar.ViewDetailed, + RefreshSeconds: 120, + ProvidersOrder: []string{"codex:1", "synthetic"}, + WarningPercent: 60, + CriticalPercent: 85, + } + if err := s.SetMenubarSettings(settings); err != nil { + t.Fatalf("SetMenubarSettings returned error: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + reopened, err := New(dbPath) + if err != nil { + t.Fatalf("reopen returned error: %v", err) + } + defer reopened.Close() + + got, err := reopened.GetMenubarSettings() + if err != nil { + t.Fatalf("GetMenubarSettings returned error: %v", err) + } + if got.Enabled { + t.Fatal("expected menubar to stay disabled after round-trip") + } + if got.DefaultView != menubar.ViewDetailed { + t.Fatalf("expected detailed view, got %s", got.DefaultView) + } + if got.RefreshSeconds != 120 { + t.Fatalf("expected refresh 120, got %d", got.RefreshSeconds) + } + if len(got.ProvidersOrder) != 2 || got.ProvidersOrder[0] != "codex:1" { + t.Fatalf("unexpected provider order: %#v", got.ProvidersOrder) + } + if got.WarningPercent != 60 || got.CriticalPercent != 85 { + t.Fatalf("unexpected thresholds: %d/%d", got.WarningPercent, got.CriticalPercent) + } +} + +func TestStoreMenubarSettingsDefaults(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("New returned error: %v", err) + } + defer s.Close() + + got, err := s.GetMenubarSettings() + if err != nil { + t.Fatalf("GetMenubarSettings returned error: %v", err) + } + if got.DefaultView != menubar.ViewStandard { + t.Fatalf("expected standard view, got %s", got.DefaultView) + } + if got.RefreshSeconds != 60 { + t.Fatalf("expected refresh 60, got %d", got.RefreshSeconds) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 40f7c8f..6e3d670 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3,12 +3,14 @@ package store import ( "database/sql" "encoding/base64" + "encoding/json" "errors" "fmt" "strings" "time" "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/menubar" _ "modernc.org/sqlite" ) @@ -1230,6 +1232,38 @@ func (s *Store) SetSetting(key, value string) error { return nil } +// GetMenubarSettings returns persisted menubar settings, falling back to defaults. +func (s *Store) GetMenubarSettings() (*menubar.Settings, error) { + defaults := menubar.DefaultSettings() + if s == nil { + return defaults, nil + } + value, err := s.GetSetting("menubar") + if err != nil { + return nil, err + } + if value == "" { + return defaults, nil + } + var settings menubar.Settings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return nil, fmt.Errorf("store.GetMenubarSettings: %w", err) + } + return settings.Normalize(), nil +} + +// SetMenubarSettings persists normalized menubar settings as a single JSON blob. +func (s *Store) SetMenubarSettings(settings *menubar.Settings) error { + if s == nil { + return fmt.Errorf("store.SetMenubarSettings: store is nil") + } + payload, err := json.Marshal(settings.Normalize()) + if err != nil { + return fmt.Errorf("store.SetMenubarSettings: %w", err) + } + return s.SetSetting("menubar", string(payload)) +} + // SaveAuthToken persists a session token with its expiry. func (s *Store) SaveAuthToken(token string, expiresAt time.Time) error { _, err := s.db.Exec( diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 17ad79b..17261fd 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -16,6 +16,7 @@ import ( "github.com/onllm-dev/onwatch/v2/internal/api" "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/menubar" "github.com/onllm-dev/onwatch/v2/internal/notify" "github.com/onllm-dev/onwatch/v2/internal/store" "github.com/onllm-dev/onwatch/v2/internal/tracker" @@ -4135,6 +4136,7 @@ func compactNum(v float64) string { func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { tz := "" var hiddenInsights []string + menubarSettings := menubar.DefaultSettings() if h.store != nil { val, err := h.store.GetSetting("timezone") if err != nil { @@ -4148,6 +4150,11 @@ func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { } else if hiVal != "" { _ = json.Unmarshal([]byte(hiVal), &hiddenInsights) } + if settings, err := h.store.GetMenubarSettings(); err != nil { + h.logger.Error("failed to get menubar settings", "error", err) + } else if settings != nil { + menubarSettings = settings + } } if hiddenInsights == nil { hiddenInsights = []string{} @@ -4156,6 +4163,7 @@ func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { result := map[string]interface{}{ "timezone": tz, "hidden_insights": hiddenInsights, + "menubar": menubarSettings, } // SMTP settings (never return the actual password) @@ -4444,6 +4452,23 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { result["provider_visibility"] = vis } + // Handle menubar settings + if raw, ok := body["menubar"]; ok { + var settings menubar.Settings + if err := json.Unmarshal(raw, &settings); err != nil { + respondError(w, http.StatusBadRequest, "invalid menubar value") + return + } + normalized := settings.Normalize() + normalized.DefaultView = normalizeMenubarView(string(normalized.DefaultView), menubar.ViewStandard) + if err := h.store.SetMenubarSettings(normalized); err != nil { + h.logger.Error("failed to save menubar settings", "error", err) + respondError(w, http.StatusInternalServerError, "failed to save menubar settings") + return + } + result["menubar"] = normalized + } + respondJSON(w, http.StatusOK, result) } diff --git a/internal/web/menubar.go b/internal/web/menubar.go new file mode 100644 index 0000000..9fcdf6b --- /dev/null +++ b/internal/web/menubar.go @@ -0,0 +1,509 @@ +package web + +import ( + "fmt" + "net/http" + "os" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/menubar" +) + +// Capabilities returns runtime capabilities for the current build. +func (h *Handler) Capabilities(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "version": h.version, + "platform": runtime.GOOS, + "variant": menubarVariant(), + "menubar_supported": menubar.IsSupported(), + "menubar_running": menubar.IsRunning(), + }) +} + +// MenubarSummary returns the normalized data contract used by the menubar UI. +func (h *Handler) MenubarSummary(w http.ResponseWriter, r *http.Request) { + if !menubar.IsSupported() && os.Getenv("ONWATCH_TEST_MODE") != "1" { + http.NotFound(w, r) + return + } + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + h.logger.Error("failed to build menubar snapshot", "error", err) + respondError(w, http.StatusInternalServerError, "failed to build menubar snapshot") + return + } + respondJSON(w, http.StatusOK, snapshot) +} + +// MenubarTest renders the same menubar UI in a browser page for automated testing. +func (h *Handler) MenubarTest(w http.ResponseWriter, r *http.Request) { + if os.Getenv("ONWATCH_TEST_MODE") != "1" { + http.NotFound(w, r) + return + } + settings, _ := h.menubarSettings() + view := normalizeMenubarView(r.URL.Query().Get("view"), settings.DefaultView) + html, err := menubar.InlineHTML(view, settings) + if err != nil { + h.logger.Error("failed to render menubar test page", "error", err) + respondError(w, http.StatusInternalServerError, "failed to render menubar test page") + return + } + w.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data:; "+ + "connect-src 'self'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) +} + +// BuildMenubarSnapshot constructs the shared menubar UI contract. +func (h *Handler) BuildMenubarSnapshot() (*menubar.Snapshot, error) { + settings, err := h.menubarSettings() + if err != nil { + return nil, err + } + + visibility := h.providerVisibilityMap() + providers := make([]menubar.ProviderCard, 0, 8) + latest := time.Time{} + + if h.config != nil && h.config.HasProvider("synthetic") && h.providerDashboardVisible("synthetic", visibility) { + payload := h.buildSyntheticCurrent() + if card := normalizeProviderCard("synthetic", "Synthetic", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + if h.config != nil && h.config.HasProvider("zai") && h.providerDashboardVisible("zai", visibility) { + payload := h.buildZaiCurrent() + if card := normalizeProviderCard("zai", "Z.ai", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + if h.config != nil && h.config.HasProvider("anthropic") && h.providerDashboardVisible("anthropic", visibility) { + payload := h.buildAnthropicCurrent() + if card := normalizeProviderCard("anthropic", "Anthropic", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + if h.config != nil && h.config.HasProvider("copilot") && h.providerDashboardVisible("copilot", visibility) { + payload := h.buildCopilotCurrent() + if card := normalizeProviderCard("copilot", "Copilot", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + if h.config != nil && h.config.HasProvider("codex") && h.providerDashboardVisible("codex", visibility) { + for _, usage := range h.codexUsageAccounts() { + accountID := codexUsageAccountID(usage) + providerKey := fmt.Sprintf("codex:%d", accountID) + if !providerDashboardVisibleForKey(visibility, providerKey, "codex") { + continue + } + name := stringValue(usage, "accountName") + if name == "" { + name = "default" + } + subtitle := "ChatGPT account" + if card := normalizeProviderCard(providerKey, "Codex - "+name, subtitle, usage, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(usage); captured.After(latest) { + latest = captured + } + } + } + } + if h.config != nil && h.config.HasProvider("antigravity") && h.providerDashboardVisible("antigravity", visibility) { + payload := h.buildAntigravityCurrent() + if card := normalizeProviderCard("antigravity", "Antigravity", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + if h.config != nil && h.config.HasProvider("minimax") && h.providerDashboardVisible("minimax", visibility) { + payload := h.buildMiniMaxCurrent() + if card := normalizeProviderCard("minimax", "MiniMax", "", payload, settings.WarningPercent, settings.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + + sortProviderCards(providers, settings.ProvidersOrder) + aggregate := buildAggregate(providers) + return &menubar.Snapshot{ + GeneratedAt: time.Now().UTC(), + UpdatedAgo: timeAgo(latest), + Aggregate: aggregate, + Providers: providers, + }, nil +} + +func (h *Handler) menubarSettings() (*menubar.Settings, error) { + if h.store == nil { + return menubar.DefaultSettings(), nil + } + settings, err := h.store.GetMenubarSettings() + if err != nil { + return nil, err + } + return settings.Normalize(), nil +} + +func normalizeProviderCard(id, label, subtitle string, payload map[string]interface{}, warningPercent, criticalPercent int) *menubar.ProviderCard { + quotas := normalizeQuotas(payload, warningPercent, criticalPercent) + if len(quotas) == 0 { + return nil + } + status := "healthy" + highest := 0.0 + trends := make([]menubar.TrendSeries, 0, len(quotas)) + for _, quota := range quotas { + if quota.Percent > highest { + highest = quota.Percent + } + status = worsenStatus(status, quota.Status) + points := quota.SparklinePoints + if len(points) == 0 { + points = []float64{quota.Percent, quota.Percent, quota.Percent, quota.Percent} + } + trends = append(trends, menubar.TrendSeries{ + Key: quota.Key, + Label: quota.Label, + Status: quota.Status, + Points: points, + }) + } + return &menubar.ProviderCard{ + ID: id, + BaseProvider: providerKeyBase(id), + Label: label, + Subtitle: subtitle, + Status: status, + HighestPercent: highest, + UpdatedAt: timeAgo(parseCapturedAt(payload)), + Quotas: quotas, + Trends: trends, + } +} + +func normalizeQuotas(payload map[string]interface{}, warningPercent, criticalPercent int) []menubar.QuotaMeter { + var rawQuotas []interface{} + switch typed := payload["quotas"].(type) { + case []interface{}: + rawQuotas = typed + case []map[string]interface{}: + rawQuotas = make([]interface{}, 0, len(typed)) + for _, item := range typed { + rawQuotas = append(rawQuotas, item) + } + } + + if len(rawQuotas) == 0 { + for _, key := range []string{"subscription", "search", "toolCalls", "tokensLimit", "timeLimit", "sharedQuota"} { + if quotaMap, ok := payload[key].(map[string]interface{}); ok { + rawQuotas = append(rawQuotas, quotaMap) + } + } + } + + quotas := make([]menubar.QuotaMeter, 0, len(rawQuotas)) + for _, raw := range rawQuotas { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + label := stringValue(item, "displayName") + if label == "" { + label = stringValue(item, "label") + } + if label == "" { + label = stringValue(item, "name") + } + if label == "" { + label = stringValue(item, "quotaName") + } + if label == "" { + continue + } + percent := firstFloat(item, "cardPercent", "usagePercent", "percent", "utilization", "remainingPercent") + quotas = append(quotas, menubar.QuotaMeter{ + Key: strings.ToLower(strings.ReplaceAll(label, " ", "_")), + Label: label, + DisplayValue: displayValue(item, percent), + Percent: percent, + Status: quotaStatus(item, percent, warningPercent, criticalPercent), + Used: firstFloat(item, "usage", "used", "currentUsage", "currentUsed"), + Limit: firstFloat(item, "limit", "total", "currentLimit", "entitlement"), + ResetAt: firstString(item, "renewsAt", "resetsAt", "resetDate", "resetTime", "resetAt"), + TimeUntilReset: stringValue(item, "timeUntilReset"), + ProjectedValue: firstFloat(item, "projectedUsage", "projectedUtil", "projectedValue"), + CurrentRate: firstFloat(item, "currentRate"), + }) + } + sort.SliceStable(quotas, func(i, j int) bool { + if quotas[i].Percent != quotas[j].Percent { + return quotas[i].Percent > quotas[j].Percent + } + return quotas[i].Label < quotas[j].Label + }) + return quotas +} + +func quotaStatus(item map[string]interface{}, percent float64, warningPercent, criticalPercent int) string { + rawStatus := stringValue(item, "status") + if _, ok := item["remainingPercent"]; ok || strings.EqualFold(stringValue(item, "cardLabel"), "Remaining") { + if rawStatus != "" { + return rawStatus + } + return statusFromRemaining(percent, warningPercent, criticalPercent) + } + return statusFromPercent(percent, warningPercent, criticalPercent) +} + +func buildAggregate(providers []menubar.ProviderCard) menubar.Aggregate { + aggregate := menubar.Aggregate{ + ProviderCount: len(providers), + Status: "healthy", + Label: "All Good", + } + for _, provider := range providers { + if provider.HighestPercent > aggregate.HighestPercent { + aggregate.HighestPercent = provider.HighestPercent + } + switch provider.Status { + case "critical": + aggregate.CriticalCount++ + case "danger", "warning": + aggregate.WarningCount++ + } + aggregate.Status = worsenStatus(aggregate.Status, provider.Status) + } + + switch { + case aggregate.CriticalCount > 0: + aggregate.Label = fmt.Sprintf("%d Critical", aggregate.CriticalCount) + case aggregate.WarningCount > 0: + aggregate.Label = fmt.Sprintf("%d Warning", aggregate.WarningCount) + default: + aggregate.Label = "All Good" + } + return aggregate +} + +func sortProviderCards(cards []menubar.ProviderCard, preferred []string) { + if len(cards) == 0 { + return + } + order := make(map[string]int, len(preferred)) + for idx, key := range preferred { + order[key] = idx + } + sort.SliceStable(cards, func(i, j int) bool { + leftOrder, leftOK := order[cards[i].ID] + rightOrder, rightOK := order[cards[j].ID] + switch { + case leftOK && rightOK: + return leftOrder < rightOrder + case leftOK: + return true + case rightOK: + return false + case cards[i].BaseProvider == cards[j].BaseProvider: + return cards[i].Label < cards[j].Label + default: + return cards[i].Label < cards[j].Label + } + }) +} + +func parseCapturedAt(payload map[string]interface{}) time.Time { + value := stringValue(payload, "capturedAt") + if value == "" { + return time.Time{} + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func menubarVariant() string { + if runtime.GOOS == "darwin" { + if menubar.IsSupported() { + return "full" + } + return "lite" + } + return "lite" +} + +func normalizeMenubarView(raw string, fallback menubar.ViewType) menubar.ViewType { + switch menubar.ViewType(strings.ToLower(strings.TrimSpace(raw))) { + case menubar.ViewMinimal: + return menubar.ViewMinimal + case menubar.ViewDetailed: + return menubar.ViewDetailed + case menubar.ViewStandard: + return menubar.ViewStandard + } + if fallback != "" { + return fallback + } + return menubar.ViewStandard +} + +func providerDashboardVisibleForKey(vis map[string]map[string]bool, key, fallback string) bool { + if pv, ok := vis[key]; ok { + if dashboard, exists := pv["dashboard"]; exists { + return dashboard + } + } + if fallback == "" { + return true + } + if pv, ok := vis[fallback]; ok { + if dashboard, exists := pv["dashboard"]; exists { + return dashboard + } + } + return true +} + +func worsenStatus(current, next string) string { + rank := map[string]int{ + "healthy": 0, + "warning": 1, + "danger": 2, + "critical": 3, + } + if rank[next] > rank[current] { + return next + } + return current +} + +func statusFromPercent(percent float64, warningPercent, criticalPercent int) string { + warning := float64(warningPercent) + if warning <= 0 { + warning = 70 + } + critical := float64(criticalPercent) + if critical <= warning { + critical = 90 + } + switch { + case percent >= critical: + return "critical" + case percent >= warning: + return "warning" + default: + return "healthy" + } +} + +func statusFromRemaining(percent float64, warningPercent, criticalPercent int) string { + warning := 100 - float64(warningPercent) + critical := 100 - float64(criticalPercent) + switch { + case percent <= critical: + return "critical" + case percent <= warning: + return "warning" + default: + return "healthy" + } +} + +func timeAgo(at time.Time) string { + if at.IsZero() { + return "" + } + delta := time.Since(at) + if delta < time.Minute { + return "just now" + } + if delta < time.Hour { + return fmt.Sprintf("%dm ago", int(delta.Minutes())) + } + if delta < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(delta.Hours())) + } + return fmt.Sprintf("%dd ago", int(delta.Hours()/24)) +} + +func displayValue(item map[string]interface{}, percent float64) string { + if v := stringValue(item, "cardLabel"); v == "Remaining" { + return fmt.Sprintf("%.0f%%", percent) + } + return fmt.Sprintf("%.0f%%", percent) +} + +func firstString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value := stringValue(item, key); value != "" { + return value + } + } + return "" +} + +func stringValue(item map[string]interface{}, key string) string { + switch value := item[key].(type) { + case string: + return value + case fmt.Stringer: + return value.String() + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + default: + return "" + } +} + +func firstFloat(item map[string]interface{}, keys ...string) float64 { + for _, key := range keys { + switch value := item[key].(type) { + case float64: + return value + case float32: + return float64(value) + case int: + return float64(value) + case int64: + return float64(value) + case uint64: + return float64(value) + case string: + if parsed, err := strconv.ParseFloat(value, 64); err == nil { + return parsed + } + } + } + return 0 +} diff --git a/internal/web/menubar_test.go b/internal/web/menubar_test.go new file mode 100644 index 0000000..bad7c96 --- /dev/null +++ b/internal/web/menubar_test.go @@ -0,0 +1,197 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/menubar" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func newMenubarTestHandler(t *testing.T) (*Handler, *store.Store) { + t.Helper() + + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New returned error: %v", err) + } + + snapshot := &api.Snapshot{ + CapturedAt: time.Now().UTC(), + Sub: api.QuotaInfo{Limit: 100, Requests: 30, RenewsAt: time.Now().Add(2 * time.Hour)}, + Search: api.QuotaInfo{Limit: 50, Requests: 10, RenewsAt: time.Now().Add(90 * time.Minute)}, + ToolCall: api.QuotaInfo{Limit: 200, Requests: 20, RenewsAt: time.Now().Add(3 * time.Hour)}, + } + if _, err := s.InsertSnapshot(snapshot); err != nil { + t.Fatalf("InsertSnapshot returned error: %v", err) + } + + tr := tracker.New(s, nil) + h := NewHandler(s, tr, nil, nil, createTestConfigWithSynthetic()) + h.SetVersion("test-version") + return h, s +} + +func TestCapabilitiesIncludesMenubarFields(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/capabilities", nil) + rr := httptest.NewRecorder() + + h.Capabilities(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if response["version"] != "test-version" { + t.Fatalf("expected test version, got %#v", response["version"]) + } + if _, ok := response["menubar_supported"]; !ok { + t.Fatal("expected menubar_supported in response") + } + if _, ok := response["menubar_running"]; !ok { + t.Fatal("expected menubar_running in response") + } + if _, ok := response["variant"]; !ok { + t.Fatal("expected variant in response") + } +} + +func TestGetSettingsIncludesMenubarDefaults(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/settings", nil) + rr := httptest.NewRecorder() + + h.GetSettings(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var response struct { + Menubar menubar.Settings `json:"menubar"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if response.Menubar.DefaultView != menubar.ViewStandard { + t.Fatalf("expected standard view, got %s", response.Menubar.DefaultView) + } +} + +func TestUpdateSettingsPersistsMenubarSection(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + body := strings.NewReader(`{"menubar":{"enabled":false,"default_view":"detailed","refresh_seconds":120,"providers_order":["synthetic"],"warning_percent":55,"critical_percent":80}}`) + req := httptest.NewRequest(http.MethodPut, "/api/settings", body) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.UpdateSettings(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + got, err := s.GetMenubarSettings() + if err != nil { + t.Fatalf("GetMenubarSettings returned error: %v", err) + } + if got.Enabled { + t.Fatal("expected menubar to be disabled after update") + } + if got.DefaultView != menubar.ViewDetailed { + t.Fatalf("expected detailed view, got %s", got.DefaultView) + } + if got.WarningPercent != 55 || got.CriticalPercent != 80 { + t.Fatalf("unexpected thresholds: %d/%d", got.WarningPercent, got.CriticalPercent) + } +} + +func TestMenubarTestEndpointRequiresTestMode(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/test?view=minimal", nil) + rr := httptest.NewRecorder() + + h.MenubarTest(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestMenubarTestEndpointRendersRequestedView(t *testing.T) { + t.Setenv("ONWATCH_TEST_MODE", "1") + + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/test?view=minimal", nil) + rr := httptest.NewRecorder() + + h.MenubarTest(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), `"default_view":"minimal"`) { + t.Fatalf("expected minimal view bootstrap, got body: %s", rr.Body.String()) + } +} + +func TestMenubarSummaryUsesConfiguredThresholds(t *testing.T) { + t.Setenv("ONWATCH_TEST_MODE", "1") + + h, s := newMenubarTestHandler(t) + defer s.Close() + + if err := s.SetMenubarSettings(&menubar.Settings{ + Enabled: true, + DefaultView: menubar.ViewStandard, + RefreshSeconds: 60, + WarningPercent: 10, + CriticalPercent: 20, + }); err != nil { + t.Fatalf("SetMenubarSettings returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil) + rr := httptest.NewRecorder() + + h.MenubarSummary(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + var snapshot menubar.Snapshot + if err := json.Unmarshal(rr.Body.Bytes(), &snapshot); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if snapshot.Aggregate.ProviderCount == 0 { + t.Fatal("expected at least one provider in menubar snapshot") + } + if snapshot.Aggregate.Status != "critical" { + t.Fatalf("expected critical aggregate status, got %s", snapshot.Aggregate.Status) + } + if len(snapshot.Providers) == 0 { + t.Fatal("expected provider cards in snapshot") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 3ad0c54..ef95661 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -54,6 +54,9 @@ func NewServer(port int, handler *Handler, logger *slog.Logger, username, passwo mux.HandleFunc("/api/history", handler.History) mux.HandleFunc("/api/cycles", handler.Cycles) mux.HandleFunc("/api/summary", handler.Summary) + mux.HandleFunc("/api/capabilities", handler.Capabilities) + mux.HandleFunc("/api/menubar/summary", handler.MenubarSummary) + mux.HandleFunc("/api/menubar/test", handler.MenubarTest) mux.HandleFunc("/api/sessions", handler.Sessions) mux.HandleFunc("/api/insights", handler.Insights) mux.HandleFunc("/api/settings", func(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index 1c97e87..c04c455 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/onllm-dev/onwatch/v2/internal/api" "github.com/onllm-dev/onwatch/v2/internal/config" "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/menubar" "github.com/onllm-dev/onwatch/v2/internal/store" "github.com/onllm-dev/onwatch/v2/internal/tracker" "github.com/onllm-dev/onwatch/v2/internal/update" @@ -403,6 +404,9 @@ func run() error { if hasCommand("codex") { return runCodexCommand() } + if hasCommand("menubar") { + return runMenubarCommand() + } if hasCommand("stop", "--stop") { return runStop(testMode) } @@ -472,6 +476,7 @@ func run() error { // Stop any previous instance (parent does this, daemon child skips it) if !isDaemonChild { stopPreviousInstance(cfg.Port, testMode) + _ = stopMenubarProcess(testMode) } // Daemonize: if not in debug mode, not already the daemon child, and NOT in Docker, fork @@ -976,6 +981,16 @@ func run() error { } }() + if runtime.GOOS == "darwin" && menubar.IsSupported() { + go func() { + if waitForServerReady(cfg.Port, 10*time.Second) { + if err := startMenubarCompanion(cfg, logger); err != nil { + logger.Warn("failed to start menubar companion", "error", err) + } + } + }() + } + // Periodically return freed memory to the OS. On macOS, MADV_FREE pages // are reclaimable but still counted in RSS. FreeOSMemory forces MADV_DONTNEED. // Also evict stale rate limiter entries and expired session tokens to prevent memory growth. @@ -1012,6 +1027,7 @@ func run() error { // Cancel context to stop agent cancel() agentMgr.StopAll() + _ = stopMenubarProcess(cfg.TestMode) // Give agent a moment to clean up time.Sleep(100 * time.Millisecond) @@ -1127,6 +1143,10 @@ func runStop(testMode bool) error { if !stopped { fmt.Printf("No running %s instance found\n", label) } + if menubarPID := readRuntimePID(menubarPIDPath(testMode)); menubarPID > 0 { + _ = stopMenubarProcess(testMode) + fmt.Printf("Stopped %s menubar companion (PID %d)\n", label, menubarPID) + } return nil } @@ -1180,6 +1200,9 @@ func runStatus(testMode bool) error { // Show PID file location fmt.Printf(" PID file: %s\n", pidFile) + if menubarPID := readRuntimePID(menubarPIDPath(testMode)); processRunning(menubarPID) { + fmt.Printf(" Menubar: running (PID %d)\n", menubarPID) + } // Show log file if it exists logPath := ".onwatch.log" diff --git a/menubar_runtime.go b/menubar_runtime.go new file mode 100644 index 0000000..503df5c --- /dev/null +++ b/menubar_runtime.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "log/slog" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/menubar" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" + "github.com/onllm-dev/onwatch/v2/internal/web" +) + +func menubarPIDPath(testMode bool) string { + name := "onwatch-menubar.pid" + if testMode { + name = "onwatch-menubar-test.pid" + } + return filepath.Join(pidDir, name) +} + +func readRuntimePID(path string) int { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + var pid int + content := strings.TrimSpace(string(data)) + fmt.Sscanf(content, "%d", &pid) + return pid +} + +func writeRuntimePID(path string) error { + if err := ensurePIDDir(); err != nil { + return err + } + return os.WriteFile(path, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) +} + +func processRunning(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} + +func stopMenubarProcess(testMode bool) error { + path := menubarPIDPath(testMode) + pid := readRuntimePID(path) + if pid <= 0 { + return nil + } + proc, err := os.FindProcess(pid) + if err == nil { + _ = proc.Signal(syscall.SIGTERM) + } + _ = os.Remove(path) + return nil +} + +func waitForServerReady(port int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond) + if err == nil { + _ = conn.Close() + return true + } + time.Sleep(250 * time.Millisecond) + } + return false +} + +func startMenubarCompanion(cfg *config.Config, logger *slog.Logger) error { + if cfg == nil || cfg.TestMode || !menubar.IsSupported() || runtime.GOOS != "darwin" { + return nil + } + settings, err := store.New(cfg.DBPath) + if err == nil { + defer settings.Close() + if menubarSettings, settingsErr := settings.GetMenubarSettings(); settingsErr == nil && menubarSettings != nil && !menubarSettings.Enabled { + return nil + } + } + path := menubarPIDPath(cfg.TestMode) + if pid := readRuntimePID(path); processRunning(pid) { + return nil + } + + exe, err := os.Executable() + if err != nil { + return err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return err + } + + args := []string{"menubar", fmt.Sprintf("--port=%d", cfg.Port), fmt.Sprintf("--db=%s", cfg.DBPath)} + if cfg.TestMode { + args = append(args, "--test") + } + cmd := exec.Command(exe, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return err + } + logger.Info("started menubar companion", "pid", cmd.Process.Pid) + return nil +} + +func runMenubarCommand() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config for menubar companion: %w", err) + } + if !menubar.IsSupported() { + return fmt.Errorf("menubar companion is not available in this build") + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + db, err := store.New(cfg.DBPath) + if err != nil { + return fmt.Errorf("failed to open database for menubar companion: %w", err) + } + defer db.Close() + + tr := tracker.New(db, logger) + zaiTr := tracker.NewZaiTracker(db, logger) + h := web.NewHandler(db, tr, logger, nil, cfg, zaiTr) + h.SetVersion(version) + h.SetAnthropicTracker(tracker.NewAnthropicTracker(db, logger)) + h.SetCopilotTracker(tracker.NewCopilotTracker(db, logger)) + h.SetCodexTracker(tracker.NewCodexTracker(db, logger)) + h.SetAntigravityTracker(tracker.NewAntigravityTracker(db, logger)) + h.SetMiniMaxTracker(tracker.NewMiniMaxTracker(db, logger)) + + settings, err := db.GetMenubarSettings() + if err != nil { + return err + } + mbCfg := settings.ToConfig(cfg.Port, h.BuildMenubarSnapshot) + mbCfg.TestMode = cfg.TestMode + + pidPath := menubarPIDPath(cfg.TestMode) + if err := writeRuntimePID(pidPath); err != nil { + return fmt.Errorf("failed to write menubar pid file: %w", err) + } + defer os.Remove(pidPath) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + go func() { + <-sigCh + _ = menubar.Stop() + }() + + return menubar.Init(mbCfg) +} From f48dfa6998dd2d3201ee0e9bb1a93d92493c6d20 Mon Sep 17 00:00:00 2001 From: Prakersh Maheshwari Date: Mon, 9 Mar 2026 21:43:38 +0530 Subject: [PATCH 02/19] feat(menubar): add dashboard controls and browser coverage --- internal/web/static/app.js | 243 +++++++++++++++++++++++- internal/web/static/style.css | 101 ++++++++++ internal/web/templates/settings.html | 66 +++++++ tests/e2e/conftest.py | 9 +- tests/e2e/page_objects/settings_page.py | 2 +- tests/e2e/tests/test_menubar.py | 54 ++++++ tests/e2e/tests/test_settings.py | 27 ++- 7 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/tests/test_menubar.py diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 7557a30..8bae247 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -142,6 +142,9 @@ const State = { allProvidersInsights: null, allProvidersHistory: null, providerVisibility: {}, + menubarCapabilities: null, + menubarProviderOrder: [], + menubarProviders: [], currentRequestSeq: 0, insightsRequestSeq: 0, historyRequestSeq: 0, @@ -6050,9 +6053,10 @@ function isSettingsPage() { return window.location.pathname === '/settings'; } -function initSettingsPage() { +async function initSettingsPage() { setupSettingsTabs(); - loadSettings(); + await setupMenubarSettings(); + await loadSettings(); setupSettingsSave(); setupProviderReload(); setupSMTPTest(); @@ -6063,6 +6067,12 @@ function initSettingsPage() { populateTimezoneSelect(); } +function activateSettingsTab(tabName) { + const nextTab = document.querySelector(`.settings-tab[data-tab="${tabName}"]`); + if (!nextTab || nextTab.hidden) return; + nextTab.click(); +} + function setupSettingsTabs() { const tabs = document.querySelectorAll('.settings-tab'); const panels = document.querySelectorAll('.settings-panel'); @@ -6096,6 +6106,46 @@ function setupThresholdSliders() { } } +async function loadCapabilities() { + try { + const resp = await authFetch('/api/capabilities'); + if (!resp.ok) return null; + return await resp.json(); + } catch (e) { + return null; + } +} + +async function setupMenubarSettings() { + const tab = document.querySelector('.settings-tab[data-tab="menubar"]'); + const panel = document.getElementById('panel-menubar'); + const settingsShell = document.getElementById('menubar-settings-shell'); + const orderShell = document.getElementById('menubar-order-shell'); + const divider = document.getElementById('menubar-order-divider'); + const upgradeBanner = document.getElementById('menubar-upgrade-banner'); + const badge = document.getElementById('menubar-build-badge'); + if (!tab || !panel) return; + + const caps = await loadCapabilities(); + State.menubarCapabilities = caps; + + const isMac = caps && caps.platform === 'darwin'; + if (!isMac) { + tab.hidden = true; + panel.hidden = true; + if (tab.classList.contains('active')) activateSettingsTab('general'); + return; + } + + tab.hidden = false; + const supported = !!caps.menubar_supported; + if (badge) badge.textContent = supported ? 'Full Build' : 'Lite Build'; + if (settingsShell) settingsShell.hidden = !supported; + if (orderShell) orderShell.hidden = !supported; + if (divider) divider.hidden = !supported; + if (upgradeBanner) upgradeBanner.hidden = supported; +} + async function loadSettings() { try { const resp = await authFetch('/api/settings'); @@ -6152,12 +6202,38 @@ async function loadSettings() { } // Provider visibility + dynamic provider status - populateProviderToggles(data.provider_visibility || {}); + await populateProviderToggles(data.provider_visibility || {}); + await populateMenubarSettings(data.menubar || {}); } catch (e) { // Settings load failed silently } } +async function populateMenubarSettings(data) { + const caps = State.menubarCapabilities || await loadCapabilities(); + State.menubarCapabilities = caps; + if (!caps || caps.platform !== 'darwin') return; + + const settings = data || {}; + const shell = document.getElementById('menubar-settings-shell'); + if (shell && shell.hidden) return; + + const enabled = document.getElementById('menubar-enabled'); + const defaultView = document.getElementById('menubar-default-view'); + const refresh = document.getElementById('menubar-refresh'); + const warning = document.getElementById('menubar-warning'); + const critical = document.getElementById('menubar-critical'); + + if (enabled) enabled.checked = settings.enabled !== false; + if (defaultView && settings.default_view) defaultView.value = settings.default_view; + if (refresh && settings.refresh_seconds) refresh.value = String(settings.refresh_seconds); + if (warning && settings.warning_percent != null) warning.value = settings.warning_percent; + if (critical && settings.critical_percent != null) critical.value = settings.critical_percent; + + State.menubarProviderOrder = Array.isArray(settings.providers_order) ? settings.providers_order.slice() : []; + await populateMenubarProviderOrder(); +} + function setVal(id, val) { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.value = val; @@ -6293,6 +6369,145 @@ async function populateProviderToggles(visibility) { } } +async function fetchMenubarProviders() { + let providers = []; + try { + const res = await authFetch(`${API_BASE}/api/providers/status`); + if (res.ok) { + const data = await res.json(); + providers = Array.isArray(data.providers) ? data.providers : []; + } + } catch (e) { + providers = []; + } + + if (providers.length === 0) { + return []; + } + + const providerByKey = new Map(providers.map(p => [p.key, p])); + const codexStatus = providerByKey.get('codex') || null; + const items = providers + .filter(p => p.key !== 'codex') + .map(p => ({ + key: p.key, + name: p.name, + meta: `${p.pollingEnabled === false ? 'Telemetry Off' : 'Telemetry On'} · ${p.dashboardVisible === false ? 'Hidden from dashboard' : 'Visible in dashboard'}`, + dashboardVisible: p.dashboardVisible !== false, + })); + + try { + const res = await authFetch(`${API_BASE}/api/codex/profiles`); + if (res.ok) { + const data = await res.json(); + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + if (profiles.length > 1) { + profiles.forEach(profile => { + const key = `codex:${profile.id}`; + items.push({ + key, + name: `Codex - ${profile.name}`, + meta: 'Per-account Codex usage', + dashboardVisible: true, + }); + }); + return items; + } + } + } catch (e) { + // fall back to single Codex item below + } + + if (codexStatus) { + items.push({ + key: 'codex', + name: codexStatus.name || 'Codex', + meta: `${codexStatus.pollingEnabled === false ? 'Telemetry Off' : 'Telemetry On'} · ${codexStatus.dashboardVisible === false ? 'Hidden from dashboard' : 'Visible in dashboard'}`, + dashboardVisible: codexStatus.dashboardVisible !== false, + }); + } + + return items; +} + +async function populateMenubarProviderOrder() { + const list = document.getElementById('menubar-provider-order'); + if (!list) return; + + const providers = await fetchMenubarProviders(); + State.menubarProviders = providers.slice(); + if (providers.length === 0) { + list.innerHTML = ''; + return; + } + + const order = Array.isArray(State.menubarProviderOrder) ? State.menubarProviderOrder : []; + const indexByKey = new Map(order.map((key, index) => [key, index])); + providers.sort((a, b) => { + const left = indexByKey.has(a.key) ? indexByKey.get(a.key) : Number.MAX_SAFE_INTEGER; + const right = indexByKey.has(b.key) ? indexByKey.get(b.key) : Number.MAX_SAFE_INTEGER; + if (left !== right) return left - right; + return a.name.localeCompare(b.name); + }); + State.menubarProviderOrder = providers.map(provider => provider.key); + + list.innerHTML = providers.map(provider => ` + + `).join(''); + + let dragged = null; + list.querySelectorAll('.menubar-order-item').forEach(item => { + item.addEventListener('dragstart', () => { + dragged = item; + item.classList.add('dragging'); + }); + item.addEventListener('dragend', () => { + item.classList.remove('dragging'); + syncMenubarProviderOrder(); + }); + }); + + list.addEventListener('dragover', (event) => { + event.preventDefault(); + const dragging = list.querySelector('.menubar-order-item.dragging'); + if (!dragging) return; + const afterElement = getMenubarDragAfterElement(list, event.clientY); + if (!afterElement) { + list.appendChild(dragging); + } else if (afterElement !== dragging) { + list.insertBefore(dragging, afterElement); + } + }, { passive: false }); + + syncMenubarProviderOrder(); +} + +function getMenubarDragAfterElement(container, y) { + const items = [...container.querySelectorAll('.menubar-order-item:not(.dragging)')]; + return items.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, { offset: Number.NEGATIVE_INFINITY, element: null }).element; +} + +function syncMenubarProviderOrder() { + const list = document.getElementById('menubar-provider-order'); + if (!list) return; + State.menubarProviderOrder = [...list.querySelectorAll('.menubar-order-item[data-provider]')] + .map(item => item.dataset.provider) + .filter(Boolean); +} + function providerStatusBadge(configured, autoDetectable, isPolling) { if (!configured) { return autoDetectable @@ -6513,6 +6728,18 @@ function gatherSettings() { settings.timezone = tzSelect.value; } + const menubarShell = document.getElementById('menubar-settings-shell'); + if (menubarShell && !menubarShell.hidden) { + settings.menubar = { + enabled: document.getElementById('menubar-enabled')?.checked ?? true, + default_view: document.getElementById('menubar-default-view')?.value || 'standard', + refresh_seconds: parseInt(document.getElementById('menubar-refresh')?.value, 10) || 60, + warning_percent: parseInt(document.getElementById('menubar-warning')?.value, 10) || 70, + critical_percent: parseInt(document.getElementById('menubar-critical')?.value, 10) || 90, + providers_order: [...State.menubarProviderOrder], + }; + } + return settings; } @@ -6537,6 +6764,14 @@ function setupSettingsSave() { return; } } + if (settings.menubar) { + if (settings.menubar.warning_percent >= settings.menubar.critical_percent) { + showSettingsFeedback(feedback, 'Menubar warning threshold must be less than critical threshold.', 'error'); + saveBtn.disabled = false; + saveBtn.innerHTML = ' Save Settings'; + return; + } + } try { const resp = await authFetch('/api/settings', { @@ -6936,7 +7171,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (isSettingsPage()) { initTheme(); initLayoutToggle(); - initSettingsPage(); + await initSettingsPage(); return; } diff --git a/internal/web/static/style.css b/internal/web/static/style.css index f7294c5..fbc212f 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -2815,6 +2815,106 @@ select.settings-input { border: 1px solid var(--status-danger); } +.menubar-section { + display: flex; + flex-direction: column; + gap: 18px; +} + +.menubar-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.menubar-upgrade-banner { + border: 1px solid rgba(96, 165, 250, 0.24); + background: + radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 32%), + var(--surface-card); +} + +.menubar-upgrade-command { + margin: 0; + padding: 14px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-light); + background: var(--surface-inset); + color: var(--text-primary); + font-size: 12px; + overflow-x: auto; +} + +.menubar-order-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.menubar-order-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--surface-inset); + cursor: grab; + transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.menubar-order-item:focus-visible, +.menubar-order-item:hover { + outline: none; + border-color: var(--accent-teal); + box-shadow: var(--shadow-sm); +} + +.menubar-order-item.dragging { + opacity: 0.72; + transform: scale(0.99); +} + +.menubar-order-handle { + display: inline-flex; + flex-direction: column; + gap: 3px; + color: var(--text-muted); +} + +.menubar-order-handle span { + width: 18px; + height: 2px; + border-radius: 999px; + background: currentColor; +} + +.menubar-order-copy { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +.menubar-order-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.menubar-order-meta { + font-size: 12px; + color: var(--text-muted); +} + +.menubar-order-item.is-disabled { + opacity: 0.65; +} + /* Settings page responsive */ @media (max-width: 768px) { .settings-main { padding: 16px; } @@ -2825,6 +2925,7 @@ select.settings-input { .settings-header { padding: 12px 16px; } .settings-title { font-size: 18px; } .settings-actions { flex-direction: column; align-items: flex-start; } + .menubar-section-heading { flex-direction: column; align-items: stretch; } } @media (max-width: 480px) { diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 5936ae0..aa86952 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -30,6 +30,7 @@

Settings Beta

+ @@ -203,6 +204,71 @@

Provider Controls

+ + +