diff --git a/.env.example b/.env.example index a6b9d3c..c9f20c7 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,9 @@ ONWATCH_ADMIN_PASS=changeme # --- Logging --- # Log level: debug, info, warn, error (default: info) -# In background mode (default), logs go to .onwatch.log +# In background mode (default), logs are stored in the DB directory (default: ~/.onwatch/data/) +# Main daemon log: .onwatch.log (or .onwatch-test.log in --test mode) +# Menubar companion log (macOS menubar builds): menubar.log (or menubar-test.log in --test mode) +# Each file rotates at 50MB with 3 backups (.1, .2, .3) # In debug mode (--debug), logs go to stdout ONWATCH_LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27fd099..89a4006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,39 @@ jobs: - name: Build run: go build -o onwatch . + + menubar-macos: + runs-on: macos-latest + name: Menubar macOS + + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Compile tagged menubar packages + run: | + go test -tags menubar ./internal/menubar ./internal/web + CGO_LDFLAGS="-framework UniformTypeIdentifiers" go build -tags menubar,desktop,production -o /tmp/onwatch-menubar . + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install E2E dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/e2e/requirements.txt + python -m playwright install chromium + + - name: Run menubar browser tests + env: + CGO_LDFLAGS: -framework UniformTypeIdentifiers + ONWATCH_E2E_GO_BUILD_TAGS: menubar,desktop,production + run: | + cd tests/e2e + pytest tests/test_menubar.py -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94dcd7b..4adb481 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,18 +34,11 @@ jobs: - name: Test with race detection run: go test -race ./... - # Build binaries in parallel (after tests pass) - build: + build-standard: needs: test strategy: matrix: include: - - goos: darwin - goarch: arm64 - binary: onwatch-darwin-arm64 - - goos: darwin - goarch: amd64 - binary: onwatch-darwin-amd64 - goos: linux goarch: amd64 binary: onwatch-linux-amd64 @@ -88,9 +81,73 @@ jobs: name: ${{ matrix.binary }} path: ${{ matrix.binary }} + build-macos-amd64: + needs: test + runs-on: macos-13 + name: Build macOS amd64 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Read version + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + + - name: Build macOS artifact + run: | + CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build \ + -tags menubar,desktop,production \ + -ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" \ + -o onwatch-darwin-amd64 . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: onwatch-darwin-amd64 + path: onwatch-darwin-amd64 + + build-macos-arm64: + needs: test + runs-on: macos-14 + name: Build macOS arm64 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Read version + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + + - name: Build macOS artifact + run: | + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build \ + -tags menubar,desktop,production \ + -ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" \ + -o onwatch-darwin-arm64 . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: onwatch-darwin-arm64 + path: onwatch-darwin-arm64 + # Create GitHub release with all binaries release: - needs: build + needs: [build-standard, build-macos-amd64, build-macos-arm64] runs-on: ubuntu-latest name: Release diff --git a/.gitignore b/.gitignore index df7c87e..c2998d8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ Thumbs.db *.gif *.bmp !docs/screenshots/*.png +!internal/menubar/icon_template.png +!internal/menubar/icon_template@2x.png tmp/ temp/ test-screenshots/ diff --git a/README.md b/README.md index d488217..bb0c614 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ curl -fsSL https://raw.githubusercontent.com/onllm-dev/onwatch/main/install.sh | This downloads the binary to `~/.onwatch/`, creates a `.env` config, sets up a systemd service (Linux) or self-daemonizes (macOS), and adds `onwatch` to your PATH. +On macOS, the installer downloads the standard binary with menubar support. + ### Homebrew (macOS & Linux) ```bash @@ -127,7 +129,7 @@ Provider setup guides: ### Run ```bash -onwatch # start in background (daemonizes, logs to ~/.onwatch/.onwatch.log) +onwatch # start in background (daemonizes, logs to ~/.onwatch/data/.onwatch.log) onwatch --debug # foreground mode, logs to stdout onwatch stop # stop the running instance onwatch status # check if running @@ -177,6 +179,15 @@ Each quota card shows: usage vs. limit with progress bar, live countdown to rese **Settings** -- Dedicated settings page (`/settings`) with tabs for general preferences, provider controls, notification thresholds, and SMTP email configuration. +**Menubar (macOS, Beta)** -- The macOS build includes a menubar companion with two preset views: + +- **Standard** -- Provider cards with circular quota meters and reset metadata +- **Detailed** -- Expanded provider cards with sparkline trends and full quota breakdowns + +Configure it in **Settings > Menubar**. You can enable or disable the companion, pick the default view, change refresh and threshold settings, and drag providers into the order you want. + +Menubar is currently in beta. Feedback is highly appreciated at [github.com/onllm-dev/onwatch/issues](https://github.com/onllm-dev/onwatch/issues). + **Email notifications (Beta)** -- Configure SMTP to receive alerts when quotas cross warning or critical thresholds, or when quotas reset. Per-quota threshold overrides for fine-grained control. SMTP passwords are encrypted at rest with AES-GCM. **Push notifications (Beta)** -- Receive browser push notifications when quotas cross thresholds. onWatch is a PWA (Progressive Web App) - install it from your browser for a native app experience. Uses Web Push protocol (VAPID) with zero external dependencies. Configure delivery channels (email, push, or both) per your preference. @@ -319,10 +330,13 @@ All endpoints require authentication (session cookie or Basic Auth). Append `?pr | `/api/cycles?type=subscription` | GET | Reset cycle history | | `/api/cycle-overview` | GET | Cross-quota correlation at peak usage | | `/api/summary` | GET | Usage summaries | +| `/api/capabilities` | GET | Build/runtime capabilities (platform, menubar) | +| `/api/menubar/summary` | GET | Normalized menubar snapshot payload | +| `/api/menubar/test` | GET | Browser-testable menubar page in test mode | | `/api/sessions` | GET | Session history | | `/api/insights` | GET | Usage insights | | `/api/providers` | GET | Available providers | -| `/api/settings` | GET/PUT | User settings (notifications, SMTP, providers) | +| `/api/settings` | GET/PUT | User settings (notifications, SMTP, providers, menubar) | | `/api/settings/smtp/test` | POST | Send test email via configured SMTP | | `/api/password` | PUT | Change password | | `/api/push/vapid` | GET | Get VAPID public key for push subscription | @@ -366,11 +380,15 @@ onwatch stop && onwatch ```shell ~/.onwatch/ ├── onwatch.pid # PID file -├── .onwatch.log # Log file (background mode) └── data/ - └── onwatch.db # SQLite database (WAL mode) + ├── onwatch.db # SQLite database (WAL mode) + ├── .onwatch.log # Main daemon log file (background mode) + └── menubar.log # Menubar companion log file (macOS menubar builds) ``` +Log files are stored next to the database (default `~/.onwatch/data/`). +Each log rotates at 50 MB with 3 backups (`.1`, `.2`, `.3`) for both main and menubar logs. + On first run, if a database exists at `./onwatch.db`, onWatch auto-migrates it to `~/.onwatch/data/`. --- @@ -494,7 +512,7 @@ The `docker-compose.yml` includes memory limits (64M limit, 32M reservation), lo See [DEVELOPMENT.md](docs/DEVELOPMENT.md) for build instructions, cross-compilation, and testing. ```bash -./app.sh --build # Production binary (or: make build) +./app.sh --build # Production binary (macOS includes menubar) (or: make build) ./app.sh --test # Tests with race detection (or: make test) ./app.sh --build --run # Build + run debug mode (or: make run) ./app.sh --release # Cross-compile all platforms (or: make release-local) diff --git a/VERSION b/VERSION index ee2951a..0865cff 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.18 +2.11.19 diff --git a/app.sh b/app.sh index 822952e..3693934 100755 --- a/app.sh +++ b/app.sh @@ -4,7 +4,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" VERSION=$(cat "$SCRIPT_DIR/VERSION") BINARY="onwatch" -LDFLAGS="-ldflags=-s -w -X main.version=$VERSION" +DARWIN_FULL_TAGS="menubar,desktop,production" +DARWIN_CGO_LDFLAGS="-framework UniformTypeIdentifiers" # --- Colors --- RED='\033[0;31m' @@ -28,11 +29,11 @@ ${CYAN}USAGE:${NC} ./app.sh [FLAGS...] ${CYAN}FLAGS:${NC} - --build, -b Build production binary with version ldflags + --build, -b Build production binary (macOS includes menubar support) --test, -t Run all tests with race detection and coverage --smoke, -s Quick validation: vet + build check + short tests --run, -r Build and run in debug mode (foreground) - --release Run tests, then cross-compile for 5 platforms + --release Run tests, then build release binaries --clean, -c Remove binary, coverage files, dist/, test cache --stop Stop a running instance (native or Docker) --docker Docker mode: --build/--run/--clean/--stop use Docker @@ -43,7 +44,7 @@ ${CYAN}FLAGS:${NC} ${CYAN}EXAMPLES:${NC} ./app.sh --build # Build production binary - ./app.sh --test # Run full test suite + ./app.sh --test # Run full test suite ./app.sh --smoke # Quick pre-commit check ./app.sh --clean --build --run # Clean, rebuild, and run ./app.sh --deps --build --test # Install deps, build, test @@ -176,11 +177,47 @@ do_clean() { do_build() { info "Building onWatch v${VERSION}..." - cd "$SCRIPT_DIR" - go build -ldflags="-s -w -X main.version=$VERSION" -o "$BINARY" . + build_native_binary "$SCRIPT_DIR/$BINARY" success "Built ./$BINARY ($(du -h "$BINARY" | cut -f1 | xargs))" } +build_native_binary() { + local output="$1" + cd "$SCRIPT_DIR" + + if [[ "$(uname)" == "Darwin" ]]; then + CGO_ENABLED=1 CGO_LDFLAGS="$DARWIN_CGO_LDFLAGS" go build \ + -tags "$DARWIN_FULL_TAGS" \ + -ldflags="-s -w -X main.version=$VERSION" \ + -o "$output" . + return + fi + + go build \ + -ldflags="-s -w -X main.version=$VERSION" \ + -o "$output" . +} + +build_darwin() { + cd "$SCRIPT_DIR" + if [[ "$(uname)" != "Darwin" ]]; then + error "macOS menubar builds require a macOS host" + return 1 + fi + + mkdir -p "$SCRIPT_DIR/dist" + for arch in arm64 amd64; do + local output="dist/onwatch-darwin-${arch}" + info "Building ${output}..." + CGO_ENABLED=1 CGO_LDFLAGS="$DARWIN_CGO_LDFLAGS" GOOS=darwin GOARCH="$arch" go build \ + -tags "$DARWIN_FULL_TAGS" \ + -ldflags="-s -w -X main.version=$VERSION" \ + -o "$SCRIPT_DIR/$output" . + done + + success "Built macOS binaries in dist/" +} + do_test() { info "Running tests with race detection and coverage..." cd "$SCRIPT_DIR" @@ -196,7 +233,7 @@ do_smoke() { go vet ./... info " Build check..." - go build -ldflags="-s -w -X main.version=$VERSION" -o /dev/null . + build_native_binary /dev/null info " Short tests..." go test -short -count=1 ./... @@ -210,12 +247,16 @@ do_release() { go test -race -cover -count=1 ./... success "Tests passed." - info "Cross-compiling onWatch v${VERSION} for 5 platforms..." + info "Building release artifacts for onWatch v${VERSION}..." mkdir -p "$SCRIPT_DIR/dist" + if [[ "$(uname)" == "Darwin" ]]; then + build_darwin + else + warn "Skipping macOS binaries on non-macOS host. Use macOS CI or a local macOS build host for menubar artifacts." + fi + local targets=( - "darwin:arm64:" - "darwin:amd64:" "linux:amd64:" "linux:arm64:" "windows:amd64:.exe" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 790f3da..026dba4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -72,7 +72,7 @@ go build -ldflags="-s -w" -o onwatch.exe . `app.sh` is the primary entry point. `make` targets are thin wrappers. ```bash -./app.sh --build # Build production binary (or: make build) +./app.sh --build # Build production binary (macOS includes menubar support) (or: make build) ./app.sh --test # Tests with race detection and coverage (or: make test) ./app.sh --build --run # Build + run in debug mode (or: make run) ./app.sh --clean # Remove binary, coverage, dist/ (or: make clean) diff --git a/docs/screenshots/INDEX.md b/docs/screenshots/INDEX.md index f2d86a2..01207d7 100644 --- a/docs/screenshots/INDEX.md +++ b/docs/screenshots/INDEX.md @@ -51,6 +51,17 @@ Dashboard screenshots captured from a live onWatch v2.11.0 instance with real An | `all-light.png` | All Providers view in light mode. Anthropic, Synthetic, Z.ai, Codex, GitHub Copilot (Beta), and Antigravity quotas side-by-side with combined insights. | | `all-dark.png` | All Providers view in dark mode. | +## Menubar Companion (macOS, Beta) + +| File | Description | +|------|-------------| +| `menubar-minimal-light.png` | Menubar companion minimal mode in light theme. Compact single-row quota display for quick glance checks. | +| `menubar-minimal-dark.png` | Menubar companion minimal mode in dark theme. | +| `menubar-standard-light.png` | Menubar companion standard mode in light theme. Provider cards with circular quota meters and reset metadata. | +| `menubar-standard-dark.png` | Menubar companion standard mode in dark theme. | +| `menubar-detailed-light.png` | Menubar companion detailed mode in light theme. Expanded quota details with bar rows and richer metadata. | +| `menubar-detailed-dark.png` | Menubar companion detailed mode in dark theme. | + ## Legacy (pre-v2.1.0) | File | Description | diff --git a/docs/screenshots/menubar-detailed-dark.png b/docs/screenshots/menubar-detailed-dark.png new file mode 100644 index 0000000..9c1ab16 Binary files /dev/null and b/docs/screenshots/menubar-detailed-dark.png differ diff --git a/docs/screenshots/menubar-detailed-light.png b/docs/screenshots/menubar-detailed-light.png new file mode 100644 index 0000000..8d39be1 Binary files /dev/null and b/docs/screenshots/menubar-detailed-light.png differ diff --git a/docs/screenshots/menubar-minimal-dark.png b/docs/screenshots/menubar-minimal-dark.png new file mode 100644 index 0000000..cc2a4ba Binary files /dev/null and b/docs/screenshots/menubar-minimal-dark.png differ diff --git a/docs/screenshots/menubar-minimal-light.png b/docs/screenshots/menubar-minimal-light.png new file mode 100644 index 0000000..eadc1b8 Binary files /dev/null and b/docs/screenshots/menubar-minimal-light.png differ diff --git a/docs/screenshots/menubar-standard-dark.png b/docs/screenshots/menubar-standard-dark.png new file mode 100644 index 0000000..dbfd7d7 Binary files /dev/null and b/docs/screenshots/menubar-standard-dark.png differ diff --git a/docs/screenshots/menubar-standard-light.png b/docs/screenshots/menubar-standard-light.png new file mode 100644 index 0000000..52d7734 Binary files /dev/null and b/docs/screenshots/menubar-standard-light.png differ diff --git a/go.mod b/go.mod index bf02cb2..7ee9f4d 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,23 @@ module github.com/onllm-dev/onwatch/v2 go 1.25.7 require ( + fyne.io/systray v1.12.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c golang.org/x/crypto v0.47.0 modernc.org/sqlite v1.44.3 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/tools v0.40.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..9eb380a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 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/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= @@ -12,21 +16,24 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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/install.sh b/install.sh index 56055e1..ae88a2e 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,7 @@ BIN_DIR="${INSTALL_DIR}/bin" REPO="onllm-dev/onwatch" SERVICE_NAME="onwatch" SYSTEMD_MODE="user" # "user" or "system" — auto-detected at runtime +INSTALL_VERSION="latest" # Collected during interactive setup, used by start_service SETUP_USERNAME="" @@ -177,6 +178,35 @@ validate_interval() { return 1 } +print_usage() { + cat <] + +Options: + --version Download a specific release tag instead of latest + --help Show this help text +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --version) + [[ $# -ge 2 ]] || fail "--version requires a release tag" + INSTALL_VERSION="$2" + shift 2 + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac + done +} + # ─── Detect Platform ───────────────────────────────────────────────── detect_platform() { local os arch @@ -198,6 +228,10 @@ detect_platform() { esac PLATFORM="${OS}-${ARCH}" + resolve_asset_name +} + +resolve_asset_name() { ASSET_NAME="onwatch-${PLATFORM}" } @@ -304,6 +338,9 @@ stop_existing() { # then moves into place. download() { local url="https://github.com/${REPO}/releases/latest/download/${ASSET_NAME}" + if [[ "$INSTALL_VERSION" != "latest" ]]; then + url="https://github.com/${REPO}/releases/download/${INSTALL_VERSION}/${ASSET_NAME}" + fi local dest="${BIN_DIR}/onwatch" local tmp_dest="/tmp/onwatch-download-$$" @@ -1272,6 +1309,8 @@ print_errors() { # ─── Main ───────────────────────────────────────────────────────────── main() { + parse_args "$@" + printf "\n" printf " ${BOLD}onWatch Installer${NC}\n" printf " ${DIM}https://github.com/${REPO}${NC}\n" diff --git a/internal/config/config.go b/internal/config/config.go index df3ad0d..b7f0987 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,11 @@ import ( "github.com/joho/godotenv" ) +const ( + maxLogFileBytes = 50 * 1024 * 1024 + maxLogBackups = 3 +) + // Config holds all application configuration. type Config struct { // Synthetic provider configuration @@ -508,6 +513,38 @@ func redactAPIKey(key string, expectedPrefix string) string { return key[:prefixLen+4] + "***...***" + key[len(key)-3:] } +// OpenRotatingLogFile opens a log file with size-based rotation. +// If the active file reaches 50MB, the chain is rotated before opening: +// path.2 -> path.3, path.1 -> path.2, path -> path.1. +func OpenRotatingLogFile(path string) (*os.File, error) { + if info, err := os.Stat(path); err == nil { + if info.Size() >= maxLogFileBytes { + oldest := fmt.Sprintf("%s.%d", path, maxLogBackups) + if err := os.Remove(oldest); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to remove oldest log backup %s: %w", oldest, err) + } + for i := maxLogBackups - 1; i >= 1; i-- { + src := fmt.Sprintf("%s.%d", path, i) + dst := fmt.Sprintf("%s.%d", path, i+1) + if err := os.Rename(src, dst); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to rotate log backup %s to %s: %w", src, dst, err) + } + } + if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to rotate active log file %s: %w", path, err) + } + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to stat log file %s: %w", path, err) + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, err + } + return file, nil +} + // LogWriter returns the appropriate log destination based on debug mode. // In debug mode: returns os.Stdout // In Docker: returns os.Stdout (containers should log to stdout) @@ -529,7 +566,7 @@ func (c *Config) LogWriter() (io.Writer, error) { } logPath := filepath.Join(filepath.Dir(c.DBPath), logName) - file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + file, err := OpenRotatingLogFile(logPath) if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be82af4..8809ce2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -404,9 +404,10 @@ func TestConfig_LogWriter(t *testing.T) { t.Error("Debug mode should return os.Stdout") } + tmpDir := t.TempDir() cfg = &Config{ DebugMode: false, - DBPath: "/tmp/test_onwatch.db", + DBPath: filepath.Join(tmpDir, "onwatch.db"), } writer, err = cfg.LogWriter() if err != nil { @@ -415,6 +416,151 @@ func TestConfig_LogWriter(t *testing.T) { if writer == os.Stdout { t.Error("Background mode should not return os.Stdout") } + if file, ok := writer.(*os.File); ok { + _ = file.Close() + } +} + +func TestOpenRotatingLogFile_RotatesAtSizeLimit(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "rotate.log") + + if err := os.WriteFile(logPath+".1", []byte("backup-one"), 0o644); err != nil { + t.Fatalf("write backup .1: %v", err) + } + if err := os.WriteFile(logPath+".2", []byte("backup-two"), 0o644); err != nil { + t.Fatalf("write backup .2: %v", err) + } + if err := os.WriteFile(logPath+".3", []byte("backup-three"), 0o644); err != nil { + t.Fatalf("write backup .3: %v", err) + } + + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + t.Fatalf("create active log: %v", err) + } + if _, err := f.WriteString("active-log"); err != nil { + t.Fatalf("write active log: %v", err) + } + if err := f.Truncate(maxLogFileBytes); err != nil { + t.Fatalf("truncate active log: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close active log: %v", err) + } + + rotated, err := OpenRotatingLogFile(logPath) + if err != nil { + t.Fatalf("OpenRotatingLogFile() error: %v", err) + } + if err := rotated.Close(); err != nil { + t.Fatalf("close rotated log: %v", err) + } + + currentInfo, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat current log: %v", err) + } + if currentInfo.Size() != 0 { + t.Fatalf("current log size = %d, want 0", currentInfo.Size()) + } + + rotatedInfo, err := os.Stat(logPath + ".1") + if err != nil { + t.Fatalf("stat rotated .1 log: %v", err) + } + if rotatedInfo.Size() != maxLogFileBytes { + t.Fatalf("rotated .1 size = %d, want %d", rotatedInfo.Size(), maxLogFileBytes) + } + + backup2, err := os.ReadFile(logPath + ".2") + if err != nil { + t.Fatalf("read backup .2: %v", err) + } + if string(backup2) != "backup-one" { + t.Fatalf("backup .2 = %q, want %q", string(backup2), "backup-one") + } + + backup3, err := os.ReadFile(logPath + ".3") + if err != nil { + t.Fatalf("read backup .3: %v", err) + } + if string(backup3) != "backup-two" { + t.Fatalf("backup .3 = %q, want %q", string(backup3), "backup-two") + } +} + +func TestOpenRotatingLogFile_DoesNotRotateBelowSizeLimit(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "rotate.log") + + if err := os.WriteFile(logPath, []byte("active"), 0o644); err != nil { + t.Fatalf("write active log: %v", err) + } + if err := os.WriteFile(logPath+".1", []byte("backup-one"), 0o644); err != nil { + t.Fatalf("write backup .1: %v", err) + } + + file, err := OpenRotatingLogFile(logPath) + if err != nil { + t.Fatalf("OpenRotatingLogFile() error: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close log file: %v", err) + } + + active, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read active log: %v", err) + } + if string(active) != "active" { + t.Fatalf("active log = %q, want %q", string(active), "active") + } + + backup1, err := os.ReadFile(logPath + ".1") + if err != nil { + t.Fatalf("read backup .1: %v", err) + } + if string(backup1) != "backup-one" { + t.Fatalf("backup .1 = %q, want %q", string(backup1), "backup-one") + } +} + +func TestConfig_LogWriter_RotatesFileWhenAtLimit(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "onwatch.db") + logPath := filepath.Join(tmpDir, ".onwatch.log") + + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + t.Fatalf("create log file: %v", err) + } + if err := f.Truncate(maxLogFileBytes); err != nil { + t.Fatalf("truncate log file: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close log file: %v", err) + } + + cfg := &Config{DebugMode: false, DBPath: dbPath} + writer, err := cfg.LogWriter() + if err != nil { + t.Fatalf("LogWriter() failed: %v", err) + } + if file, ok := writer.(*os.File); ok { + _ = file.Close() + } + + if _, err := os.Stat(logPath + ".1"); err != nil { + t.Fatalf("expected rotated backup file: %v", err) + } + info, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat active log: %v", err) + } + if info.Size() != 0 { + t.Fatalf("active log size = %d, want 0", info.Size()) + } } func TestConfig_LoadsAnthropicFromEnv(t *testing.T) { 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..140ebde --- /dev/null +++ b/internal/menubar/companion_darwin.go @@ -0,0 +1,252 @@ +//go:build menubar && darwin + +package menubar + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "sync" + "time" + + "fyne.io/systray" + "github.com/pkg/browser" +) + +var ( + quitOnce sync.Once + quitFn func() +) + +type trayController struct { + cfg *Config + popover menubarPopover +} + +func runCompanion(cfg *Config) error { + if cfg == nil { + cfg = DefaultConfig() + } + quitOnce = sync.Once{} + quitFn = nil + + controller := &trayController{cfg: cfg} + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, refreshCompanionSignal) + defer signal.Stop(signalChan) + go func() { + for range signalChan { + controller.refreshStatus() + } + }() + + slog.Default().Debug("Initializing systray") + systray.Run(controller.onReady, controller.onExit) + return nil +} + +func stopCompanion() error { + quitOnce.Do(func() { + if quitFn != nil { + quitFn() + return + } + systray.Quit() + }) + return nil +} + +func (c *trayController) onReady() { + logger := slog.Default() + logger.Info("Systray initialized, setting icon") + + templateIcon, regularIcon := trayIcons() + if len(templateIcon) > 0 && len(regularIcon) > 0 { + systray.SetTemplateIcon(templateIcon, regularIcon) + logger.Debug("Tray icon set successfully") + } + + systray.SetTooltip("onWatch menubar companion") + systray.SetOnTapped(func() { + c.toggleMenubar() + }) + + if popover, err := newMenubarPopover(menubarPopoverWidth, menubarPopoverHeight); err != nil { + logger.Warn("native macOS menubar host unavailable, using browser fallback", "error", err) + } else { + c.popover = popover + } + + dashboardItem := systray.AddMenuItem("Open Dashboard", "Open the local onWatch dashboard") + systray.AddSeparator() + quitItem := systray.AddMenuItem("Quit Menubar", "Quit the menubar companion") + + quitFn = func() { + systray.Quit() + } + + c.refreshStatus() + logger.Info("Menubar ready and visible") + + go c.watchMenu(dashboardItem, quitItem) + go c.refreshLoop() +} + +func (c *trayController) onExit() { + if c.popover != nil { + c.popover.Destroy() + c.popover = nil + } + quitFn = nil + slog.Default().Info("Menubar shutting down") +} + +func (c *trayController) watchMenu(dashboardItem, quitItem *systray.MenuItem) { + for { + select { + case <-dashboardItem.ClickedCh: + _ = browser.OpenURL(c.dashboardURL()) + case <-quitItem.ClickedCh: + _ = stopCompanion() + return + } + } +} + +func (c *trayController) toggleMenubar() { + url := c.menubarURL() + if c.popover != nil { + if err := c.popover.ToggleURL(url); err == nil { + return + } else { + slog.Default().Warn("failed to toggle native menubar host, opening browser fallback", "error", err) + } + } + _ = browser.OpenURL(url) +} + +func (c *trayController) showMenubar() { + url := c.menubarURL() + if c.popover != nil { + if err := c.popover.ShowURL(url); err == nil { + return + } else { + slog.Default().Warn("failed to show native menubar host, opening browser fallback", "error", err) + } + } + _ = browser.OpenURL(url) +} + +func (c *trayController) refreshLoop() { + interval := time.Duration(normalizeRefreshSeconds(c.cfg.RefreshSeconds)) * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + c.refreshStatus() + } +} + +func (c *trayController) refreshStatus() { + logger := slog.Default() + if c == nil || c.cfg == nil || c.cfg.SnapshotProvider == nil { + systray.SetTitle("onWatch") + systray.SetTooltip("onWatch menubar companion") + return + } + + snapshot, err := c.cfg.SnapshotProvider() + if err != nil { + logger.Error("failed to refresh menubar snapshot", "error", err) + systray.SetTitle("--") + systray.SetTooltip("onWatch menubar companion unavailable") + return + } + if snapshot == nil { + systray.SetTitle("--") + systray.SetTooltip("onWatch menubar companion unavailable") + return + } + + settings, err := c.fetchPreferences() + if err != nil { + logger.Debug("failed to refresh menubar preferences, using defaults", "error", err) + } + title := TrayTitle(snapshot, settings) + tooltip := trayTooltip(snapshot) + systray.SetTitle(title) + systray.SetTooltip(tooltip) + logger.Debug("Tray icon set successfully", "title", title) +} + +func (c *trayController) menubarURL() string { + port := 9211 + if c != nil && c.cfg != nil && c.cfg.Port > 0 { + port = c.cfg.Port + } + return fmt.Sprintf("http://localhost:%d/menubar", port) +} + +func (c *trayController) dashboardURL() string { + port := 9211 + if c != nil && c.cfg != nil && c.cfg.Port > 0 { + port = c.cfg.Port + } + return fmt.Sprintf("http://localhost:%d", port) +} + +func (c *trayController) preferencesURL() string { + port := 9211 + if c != nil && c.cfg != nil && c.cfg.Port > 0 { + port = c.cfg.Port + } + return fmt.Sprintf("http://localhost:%d/api/menubar/preferences", port) +} + +func (c *trayController) fetchPreferences() (*Settings, error) { + req, err := http.NewRequest(http.MethodGet, c.preferencesURL(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("preferences request failed: %s", resp.Status) + } + var settings Settings + if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil { + return nil, err + } + return settings.Normalize(), nil +} + +func trayTooltip(snapshot *Snapshot) string { + if snapshot == nil { + return "onWatch menubar companion" + } + aggregate := snapshot.Aggregate + if aggregate.ProviderCount == 0 { + return "onWatch menubar companion: no provider data available" + } + return fmt.Sprintf( + "onWatch menubar companion: %s across %d providers, updated %s", + aggregate.Label, + aggregate.ProviderCount, + snapshot.UpdatedAgo, + ) +} + +func normalizeRefreshSeconds(value int) int { + if value < 10 { + return 60 + } + return value +} 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..ce754b7 --- /dev/null +++ b/internal/menubar/config.go @@ -0,0 +1,303 @@ +package menubar + +import ( + "strings" + "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"` + VisibleProviders []string `json:"visible_providers"` + WarningPercent int `json:"warning_percent"` + CriticalPercent int `json:"critical_percent"` + StatusDisplay StatusDisplay `json:"status_display"` + Theme ThemeMode `json:"theme"` +} + +// StatusDisplayMode controls which compact metric is rendered beside the tray icon. +type StatusDisplayMode string + +const ( + StatusDisplayMultiProvider StatusDisplayMode = "multi_provider" + StatusDisplayCriticalCount StatusDisplayMode = "critical_count" + StatusDisplayIconOnly StatusDisplayMode = "icon_only" + statusDisplayProviderSpecificLegacy StatusDisplayMode = "provider_specific" +) + +// StatusDisplaySelection identifies one provider quota to surface in the tray title. +type StatusDisplaySelection struct { + ProviderID string `json:"provider_id"` + QuotaKey string `json:"quota_key,omitempty"` +} + +// StatusDisplay stores tray-title preferences shared by the popover and native companion. +type StatusDisplay struct { + Mode StatusDisplayMode `json:"mode"` + SelectedQuotas []StatusDisplaySelection `json:"selected_quotas,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + QuotaKey string `json:"quota_key,omitempty"` +} + +// ViewType controls which preset layout is rendered. +type ViewType string + +const ( + ViewMinimal ViewType = "minimal" + ViewStandard ViewType = "standard" + ViewDetailed ViewType = "detailed" +) + +// ThemeMode controls visual theme behavior for menubar UI. +type ThemeMode string + +const ( + ThemeSystem ThemeMode = "system" + ThemeLight ThemeMode = "light" + ThemeDark ThemeMode = "dark" +) + +// 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{}, + VisibleProviders: []string{}, + WarningPercent: 70, + CriticalPercent: 90, + StatusDisplay: StatusDisplay{ + Mode: StatusDisplayMultiProvider, + }, + Theme: ThemeSystem, + } +} + +// Normalize fills invalid or missing settings with safe defaults. +func (s *Settings) Normalize() *Settings { + defaults := DefaultSettings() + if s == nil { + return defaults + } + out := *s + switch out.DefaultView { + case ViewMinimal, ViewStandard, ViewDetailed: + default: + out.DefaultView = defaults.DefaultView + } + + switch { + case out.RefreshSeconds < 10: + out.RefreshSeconds = defaults.RefreshSeconds + } + switch { + case out.WarningPercent < 1 || out.WarningPercent > 99: + out.WarningPercent = defaults.WarningPercent + } + switch { + case out.CriticalPercent < 1 || out.CriticalPercent > 100: + out.CriticalPercent = defaults.CriticalPercent + } + switch { + case out.WarningPercent >= out.CriticalPercent: + out.WarningPercent = defaults.WarningPercent + out.CriticalPercent = defaults.CriticalPercent + } + if out.ProvidersOrder == nil { + out.ProvidersOrder = []string{} + } + out.ProvidersOrder = normalizedStringList(out.ProvidersOrder) + if out.VisibleProviders == nil { + out.VisibleProviders = []string{} + } + out.VisibleProviders = normalizedStringList(out.VisibleProviders) + out.StatusDisplay = out.StatusDisplay.normalize(defaults.StatusDisplay) + switch out.Theme { + case ThemeSystem, ThemeLight, ThemeDark: + default: + out.Theme = ThemeSystem + } + 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 +} + +func (s StatusDisplay) normalize(fallback StatusDisplay) StatusDisplay { + out := StatusDisplay{ + Mode: fallback.Mode, + SelectedQuotas: normalizeStatusSelections(s.SelectedQuotas), + } + legacyProviderID := strings.TrimSpace(s.ProviderID) + legacyQuotaKey := strings.TrimSpace(s.QuotaKey) + switch s.Mode { + case StatusDisplayMultiProvider, + StatusDisplayCriticalCount, + StatusDisplayIconOnly: + out.Mode = s.Mode + case statusDisplayProviderSpecificLegacy: + out.Mode = StatusDisplayMultiProvider + } + if len(out.SelectedQuotas) == 0 && legacyProviderID != "" { + out.SelectedQuotas = []StatusDisplaySelection{{ + ProviderID: legacyProviderID, + QuotaKey: legacyQuotaKey, + }} + } + if out.Mode != StatusDisplayMultiProvider { + out.SelectedQuotas = []StatusDisplaySelection{} + return out + } + return out +} + +func normalizeStatusSelections(values []StatusDisplaySelection) []StatusDisplaySelection { + if len(values) == 0 { + return []StatusDisplaySelection{} + } + seen := make(map[string]struct{}, len(values)) + out := make([]StatusDisplaySelection, 0, len(values)) + for _, value := range values { + providerID := strings.TrimSpace(value.ProviderID) + if providerID == "" { + continue + } + quotaKey := strings.TrimSpace(value.QuotaKey) + key := providerID + "\x00" + quotaKey + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, StatusDisplaySelection{ + ProviderID: providerID, + QuotaKey: quotaKey, + }) + if len(out) == 3 { + break + } + } + return out +} + +func normalizedStringList(values []string) []string { + if len(values) == 0 { + return []string{} + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} 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..fe9b727 --- /dev/null +++ b/internal/menubar/icon.go @@ -0,0 +1,13 @@ +package menubar + +import _ "embed" + +//go:embed icon_template.png +var IconTemplate []byte + +//go:embed icon_template@2x.png +var IconTemplate2x []byte + +func trayIcons() ([]byte, []byte) { + return IconTemplate, IconTemplate2x +} diff --git a/internal/menubar/icon_template.png b/internal/menubar/icon_template.png new file mode 100644 index 0000000..d4877cb Binary files /dev/null and b/internal/menubar/icon_template.png differ diff --git a/internal/menubar/icon_template@2x.png b/internal/menubar/icon_template@2x.png new file mode 100644 index 0000000..236561e Binary files /dev/null and b/internal/menubar/icon_template@2x.png differ diff --git a/internal/menubar/menubar_darwin.go b/internal/menubar/menubar_darwin.go new file mode 100644 index 0000000..91a2eb0 --- /dev/null +++ b/internal/menubar/menubar_darwin.go @@ -0,0 +1,30 @@ +//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 macOS-specific UI code isolated. +func Init(cfg *Config) error { + if cfg == nil { + cfg = DefaultConfig() + } + running.Store(true) + defer running.Store(false) + return runCompanion(cfg) +} + +// 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..b49adbf --- /dev/null +++ b/internal/menubar/menubar_stub.go @@ -0,0 +1,21 @@ +//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 } + +// TriggerRefresh is a no-op when the menubar companion is not compiled in. +func TriggerRefresh(testMode bool) error { + _ = testMode + return nil +} diff --git a/internal/menubar/menubar_test.go b/internal/menubar/menubar_test.go new file mode 100644 index 0000000..dbdc6bc --- /dev/null +++ b/internal/menubar/menubar_test.go @@ -0,0 +1,93 @@ +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, + VisibleProviders: []string{"synthetic", "", "synthetic"}, + WarningPercent: 99, + CriticalPercent: 60, + StatusDisplay: StatusDisplay{ + Mode: StatusDisplayMode("provider_specific"), + ProviderID: "synthetic", + QuotaKey: "search", + }, + }).Normalize() + + assert := func(ok bool, format string, args ...any) { + if !ok { + t.Fatalf(format, args...) + } + } + + assert(settings.DefaultView == ViewStandard, "expected standard view, got %s", settings.DefaultView) + assert(settings.RefreshSeconds == 60, "expected refresh 60, got %d", settings.RefreshSeconds) + assert( + settings.WarningPercent == 70 && settings.CriticalPercent == 90, + "expected fallback thresholds 70/90, got %d/%d", + settings.WarningPercent, + settings.CriticalPercent, + ) + assert(settings.Theme == ThemeSystem, "expected system theme, got %s", settings.Theme) + assert(settings.ProvidersOrder != nil, "expected providers order to be initialized") + assert( + len(settings.VisibleProviders) == 1 && settings.VisibleProviders[0] == "synthetic", + "unexpected visible providers: %#v", + settings.VisibleProviders, + ) + assert( + settings.StatusDisplay.Mode == StatusDisplayMultiProvider, + "expected multi_provider status display, got %s", + settings.StatusDisplay.Mode, + ) + assert( + len(settings.StatusDisplay.SelectedQuotas) == 1, + "expected one migrated tray selection, got %#v", + settings.StatusDisplay.SelectedQuotas, + ) + selection := settings.StatusDisplay.SelectedQuotas[0] + assert( + selection.ProviderID == "synthetic" && selection.QuotaKey == "search", + "unexpected migrated tray selection: %#v", + selection, + ) +} + +func TestSettingsNormalizePreservesMinimalView(t *testing.T) { + settings := (&Settings{DefaultView: ViewMinimal}).Normalize() + if settings.DefaultView != ViewMinimal { + t.Fatalf("expected minimal view to be preserved, got %s", settings.DefaultView) + } +} + +func TestInlineHTMLUsesRequestedView(t *testing.T) { + html, err := InlineHTML(ViewDetailed, DefaultSettings()) + if err != nil { + t.Fatalf("InlineHTML returned error: %v", err) + } + if !strings.Contains(html, `"default_view":"detailed"`) { + t.Fatalf("expected detailed default view in inline html, got: %s", html) + } +} + +func TestIsSupportedSmoke(t *testing.T) { + t.Logf("menubar supported: %v", IsSupported()) +} diff --git a/internal/menubar/popover_darwin.go b/internal/menubar/popover_darwin.go new file mode 100644 index 0000000..d2b99f5 --- /dev/null +++ b/internal/menubar/popover_darwin.go @@ -0,0 +1,19 @@ +//go:build menubar && darwin + +package menubar + +import "errors" + +const ( + menubarPopoverWidth = 360 + menubarPopoverHeight = 680 +) + +var errNativePopoverUnavailable = errors.New("native macOS menubar host unavailable") + +type menubarPopover interface { + ShowURL(string) error + ToggleURL(string) error + Close() + Destroy() +} diff --git a/internal/menubar/popover_darwin.m b/internal/menubar/popover_darwin.m new file mode 100644 index 0000000..968c5c4 --- /dev/null +++ b/internal/menubar/popover_darwin.m @@ -0,0 +1,511 @@ +//go:build menubar && darwin && cgo + +#import +#import + +@interface OnWatchBorderlessPanel : NSPanel +@end + +@implementation OnWatchBorderlessPanel +- (BOOL)canBecomeKeyWindow { + return YES; +} + +- (BOOL)canBecomeMainWindow { + return NO; +} + +- (BOOL)acceptsFirstMouse:(NSEvent *)event { + return YES; +} +@end + +static void onwatch_run_on_main_sync(dispatch_block_t block) { + if ([NSThread isMainThread]) { + block(); + return; + } + dispatch_sync(dispatch_get_main_queue(), block); +} + +@interface OnWatchPopoverController : NSObject +@property(nonatomic, strong) OnWatchBorderlessPanel *panel; +@property(nonatomic, strong) NSView *containerView; +@property(nonatomic, strong) WKWebView *webView; +@property(nonatomic, strong) id globalMouseMonitor; +@property(nonatomic, strong) id localMouseMonitor; +@property(nonatomic, strong) id appDeactivationObserver; +@property(nonatomic, assign) CGFloat width; +@property(nonatomic, assign) CGFloat height; +- (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height; +- (void)applyHeight:(CGFloat)height; +- (void)loadURLString:(NSString *)urlString; +- (BOOL)show; +- (BOOL)toggle; +- (void)close; +- (BOOL)isShown; +@end + +@implementation OnWatchPopoverController + +- (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height { + self = [super init]; + if (!self) { + return nil; + } + + self.width = width; + self.height = height; + + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + WKUserContentController *userContentController = [[WKUserContentController alloc] init]; + [userContentController addScriptMessageHandler:self name:@"onwatchResize"]; + [userContentController addScriptMessageHandler:self name:@"onwatchAction"]; + configuration.userContentController = userContentController; + + self.webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, width, height) + configuration:configuration]; + self.webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.webView.navigationDelegate = self; + self.webView.UIDelegate = self; + + self.containerView = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, width, height)]; + self.containerView.autoresizesSubviews = YES; + self.containerView.wantsLayer = YES; + self.containerView.layer.masksToBounds = YES; + self.containerView.layer.cornerRadius = 14.0; + self.containerView.layer.cornerCurve = @"continuous"; + self.containerView.layer.backgroundColor = [[NSColor colorWithRed:0.04 green:0.04 blue:0.04 alpha:1.0] CGColor]; + + self.webView.frame = self.containerView.bounds; + [self.webView setValue:@NO forKey:@"drawsBackground"]; + self.webView.wantsLayer = YES; + self.webView.layer.backgroundColor = [[NSColor colorWithRed:0.04 green:0.04 blue:0.04 alpha:1.0] CGColor]; + [self.containerView addSubview:self.webView]; + + NSWindowStyleMask styleMask = NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel; + self.panel = [[OnWatchBorderlessPanel alloc] initWithContentRect:NSMakeRect(0, 0, width, height) + styleMask:styleMask + backing:NSBackingStoreBuffered + defer:YES]; + self.panel.floatingPanel = YES; + self.panel.becomesKeyOnlyIfNeeded = YES; + self.panel.hidesOnDeactivate = NO; + self.panel.releasedWhenClosed = NO; + self.panel.opaque = NO; + self.panel.backgroundColor = [NSColor clearColor]; + self.panel.hasShadow = YES; + self.panel.level = NSStatusWindowLevel; + self.panel.collectionBehavior = NSWindowCollectionBehaviorMoveToActiveSpace | NSWindowCollectionBehaviorFullScreenAuxiliary; + self.panel.contentView = self.containerView; + + return self; +} + +- (void)dealloc { + [self stopTransientCloseMonitoring]; + [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"onwatchResize"]; + [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"onwatchAction"]; +} + +- (void)stopTransientCloseMonitoring { + if (self.globalMouseMonitor != nil) { + [NSEvent removeMonitor:self.globalMouseMonitor]; + self.globalMouseMonitor = nil; + } + if (self.localMouseMonitor != nil) { + [NSEvent removeMonitor:self.localMouseMonitor]; + self.localMouseMonitor = nil; + } + if (self.appDeactivationObserver != nil) { + [[NSNotificationCenter defaultCenter] removeObserver:self.appDeactivationObserver]; + self.appDeactivationObserver = nil; + } +} + +- (NSStatusItem *)statusItem { + id delegate = NSApp.delegate; + if (!delegate) { + return nil; + } + + @try { + id item = [delegate valueForKey:@"statusItem"]; + if ([item isKindOfClass:[NSStatusItem class]]) { + return (NSStatusItem *)item; + } + } @catch (NSException *exception) { + return nil; + } + + return nil; +} + +- (NSRect)statusButtonScreenRect { + NSStatusItem *statusItem = [self statusItem]; + NSStatusBarButton *button = statusItem.button; + if (!button || !button.window) { + return NSZeroRect; + } + NSRect buttonFrameInWindow = [button convertRect:button.bounds toView:nil]; + return [button.window convertRectToScreen:buttonFrameInWindow]; +} + +- (NSScreen *)screenForAnchorRect:(NSRect)anchorRect { + NSPoint anchorPoint = NSMakePoint(NSMidX(anchorRect), NSMidY(anchorRect)); + for (NSScreen *screen in NSScreen.screens) { + if (NSPointInRect(anchorPoint, screen.frame)) { + return screen; + } + } + return [NSScreen mainScreen]; +} + +- (BOOL)positionPanelAnchoredToStatusItem { + NSRect buttonRect = [self statusButtonScreenRect]; + if (NSIsEmptyRect(buttonRect)) { + return NO; + } + + NSScreen *screen = [self screenForAnchorRect:buttonRect]; + if (!screen) { + return NO; + } + + NSRect visibleFrame = screen.visibleFrame; + CGFloat width = self.width; + CGFloat height = self.height; + + CGFloat targetX = NSMidX(buttonRect) - (width * 0.5); + CGFloat minX = NSMinX(visibleFrame); + CGFloat maxX = NSMaxX(visibleFrame) - width; + if (maxX < minX) { + maxX = minX; + } + if (targetX < minX) { + targetX = minX; + } else if (targetX > maxX) { + targetX = maxX; + } + + CGFloat targetY = NSMinY(buttonRect) - height - 6.0; + CGFloat minY = NSMinY(visibleFrame); + CGFloat maxY = NSMaxY(visibleFrame) - height; + if (maxY < minY) { + maxY = minY; + } + if (targetY < minY) { + targetY = minY; + } else if (targetY > maxY) { + targetY = maxY; + } + + NSRect nextFrame = NSMakeRect(round(targetX), round(targetY), width, height); + [self.panel setFrame:nextFrame display:YES]; + return YES; +} + +- (NSPoint)screenPointForEvent:(NSEvent *)event { + NSPoint point = event.locationInWindow; + if (event.window) { + point = [event.window convertPointToScreen:point]; + } + return point; +} + +- (BOOL)containsScreenPoint:(NSPoint)screenPoint { + if ([self isShown] && NSPointInRect(screenPoint, self.panel.frame)) { + return YES; + } + + NSRect buttonRect = [self statusButtonScreenRect]; + if (!NSIsEmptyRect(buttonRect) && NSPointInRect(screenPoint, buttonRect)) { + return YES; + } + + return NO; +} + +- (void)closeIfInteractionIsOutside:(NSPoint)screenPoint { + if (![self isShown]) { + return; + } + if ([self containsScreenPoint:screenPoint]) { + return; + } + [self close]; +} + +- (void)startTransientCloseMonitoring { + [self stopTransientCloseMonitoring]; + + __weak typeof(self) weakSelf = self; + NSEventMask mask = NSEventMaskLeftMouseDown | NSEventMaskRightMouseDown | NSEventMaskOtherMouseDown; + + self.globalMouseMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:mask + handler:^(NSEvent *event) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + NSPoint screenPoint = event.locationInWindow; + dispatch_async(dispatch_get_main_queue(), ^{ + [strongSelf closeIfInteractionIsOutside:screenPoint]; + }); + }]; + + self.localMouseMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:mask + handler:^NSEvent *_Nullable(NSEvent *event) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return event; + } + NSPoint screenPoint = [strongSelf screenPointForEvent:event]; + [strongSelf closeIfInteractionIsOutside:screenPoint]; + return event; + }]; + + self.appDeactivationObserver = [[NSNotificationCenter defaultCenter] + addObserverForName:NSApplicationDidResignActiveNotification + object:NSApp + queue:[NSOperationQueue mainQueue] + usingBlock:^(__unused NSNotification *note) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + [strongSelf close]; + }]; +} + +- (void)applyHeight:(CGFloat)height { + CGFloat clampedHeight = MAX(140.0, MIN(600.0, height)); + CGFloat delta = clampedHeight - self.height; + if (delta < 0) { + delta = -delta; + } + + self.height = clampedHeight; + NSSize size = NSMakeSize(self.width, clampedHeight); + self.containerView.frame = NSMakeRect(0, 0, self.width, clampedHeight); + self.webView.frame = self.containerView.bounds; + [self.panel setContentSize:size]; + + if ([self isShown] && delta >= 0.5) { + [self positionPanelAnchoredToStatusItem]; + } +} + +- (BOOL)isLocalURL:(NSURL *)url { + if (!url) { + return NO; + } + if ([url.scheme isEqualToString:@"about"]) { + return YES; + } + NSString *host = url.host.lowercaseString; + return [host isEqualToString:@"localhost"] || [host isEqualToString:@"127.0.0.1"]; +} + +- (void)loadURLString:(NSString *)urlString { + if (!urlString.length) { + return; + } + + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + return; + } + + NSURLRequest *request = [NSURLRequest requestWithURL:url]; + [self.webView loadRequest:request]; +} + +- (BOOL)show { + if (!self.panel) { + return NO; + } + + [self applyHeight:self.height]; + if (![self positionPanelAnchoredToStatusItem]) { + return NO; + } + + if (![self isShown]) { + [self.panel makeKeyAndOrderFront:nil]; + } + [self startTransientCloseMonitoring]; + return YES; +} + +- (BOOL)toggle { + if ([self isShown]) { + [self close]; + return YES; + } + return [self show]; +} + +- (void)close { + [self stopTransientCloseMonitoring]; + if (![self isShown]) { + return; + } + [self.panel orderOut:nil]; +} + +- (BOOL)isShown { + return self.panel != nil && self.panel.visible; +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction + decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + NSURL *url = navigationAction.request.URL; + if ([self isLocalURL:url]) { + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + + if (url) { + [[NSWorkspace sharedWorkspace] openURL:url]; + } + decisionHandler(WKNavigationActionPolicyCancel); +} + +- (WKWebView *)webView:(WKWebView *)webView + createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration + forNavigationAction:(WKNavigationAction *)navigationAction + windowFeatures:(WKWindowFeatures *)windowFeatures { + NSURL *url = navigationAction.request.URL; + if (url) { + [[NSWorkspace sharedWorkspace] openURL:url]; + } + return nil; +} + +- (void)userContentController:(WKUserContentController *)userContentController + didReceiveScriptMessage:(WKScriptMessage *)message { + if ([message.name isEqualToString:@"onwatchResize"]) { + CGFloat nextHeight = self.height; + id body = message.body; + if ([body isKindOfClass:[NSNumber class]]) { + nextHeight = [body doubleValue]; + } else if ([body isKindOfClass:[NSDictionary class]]) { + id value = [(NSDictionary *)body objectForKey:@"height"]; + if ([value respondsToSelector:@selector(doubleValue)]) { + nextHeight = [value doubleValue]; + } + } + [self applyHeight:nextHeight]; + return; + } + + if (![message.name isEqualToString:@"onwatchAction"]) { + return; + } + + NSString *action = nil; + id body = message.body; + if ([body isKindOfClass:[NSString class]]) { + action = (NSString *)body; + } else if ([body isKindOfClass:[NSDictionary class]]) { + id value = [(NSDictionary *)body objectForKey:@"action"]; + if ([value isKindOfClass:[NSString class]]) { + action = (NSString *)value; + } + } + + if (![action isKindOfClass:[NSString class]]) { + return; + } + + if ([action isEqualToString:@"close"]) { + [self close]; + return; + } + + if ([action isEqualToString:@"open_dashboard"]) { + NSURL *url = [NSURL URLWithString:@"http://localhost:9211"]; + if (url) { + [[NSWorkspace sharedWorkspace] openURL:url]; + [self close]; + } + } +} + +@end + +static OnWatchPopoverController *onwatch_popover_controller(void *handle) { + if (!handle) { + return nil; + } + return (__bridge OnWatchPopoverController *)handle; +} + +void *onwatch_popover_create(int width, int height) { + __block void *handle = nil; + onwatch_run_on_main_sync(^{ + [NSApplication sharedApplication]; + OnWatchPopoverController *controller = + [[OnWatchPopoverController alloc] initWithWidth:width height:height]; + handle = (__bridge_retained void *)controller; + }); + return handle; +} + +void onwatch_popover_destroy(void *handle) { + if (!handle) { + return; + } + + onwatch_run_on_main_sync(^{ + OnWatchPopoverController *controller = (__bridge_transfer OnWatchPopoverController *)handle; + [controller close]; + }); +} + +bool onwatch_popover_show(void *handle) { + __block BOOL shown = NO; + onwatch_run_on_main_sync(^{ + shown = [onwatch_popover_controller(handle) show]; + }); + return shown; +} + +bool onwatch_popover_toggle(void *handle) { + __block BOOL toggled = NO; + onwatch_run_on_main_sync(^{ + toggled = [onwatch_popover_controller(handle) toggle]; + }); + return toggled; +} + +void onwatch_popover_load_url(void *handle, const char *url) { + if (!handle || !url) { + return; + } + + onwatch_run_on_main_sync(^{ + NSString *urlString = [[NSString alloc] initWithUTF8String:url]; + [onwatch_popover_controller(handle) loadURLString:urlString]; + }); +} + +void onwatch_popover_close(void *handle) { + if (!handle) { + return; + } + + onwatch_run_on_main_sync(^{ + [onwatch_popover_controller(handle) close]; + }); +} + +bool onwatch_popover_is_shown(void *handle) { + __block BOOL shown = NO; + onwatch_run_on_main_sync(^{ + shown = [onwatch_popover_controller(handle) isShown]; + }); + return shown; +} diff --git a/internal/menubar/runtime_state.go b/internal/menubar/runtime_state.go new file mode 100644 index 0000000..4dde211 --- /dev/null +++ b/internal/menubar/runtime_state.go @@ -0,0 +1,75 @@ +//go:build menubar && darwin + +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)) +} + +const refreshCompanionSignal = syscall.SIGUSR1 + +func TriggerRefresh(testMode bool) error { + pidPath := companionPIDPath(testMode) + pid := readPID(pidPath) + if pid <= 0 { + return nil + } + proc, err := os.FindProcess(pid) + if err != nil || proc.Signal(refreshCompanionSignal) != nil { + _ = os.Remove(pidPath) + return nil + } + return nil +} diff --git a/internal/menubar/runtime_state_darwin_test.go b/internal/menubar/runtime_state_darwin_test.go new file mode 100644 index 0000000..47e5f01 --- /dev/null +++ b/internal/menubar/runtime_state_darwin_test.go @@ -0,0 +1,14 @@ +//go:build menubar && darwin + +package menubar + +import ( + "syscall" + "testing" +) + +func TestRefreshCompanionSignalUsesSIGUSR1(t *testing.T) { + if refreshCompanionSignal != syscall.SIGUSR1 { + t.Fatalf("expected refresh signal %v, got %v", syscall.SIGUSR1, refreshCompanionSignal) + } +} diff --git a/internal/menubar/tray_display.go b/internal/menubar/tray_display.go new file mode 100644 index 0000000..a5c3d94 --- /dev/null +++ b/internal/menubar/tray_display.go @@ -0,0 +1,83 @@ +package menubar + +import ( + "fmt" + "math" +) + +// TrayTitle formats the compact metric shown next to the macOS tray icon. +func TrayTitle(snapshot *Snapshot, settings *Settings) string { + if snapshot == nil { + return "" + } + normalized := DefaultSettings() + if settings != nil { + normalized = settings.Normalize() + } + switch normalized.StatusDisplay.Mode { + case StatusDisplayIconOnly: + return "" + case StatusDisplayCriticalCount: + count := snapshot.Aggregate.WarningCount + snapshot.Aggregate.CriticalCount + return fmt.Sprintf("%d ⚠", count) + case StatusDisplayMultiProvider: + parts := multiProviderMetrics(snapshot, normalized.StatusDisplay) + if len(parts) == 0 { + return "" + } + return joinTrayParts(parts) + default: + return "" + } +} + +func multiProviderMetrics(snapshot *Snapshot, display StatusDisplay) []string { + if snapshot == nil || len(display.SelectedQuotas) == 0 { + return nil + } + parts := make([]string, 0, len(display.SelectedQuotas)) + for _, selection := range display.SelectedQuotas { + provider, ok := providerByID(snapshot, selection.ProviderID) + if !ok { + continue + } + if selection.QuotaKey != "" { + matched := false + for _, quota := range provider.Quotas { + if quota.Key == selection.QuotaKey { + parts = append(parts, fmt.Sprintf("%d%%", int(math.Round(quota.Percent)))) + matched = true + break + } + } + if matched { + continue + } + } + parts = append(parts, fmt.Sprintf("%d%%", int(math.Round(provider.HighestPercent)))) + } + return parts +} + +func providerByID(snapshot *Snapshot, providerID string) (ProviderCard, bool) { + if snapshot == nil || providerID == "" { + return ProviderCard{}, false + } + for _, provider := range snapshot.Providers { + if provider.ID == providerID { + return provider, true + } + } + return ProviderCard{}, false +} + +func joinTrayParts(parts []string) string { + if len(parts) == 0 { + return "" + } + out := parts[0] + for i := 1; i < len(parts); i++ { + out += " │ " + parts[i] + } + return out +} diff --git a/internal/menubar/tray_display_test.go b/internal/menubar/tray_display_test.go new file mode 100644 index 0000000..8416604 --- /dev/null +++ b/internal/menubar/tray_display_test.go @@ -0,0 +1,84 @@ +package menubar + +import "testing" + +func TestTrayTitleDefaultIsEmptyUntilProviderSelectionIsResolved(t *testing.T) { + snapshot := &Snapshot{ + Aggregate: Aggregate{ + ProviderCount: 2, + HighestPercent: 84, + }, + Providers: []ProviderCard{ + {ID: "anthropic", Label: "Anthropic", HighestPercent: 84, Quotas: []QuotaMeter{{Key: "seven_day", Label: "Weekly All-Model", Percent: 84}}}, + {ID: "copilot", Label: "Copilot", HighestPercent: 45, Quotas: []QuotaMeter{{Key: "premium_interactions", Label: "Premium Requests", Percent: 45}}}, + }, + } + + if got := TrayTitle(snapshot, DefaultSettings()); got != "" { + t.Fatalf("TrayTitle() = %q, want empty string", got) + } +} + +func TestTrayTitleProviderSpecific(t *testing.T) { + snapshot := &Snapshot{ + Aggregate: Aggregate{ProviderCount: 2, HighestPercent: 84}, + Providers: []ProviderCard{ + { + ID: "anthropic", + Label: "Anthropic", + HighestPercent: 84, + Quotas: []QuotaMeter{ + {Key: "five_hour", Label: "5-Hour Limit", Percent: 84}, + }, + }, + }, + } + settings := DefaultSettings() + settings.StatusDisplay = StatusDisplay{ + Mode: StatusDisplayMultiProvider, + SelectedQuotas: []StatusDisplaySelection{ + {ProviderID: "anthropic", QuotaKey: "five_hour"}, + }, + } + + if got := TrayTitle(snapshot, settings); got != "84%" { + t.Fatalf("TrayTitle(multi_provider) = %q, want %q", got, "84%") + } +} + +func TestTrayTitleCriticalCountAndIconOnly(t *testing.T) { + snapshot := &Snapshot{ + Aggregate: Aggregate{ + ProviderCount: 2, + HighestPercent: 84, + WarningCount: 1, + CriticalCount: 1, + }, + Providers: []ProviderCard{ + {ID: "anthropic", Label: "Anthropic", HighestPercent: 84, Quotas: []QuotaMeter{{Percent: 84}, {Percent: 45}}}, + {ID: "copilot", Label: "Copilot", HighestPercent: 12, Quotas: []QuotaMeter{{Percent: 12}}}, + }, + } + + settings := DefaultSettings() + settings.StatusDisplay = StatusDisplay{Mode: StatusDisplayCriticalCount} + if got := TrayTitle(snapshot, settings); got != "2 ⚠" { + t.Fatalf("TrayTitle(critical_count) = %q, want %q", got, "2 ⚠") + } + + settings.StatusDisplay = StatusDisplay{ + Mode: StatusDisplayMultiProvider, + SelectedQuotas: []StatusDisplaySelection{ + {ProviderID: "anthropic"}, + {ProviderID: "copilot"}, + }, + } + if got := TrayTitle(snapshot, settings); got != "84% │ 12%" { + t.Fatalf("TrayTitle(multi_provider multiple) = %q, want %q", got, "84% │ 12%") + } + + settings.StatusDisplay = StatusDisplay{Mode: StatusDisplayIconOnly} + if got := TrayTitle(snapshot, settings); got != "" { + t.Fatalf("TrayTitle(icon_only) = %q, want empty string", got) + } +} 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/menubar/webview_darwin.go b/internal/menubar/webview_darwin.go new file mode 100644 index 0000000..65dc2ad --- /dev/null +++ b/internal/menubar/webview_darwin.go @@ -0,0 +1,95 @@ +//go:build menubar && darwin && cgo + +package menubar + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc +#cgo LDFLAGS: -framework Cocoa -framework WebKit + +#include +#include + +void* onwatch_popover_create(int width, int height); +void onwatch_popover_destroy(void* handle); +bool onwatch_popover_show(void* handle); +bool onwatch_popover_toggle(void* handle); +void onwatch_popover_load_url(void* handle, const char* url); +void onwatch_popover_close(void* handle); +bool onwatch_popover_is_shown(void* handle); +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +type webViewPopover struct { + handle unsafe.Pointer +} + +func cBool(value C.bool) bool { + return bool(value) +} + +func newMenubarPopover(width, height int) (menubarPopover, error) { + handle := unsafe.Pointer(C.onwatch_popover_create(C.int(width), C.int(height))) + if handle == nil { + return nil, errNativePopoverUnavailable + } + return &webViewPopover{handle: handle}, nil +} + +func (p *webViewPopover) ShowURL(url string) error { + if err := p.loadURL(url); err != nil { + return err + } + if !cBool(C.onwatch_popover_show(p.handle)) { + return fmt.Errorf("%w: status item unavailable", errNativePopoverUnavailable) + } + return nil +} + +func (p *webViewPopover) ToggleURL(url string) error { + if !p.isShown() { + if err := p.loadURL(url); err != nil { + return err + } + } + if !cBool(C.onwatch_popover_toggle(p.handle)) { + return fmt.Errorf("%w: status item unavailable", errNativePopoverUnavailable) + } + return nil +} + +func (p *webViewPopover) Close() { + if p == nil || p.handle == nil { + return + } + C.onwatch_popover_close(p.handle) +} + +func (p *webViewPopover) Destroy() { + if p == nil || p.handle == nil { + return + } + C.onwatch_popover_destroy(p.handle) + p.handle = nil +} + +func (p *webViewPopover) loadURL(url string) error { + if p == nil || p.handle == nil { + return errNativePopoverUnavailable + } + rawURL := C.CString(url) + defer C.free(unsafe.Pointer(rawURL)) + C.onwatch_popover_load_url(p.handle, rawURL) + return nil +} + +func (p *webViewPopover) isShown() bool { + if p == nil || p.handle == nil { + return false + } + return cBool(C.onwatch_popover_is_shown(p.handle)) +} diff --git a/internal/menubar/webview_darwin_test.go b/internal/menubar/webview_darwin_test.go new file mode 100644 index 0000000..663b676 --- /dev/null +++ b/internal/menubar/webview_darwin_test.go @@ -0,0 +1,65 @@ +//go:build menubar && darwin && cgo + +package menubar + +import ( + "os" + "runtime" + "testing" +) + +var ( + mainThreadTasks = make(chan func()) + testExitCode = make(chan int, 1) +) + +func TestMain(m *testing.M) { + runtime.LockOSThread() + + go func() { + testExitCode <- m.Run() + }() + + for { + select { + case fn := <-mainThreadTasks: + fn() + case code := <-testExitCode: + os.Exit(code) + } + } +} + +func runOnMainThread(t *testing.T, fn func()) { + t.Helper() + + done := make(chan struct{}) + mainThreadTasks <- func() { + defer close(done) + fn() + } + <-done +} + +func TestNewMenubarPopoverLifecycle(t *testing.T) { + var ( + popover menubarPopover + err error + ) + + runOnMainThread(t, func() { + popover, err = newMenubarPopover(320, 240) + }) + if err != nil { + t.Fatalf("newMenubarPopover returned error: %v", err) + } + if popover == nil { + t.Fatal("expected popover instance") + } + + runOnMainThread(t, func() { + popover.Close() + popover.Destroy() + popover.Destroy() + }) +} diff --git a/internal/menubar/webview_stub_darwin.go b/internal/menubar/webview_stub_darwin.go new file mode 100644 index 0000000..f7fff7a --- /dev/null +++ b/internal/menubar/webview_stub_darwin.go @@ -0,0 +1,7 @@ +//go:build menubar && darwin && !cgo + +package menubar + +func newMenubarPopover(width, height int) (menubarPopover, error) { + return nil, errNativePopoverUnavailable +} diff --git a/internal/store/menubar_test.go b/internal/store/menubar_test.go new file mode 100644 index 0000000..9d5539b --- /dev/null +++ b/internal/store/menubar_test.go @@ -0,0 +1,101 @@ +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"}, + VisibleProviders: []string{"synthetic"}, + WarningPercent: 60, + CriticalPercent: 85, + StatusDisplay: menubar.StatusDisplay{ + Mode: menubar.StatusDisplayMultiProvider, + SelectedQuotas: []menubar.StatusDisplaySelection{ + {ProviderID: "synthetic", QuotaKey: "search"}, + {ProviderID: "anthropic", QuotaKey: "five_hour"}, + }, + }, + Theme: menubar.ThemeDark, + } + 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) + } + if len(got.VisibleProviders) != 1 || got.VisibleProviders[0] != "synthetic" { + t.Fatalf("unexpected visible providers: %#v", got.VisibleProviders) + } + if got.StatusDisplay.Mode != menubar.StatusDisplayMultiProvider { + t.Fatalf("unexpected status display: %#v", got.StatusDisplay) + } + if len(got.StatusDisplay.SelectedQuotas) != 2 { + t.Fatalf("expected two tray selections, got %#v", got.StatusDisplay.SelectedQuotas) + } + if got.StatusDisplay.SelectedQuotas[0].ProviderID != "synthetic" || got.StatusDisplay.SelectedQuotas[0].QuotaKey != "search" { + t.Fatalf("unexpected first tray selection: %#v", got.StatusDisplay.SelectedQuotas[0]) + } + if got.Theme != menubar.ThemeDark { + t.Fatalf("expected dark theme, got %s", got.Theme) + } +} + +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..6743881 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" @@ -357,6 +358,19 @@ func (h *Handler) SetRateLimiter(l *LoginRateLimiter) { h.rateLimiter = l } +func (h *Handler) triggerMenubarRefresh() { + if h == nil { + return + } + testMode := false + if h.config != nil { + testMode = h.config.TestMode + } + if err := menubar.TriggerRefresh(testMode); err != nil { + h.logger.Debug("menubar refresh trigger failed", "error", err) + } +} + // SettingsPage renders the settings page. func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ @@ -3417,8 +3431,18 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { } response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + orderedQuotas := make([]api.AnthropicQuota, len(latest.Quotas)) + copy(orderedQuotas, latest.Quotas) + sort.SliceStable(orderedQuotas, func(i, j int) bool { + left := anthropicQuotaDisplayOrder(orderedQuotas[i].Name) + right := anthropicQuotaDisplayOrder(orderedQuotas[j].Name) + if left != right { + return left < right + } + return orderedQuotas[i].Name < orderedQuotas[j].Name + }) var quotas []map[string]interface{} - for _, q := range latest.Quotas { + for _, q := range orderedQuotas { qMap := map[string]interface{}{ "name": q.Name, "displayName": api.AnthropicDisplayName(q.Name), @@ -3458,6 +3482,23 @@ func anthropicUtilStatus(util float64) string { } } +func anthropicQuotaDisplayOrder(name string) int { + switch name { + case "five_hour": + return 0 + case "seven_day": + return 1 + case "seven_day_sonnet": + return 2 + case "monthly_limit": + return 3 + case "extra_usage": + return 4 + default: + return 100 + } +} + // historyAnthropic returns Anthropic usage history. func (h *Handler) historyAnthropic(w http.ResponseWriter, r *http.Request) { if h.store == nil { @@ -4135,6 +4176,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 +4190,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 +4203,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 +4492,24 @@ 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 + } + h.triggerMenubarRefresh() + 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..1d6d43f --- /dev/null +++ b/internal/web/menubar.go @@ -0,0 +1,830 @@ +package web + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/menubar" +) + +type menubarQuotaOption struct { + Key string `json:"key"` + Label string `json:"label"` +} + +type menubarProviderOption struct { + ID string `json:"id"` + BaseProvider string `json:"base_provider"` + Label string `json:"label"` + Subtitle string `json:"subtitle,omitempty"` + Visible bool `json:"visible"` + Quotas []menubarQuotaOption `json:"quotas"` +} + +// 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, + "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) +} + +// MenubarPage renders the localhost-only browser UI used by the tray companion. +func (h *Handler) MenubarPage(w http.ResponseWriter, r *http.Request) { + if !isLoopbackRequest(r) { + http.NotFound(w, r) + return + } + + settings, _ := h.menubarSettings() + view := normalizeMenubarView(r.URL.Query().Get("view"), settings.DefaultView) + html, err := h.renderMenubarHTML(view, settings) + if err != nil { + h.logger.Error("failed to render menubar page", "error", err) + respondError(w, http.StatusInternalServerError, "failed to render menubar 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)) +} + +// 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 := h.renderMenubarHTML(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)) +} + +// MenubarPreferences returns or updates tray-specific settings for the local menubar surface. +func (h *Handler) MenubarPreferences(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + settings, err := h.menubarSettings() + if err != nil { + h.logger.Error("failed to load menubar preferences", "error", err) + respondError(w, http.StatusInternalServerError, "failed to load menubar preferences") + return + } + providers, err := h.buildMenubarProviderOptions(settings) + if err != nil { + h.logger.Error("failed to build menubar provider options", "error", err) + respondError(w, http.StatusInternalServerError, "failed to load menubar preferences") + return + } + respondJSON(w, http.StatusOK, menubarPreferencesResponse(settings, providers)) + case http.MethodPut: + if h.store == nil { + respondError(w, http.StatusInternalServerError, "store not available") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + var settings menubar.Settings + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + if err.Error() == "http: request body too large" { + respondError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } + respondError(w, http.StatusBadRequest, "invalid JSON") + 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 preferences", "error", err) + respondError(w, http.StatusInternalServerError, "failed to save menubar preferences") + return + } + h.triggerMenubarRefresh() + providers, err := h.buildMenubarProviderOptions(normalized) + if err != nil { + h.logger.Error("failed to rebuild menubar provider options", "error", err) + respondError(w, http.StatusInternalServerError, "failed to save menubar preferences") + return + } + respondJSON(w, http.StatusOK, menubarPreferencesResponse(normalized, providers)) + default: + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (h *Handler) MenubarRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !isLoopbackRequest(r) { + http.NotFound(w, r) + return + } + if _, err := h.BuildMenubarSnapshot(); err != nil { + h.logger.Debug("menubar refresh snapshot rebuild failed", "error", err) + } + respondJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func isLoopbackRequest(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + clientIP := net.ParseIP(host) + return clientIP != nil && clientIP.IsLoopback() +} + +func isLocalMenubarPublicPath(path string) bool { + return path == "/menubar" || path == "/api/menubar/summary" || path == "/api/menubar/preferences" || path == "/api/menubar/refresh" +} + +// BuildMenubarSnapshot constructs the shared menubar UI contract. +func (h *Handler) BuildMenubarSnapshot() (*menubar.Snapshot, error) { + settings, err := h.menubarSettings() + if err != nil { + return nil, err + } + + providers, latest := h.buildMenubarProviders(settings, false) + aggregate := buildAggregate(providers) + return &menubar.Snapshot{ + GeneratedAt: time.Now().UTC(), + UpdatedAgo: timeAgo(latest), + Aggregate: aggregate, + Providers: providers, + }, nil +} + +func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidden bool) ([]menubar.ProviderCard, time.Time) { + normalized := settings.Normalize() + + 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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.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, normalized.WarningPercent, normalized.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } + + sortProviderCards(providers, normalized.ProvidersOrder) + if !includeHidden { + providers = filterMenubarProviders(providers, normalized.VisibleProviders) + } + return providers, latest +} + +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 (h *Handler) renderMenubarHTML(view menubar.ViewType, settings *menubar.Settings) (string, error) { + normalized := settings.Normalize() + normalized.DefaultView = view + bootstrap, err := json.Marshal(normalized) + if err != nil { + return "", err + } + page, err := staticFS.ReadFile("static/menubar.html") + if err != nil { + return "", err + } + html := strings.Replace(string(page), "__ONWATCH_MENUBAR_BOOTSTRAP__", string(bootstrap), 1) + version := strings.TrimSpace(h.version) + if version == "" { + version = "dev" + } + return strings.Replace(html, "__ONWATCH_MENUBAR_VERSION__", version, 1), nil +} + +func (h *Handler) buildMenubarProviderOptions(settings *menubar.Settings) ([]menubarProviderOption, error) { + normalized := settings.Normalize() + providers, _ := h.buildMenubarProviders(normalized, true) + visible := make(map[string]struct{}, len(normalized.VisibleProviders)) + for _, id := range normalized.VisibleProviders { + visible[id] = struct{}{} + } + options := make([]menubarProviderOption, 0, len(providers)) + for _, provider := range providers { + quotaOptions := make([]menubarQuotaOption, 0, len(provider.Quotas)) + for _, quota := range provider.Quotas { + quotaOptions = append(quotaOptions, menubarQuotaOption{ + Key: quota.Key, + Label: quota.Label, + }) + } + _, isVisible := visible[provider.ID] + if len(visible) == 0 { + isVisible = true + } + options = append(options, menubarProviderOption{ + ID: provider.ID, + BaseProvider: provider.BaseProvider, + Label: provider.Label, + Subtitle: provider.Subtitle, + Visible: isVisible, + Quotas: quotaOptions, + }) + } + return options, nil +} + +func menubarPreferencesResponse(settings *menubar.Settings, providers []menubarProviderOption) map[string]interface{} { + normalized := settings.Normalize() + return map[string]interface{}{ + "enabled": normalized.Enabled, + "default_view": normalized.DefaultView, + "refresh_seconds": normalized.RefreshSeconds, + "providers_order": normalized.ProvidersOrder, + "visible_providers": normalized.VisibleProviders, + "warning_percent": normalized.WarningPercent, + "critical_percent": normalized.CriticalPercent, + "status_display": resolvedStatusDisplay(normalized, providers), + "theme": normalized.Theme, + "providers": providers, + } +} + +func resolvedStatusDisplay(settings *menubar.Settings, providers []menubarProviderOption) menubar.StatusDisplay { + normalized := settings.Normalize() + display := normalized.StatusDisplay + switch display.Mode { + case menubar.StatusDisplayCriticalCount, menubar.StatusDisplayIconOnly: + return display + case menubar.StatusDisplayMultiProvider: + selections := resolvedStatusSelections(display.SelectedQuotas, providers) + if len(selections) == 0 { + return menubar.StatusDisplay{Mode: menubar.StatusDisplayIconOnly} + } + return menubar.StatusDisplay{ + Mode: menubar.StatusDisplayMultiProvider, + SelectedQuotas: selections, + } + default: + return menubar.StatusDisplay{Mode: menubar.StatusDisplayIconOnly} + } +} + +func resolvedStatusSelections(selections []menubar.StatusDisplaySelection, providers []menubarProviderOption) []menubar.StatusDisplaySelection { + pool := preferredStatusProviders(providers) + if len(pool) == 0 { + pool = providers + } + if len(pool) == 0 { + return []menubar.StatusDisplaySelection{} + } + allowed := make(map[string]menubarProviderOption, len(pool)) + for _, provider := range pool { + allowed[provider.ID] = provider + } + out := make([]menubar.StatusDisplaySelection, 0, 3) + seen := make(map[string]struct{}, 3) + appendSelection := func(provider menubarProviderOption, quotaKey string) { + resolvedQuota := providerQuotaKey(provider, quotaKey) + if resolvedQuota == "" { + return + } + key := provider.ID + "\x00" + resolvedQuota + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + out = append(out, menubar.StatusDisplaySelection{ + ProviderID: provider.ID, + QuotaKey: resolvedQuota, + }) + } + for _, selection := range selections { + provider, ok := allowed[selection.ProviderID] + if !ok { + continue + } + appendSelection(provider, selection.QuotaKey) + if len(out) == 3 { + return out + } + } + if len(out) > 0 { + return out + } + for _, provider := range pool { + appendSelection(provider, "") + if len(out) == 3 { + break + } + } + return out +} + +func preferredStatusProviders(providers []menubarProviderOption) []menubarProviderOption { + visible := make([]menubarProviderOption, 0, len(providers)) + for _, provider := range providers { + if provider.Visible { + visible = append(visible, provider) + } + } + if len(visible) > 0 { + return visible + } + return providers +} + +func providerOptionByID(providerID string, providers []menubarProviderOption) *menubarProviderOption { + for i := range providers { + if providers[i].ID == providerID { + return &providers[i] + } + } + return nil +} + +func providerHasQuota(provider menubarProviderOption, quotaKey string) bool { + for _, quota := range provider.Quotas { + if quota.Key == quotaKey { + return true + } + } + return false +} + +func firstProviderQuotaKey(provider menubarProviderOption) string { + if len(provider.Quotas) == 0 { + return "" + } + return provider.Quotas[0].Key +} + +func providerQuotaKey(provider menubarProviderOption, quotaKey string) string { + if quotaKey != "" && providerHasQuota(provider, quotaKey) { + return quotaKey + } + return firstProviderQuotaKey(provider) +} + +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"), + }) + } + 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 filterMenubarProviders(cards []menubar.ProviderCard, visible []string) []menubar.ProviderCard { + if len(cards) == 0 || len(visible) == 0 { + return cards + } + allowed := make(map[string]struct{}, len(visible)) + for _, id := range visible { + allowed[id] = struct{}{} + } + filtered := make([]menubar.ProviderCard, 0, len(cards)) + for _, card := range cards { + if _, ok := allowed[card.ID]; ok { + filtered = append(filtered, card) + } + } + return filtered +} + +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 normalizeMenubarView(raw string, fallback menubar.ViewType) menubar.ViewType { + value := menubar.ViewType(strings.ToLower(strings.TrimSpace(raw))) + switch value { + case menubar.ViewMinimal: + return menubar.ViewMinimal + case menubar.ViewDetailed: + return menubar.ViewDetailed + case menubar.ViewStandard: + return menubar.ViewStandard + } + if fallback != "" { + switch fallback { + case menubar.ViewMinimal: + return menubar.ViewMinimal + case menubar.ViewDetailed: + return menubar.ViewDetailed + } + return menubar.ViewStandard + } + 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..cdc2f1a --- /dev/null +++ b/internal/web/menubar_test.go @@ -0,0 +1,615 @@ +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") + } +} + +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","anthropic"],"visible_providers":["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) + } + if len(got.ProvidersOrder) != 2 || got.ProvidersOrder[0] != "synthetic" || got.ProvidersOrder[1] != "anthropic" { + t.Fatalf("unexpected providers order: %#v", got.ProvidersOrder) + } + if len(got.VisibleProviders) != 1 || got.VisibleProviders[0] != "synthetic" { + t.Fatalf("unexpected visible providers: %#v", got.VisibleProviders) + } +} + +func TestMenubarTestEndpointRequiresTestMode(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/test?view=standard", nil) + rr := httptest.NewRecorder() + + h.MenubarTest(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestMenubarTestEndpointPreservesMinimalView(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") + } +} + +func TestMenubarPreferencesRoundTrip(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + getReq := httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil) + getRR := httptest.NewRecorder() + + h.MenubarPreferences(getRR, getReq) + + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", getRR.Code, getRR.Body.String()) + } + + var initial struct { + DefaultView menubar.ViewType `json:"default_view"` + RefreshSeconds int `json:"refresh_seconds"` + VisibleProviders []string `json:"visible_providers"` + StatusDisplay menubar.StatusDisplay `json:"status_display"` + Theme menubar.ThemeMode `json:"theme"` + Providers []map[string]any `json:"providers"` + } + if err := json.Unmarshal(getRR.Body.Bytes(), &initial); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if initial.DefaultView != menubar.ViewStandard { + t.Fatalf("expected standard view, got %s", initial.DefaultView) + } + if initial.StatusDisplay.Mode != menubar.StatusDisplayMultiProvider { + t.Fatalf("expected multi_provider status display, got %s", initial.StatusDisplay.Mode) + } + if len(initial.StatusDisplay.SelectedQuotas) == 0 { + t.Fatal("expected multi-provider status display to resolve at least one quota") + } + if len(initial.Providers) == 0 { + t.Fatal("expected provider options in preferences response") + } + if initial.Theme != menubar.ThemeSystem { + t.Fatalf("expected system theme by default, got %s", initial.Theme) + } + + body := strings.NewReader(`{"default_view":"minimal","refresh_seconds":120,"visible_providers":["synthetic"],"status_display":{"mode":"multi_provider","selected_quotas":[{"provider_id":"synthetic","quota_key":"search"}]},"theme":"dark"}`) + putReq := httptest.NewRequest(http.MethodPut, "/api/menubar/preferences", body) + putReq.Header.Set("Content-Type", "application/json") + putReq.Header.Set("X-Requested-With", "XMLHttpRequest") + putReq.RemoteAddr = "127.0.0.1:12345" + putRR := httptest.NewRecorder() + + h.MenubarPreferences(putRR, putReq) + + if putRR.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", putRR.Code, putRR.Body.String()) + } + + got, err := s.GetMenubarSettings() + if err != nil { + t.Fatalf("GetMenubarSettings returned error: %v", err) + } + if got.DefaultView != menubar.ViewMinimal { + t.Fatalf("expected minimal view, got %s", got.DefaultView) + } + if got.RefreshSeconds != 120 { + t.Fatalf("expected refresh 120, got %d", got.RefreshSeconds) + } + if len(got.VisibleProviders) != 1 || got.VisibleProviders[0] != "synthetic" { + t.Fatalf("unexpected visible providers: %#v", got.VisibleProviders) + } + if got.StatusDisplay.Mode != menubar.StatusDisplayMultiProvider { + t.Fatalf("expected multi_provider status display, got %s", got.StatusDisplay.Mode) + } + if len(got.StatusDisplay.SelectedQuotas) != 1 || got.StatusDisplay.SelectedQuotas[0].ProviderID != "synthetic" || got.StatusDisplay.SelectedQuotas[0].QuotaKey != "search" { + t.Fatalf("unexpected status display selections: %#v", got.StatusDisplay.SelectedQuotas) + } + if got.Theme != menubar.ThemeDark { + t.Fatalf("expected dark theme, got %s", got.Theme) + } +} + +func TestMenubarRefreshRequiresPost(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/refresh", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + + h.MenubarRefresh(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rr.Code) + } +} + +func TestMenubarRefreshRequiresLoopback(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/menubar/refresh", nil) + req.RemoteAddr = "192.168.1.50:12345" + rr := httptest.NewRecorder() + + h.MenubarRefresh(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestMenubarRefreshLoopbackReturnsOK(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/menubar/refresh", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + + h.MenubarRefresh(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + + var response map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if response["status"] != "ok" { + t.Fatalf("expected status ok, got %#v", response) + } +} + +func TestMenubarPageRequiresLoopback(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/menubar", nil) + req.RemoteAddr = "192.168.1.50:12345" + rr := httptest.NewRecorder() + + h.MenubarPage(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestMenubarPageRendersLoopbackBootstrap(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/menubar?view=detailed", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + + h.MenubarPage(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `"default_view":"detailed"`) { + t.Fatalf("expected detailed bootstrap, got body: %s", body) + } + if !strings.Contains(body, `id="settings-panel"`) { + t.Fatalf("expected compact menubar shell, got body: %s", body) + } + if !strings.Contains(body, `function sendNativeAction(action)`) { + t.Fatalf("expected native action bridge helper, got body: %s", body) + } + if !strings.Contains(body, `if (sendNativeAction("close"))`) { + t.Fatalf("expected native close action bridge usage, got body: %s", body) + } + if !strings.Contains(body, `if (sendNativeAction("open_dashboard"))`) { + t.Fatalf("expected native dashboard action bridge usage, got body: %s", body) + } +} + +func TestMenubarPageUsesPersistedDefaultViewWithoutQuery(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + if err := s.SetMenubarSettings(&menubar.Settings{ + Enabled: true, + DefaultView: menubar.ViewDetailed, + RefreshSeconds: 60, + WarningPercent: 70, + CriticalPercent: 90, + }); err != nil { + t.Fatalf("SetMenubarSettings returned error: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/menubar", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + + h.MenubarPage(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), `"default_view":"detailed"`) { + t.Fatalf("expected persisted detailed default view in bootstrap, got body: %s", rr.Body.String()) + } +} + +func TestMenubarPagePreservesMinimalQueryView(t *testing.T) { + h, s := newMenubarTestHandler(t) + defer s.Close() + + req := httptest.NewRequest(http.MethodGet, "/menubar?view=minimal", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + + h.MenubarPage(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 bootstrap when minimal is requested, got body: %s", rr.Body.String()) + } +} + +func TestSessionAuthMiddleware_AllowsLoopbackMenubarPaths(t *testing.T) { + sessions := NewSessionStore("admin", legacyHashPassword("secret"), nil) + + handler := SessionAuthMiddleware(sessions)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/menubar", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + apiReq := httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil) + apiReq.RemoteAddr = "[::1]:12345" + apiRR := httptest.NewRecorder() + handler.ServeHTTP(apiRR, apiReq) + + if apiRR.Code != http.StatusOK { + t.Fatalf("expected 200 for loopback api path, got %d", apiRR.Code) + } + + prefsReq := httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil) + prefsReq.RemoteAddr = "127.0.0.1:12345" + prefsRR := httptest.NewRecorder() + handler.ServeHTTP(prefsRR, prefsReq) + + if prefsRR.Code != http.StatusOK { + t.Fatalf("expected 200 for loopback preferences path, got %d", prefsRR.Code) + } + + refreshReq := httptest.NewRequest(http.MethodPost, "/api/menubar/refresh", nil) + refreshReq.RemoteAddr = "127.0.0.1:12345" + refreshReq.Header.Set("X-Requested-With", "XMLHttpRequest") + refreshRR := httptest.NewRecorder() + handler.ServeHTTP(refreshRR, refreshReq) + + if refreshRR.Code != http.StatusOK { + t.Fatalf("expected 200 for loopback refresh path, got %d", refreshRR.Code) + } +} + +func TestSessionAuthMiddleware_DoesNotBypassRemoteMenubarPaths(t *testing.T) { + sessions := NewSessionStore("admin", legacyHashPassword("secret"), nil) + + handler := SessionAuthMiddleware(sessions)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/menubar", nil) + req.RemoteAddr = "192.168.1.50:12345" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect to login for remote request, got %d", rr.Code) + } + + apiReq := httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil) + apiReq.RemoteAddr = "192.168.1.50:12345" + apiRR := httptest.NewRecorder() + handler.ServeHTTP(apiRR, apiReq) + + if apiRR.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for remote api request, got %d", apiRR.Code) + } + + prefsReq := httptest.NewRequest(http.MethodGet, "/api/menubar/preferences", nil) + prefsReq.RemoteAddr = "192.168.1.50:12345" + prefsRR := httptest.NewRecorder() + handler.ServeHTTP(prefsRR, prefsReq) + + if prefsRR.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for remote preferences api request, got %d", prefsRR.Code) + } + + refreshReq := httptest.NewRequest(http.MethodPost, "/api/menubar/refresh", nil) + refreshReq.RemoteAddr = "192.168.1.50:12345" + refreshReq.Header.Set("X-Requested-With", "XMLHttpRequest") + refreshRR := httptest.NewRecorder() + handler.ServeHTTP(refreshRR, refreshReq) + + if refreshRR.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for remote refresh api request, got %d", refreshRR.Code) + } +} + +func TestBuildMenubarSnapshotPreservesAnthropicQuotaOrder(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New returned error: %v", err) + } + defer s.Close() + + capturedAt := time.Now().UTC().Truncate(time.Second) + reset := capturedAt.Add(2 * time.Hour) + if _, err := s.InsertAnthropicSnapshot(&api.AnthropicSnapshot{ + CapturedAt: capturedAt, + Quotas: []api.AnthropicQuota{ + {Name: "seven_day", Utilization: 98, ResetsAt: &reset}, + {Name: "seven_day_sonnet", Utilization: 65, ResetsAt: &reset}, + {Name: "five_hour", Utilization: 28, ResetsAt: &reset}, + }, + }); err != nil { + t.Fatalf("InsertAnthropicSnapshot returned error: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithAnthropic()) + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot returned error: %v", err) + } + + provider := findMenubarProviderCard(t, snapshot, "anthropic") + assertQuotaLabels(t, provider.Quotas, []string{ + api.AnthropicDisplayName("five_hour"), + api.AnthropicDisplayName("seven_day"), + api.AnthropicDisplayName("seven_day_sonnet"), + }) +} + +func TestBuildMenubarSnapshotPreservesCodexFreeQuotaOrder(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New returned error: %v", err) + } + defer s.Close() + + capturedAt := time.Now().UTC().Truncate(time.Second) + reset := capturedAt.Add(2 * time.Hour) + if _, err := s.InsertCodexSnapshot(&api.CodexSnapshot{ + CapturedAt: capturedAt, + AccountID: DefaultCodexAccountID, + PlanType: "free", + Quotas: []api.CodexQuota{ + {Name: "code_review", Utilization: 10, ResetsAt: &reset}, + {Name: "five_hour", Utilization: 28, ResetsAt: &reset}, + }, + }); err != nil { + t.Fatalf("InsertCodexSnapshot returned error: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithCodex()) + snapshot, err := h.BuildMenubarSnapshot() + if err != nil { + t.Fatalf("BuildMenubarSnapshot returned error: %v", err) + } + + provider := findMenubarProviderCard(t, snapshot, "codex:1") + assertQuotaLabels(t, provider.Quotas, []string{ + api.CodexDisplayName("seven_day"), + api.CodexDisplayName("code_review"), + }) +} + +func findMenubarProviderCard(t *testing.T, snapshot *menubar.Snapshot, providerID string) menubar.ProviderCard { + t.Helper() + + for i := range snapshot.Providers { + if snapshot.Providers[i].ID == providerID { + return snapshot.Providers[i] + } + } + + t.Fatalf("expected provider %q in snapshot, got %+v", providerID, snapshot.Providers) + return menubar.ProviderCard{} +} + +func assertQuotaLabels(t *testing.T, quotas []menubar.QuotaMeter, want []string) { + t.Helper() + + if len(quotas) != len(want) { + t.Fatalf("quota count = %d, want %d (%v)", len(quotas), len(want), quotaLabels(quotas)) + } + + for i := range want { + if quotas[i].Label != want[i] { + t.Fatalf("quota order = %v, want %v", quotaLabels(quotas), want) + } + } +} + +func quotaLabels(quotas []menubar.QuotaMeter) []string { + labels := make([]string, 0, len(quotas)) + for _, quota := range quotas { + labels = append(labels, quota.Label) + } + return labels +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 95f640f..27724cc 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -246,6 +246,12 @@ func SessionAuthMiddleware(sessions *SessionStore, logger ...*slog.Logger) func( return } + // Local tray surface is intentionally public for localhost requests. + if isLocalMenubarPublicPath(path) && isLoopbackRequest(r) { + next.ServeHTTP(w, r) + return + } + // Check session cookie first if cookie, err := r.Cookie(sessionCookieName); err == nil { if sessions.ValidateToken(cookie.Value) { diff --git a/internal/web/server.go b/internal/web/server.go index 3ad0c54..6f4b4ce 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -43,6 +43,7 @@ func NewServer(port int, handler *Handler, logger *slog.Logger, username, passwo // Register routes mux.HandleFunc("/", handler.Dashboard) + mux.HandleFunc("/menubar", handler.MenubarPage) mux.HandleFunc("/settings", handler.SettingsPage) mux.HandleFunc("/login", handler.Login) mux.HandleFunc("/logout", handler.Logout) @@ -54,6 +55,11 @@ 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/preferences", handler.MenubarPreferences) + mux.HandleFunc("/api/menubar/refresh", handler.MenubarRefresh) + 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/internal/web/static/app.js b/internal/web/static/app.js index 7557a30..5852281 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -142,6 +142,11 @@ const State = { allProvidersInsights: null, allProvidersHistory: null, providerVisibility: {}, + menubarCapabilities: null, + menubarProviderOrder: [], + menubarProviders: [], + menubarVisibleProviders: [], + menubarStatusDisplay: { mode: 'multi_provider', selected_quotas: [] }, currentRequestSeq: 0, insightsRequestSeq: 0, historyRequestSeq: 0, @@ -530,6 +535,37 @@ function codexVisibleQuotaNames(planType) { : ['five_hour', 'seven_day', 'code_review']; } +const anthropicQuotaOrder = ['five_hour', 'seven_day', 'seven_day_sonnet', 'monthly_limit', 'extra_usage']; +const codexQuotaOrder = ['five_hour', 'seven_day', 'code_review']; + +function quotaOrderForProvider(provider) { + if (provider === 'anthropic') return anthropicQuotaOrder; + if (provider === 'codex') return codexQuotaOrder; + return []; +} + +function sortQuotaKeysForProvider(keys, provider) { + const sorted = Array.isArray(keys) ? [...keys] : Array.from(keys || []); + const preferred = quotaOrderForProvider(provider); + if (preferred.length === 0) { + return sorted.sort(); + } + const rank = new Map(preferred.map((name, index) => [name, index])); + return sorted.sort((left, right) => { + const leftRank = rank.has(left) ? rank.get(left) : Number.MAX_SAFE_INTEGER; + const rightRank = rank.has(right) ? rank.get(right) : Number.MAX_SAFE_INTEGER; + if (leftRank !== rightRank) return leftRank - rightRank; + return String(left).localeCompare(String(right)); + }); +} + +function sortQuotaEntriesForProvider(quotas, provider) { + if (!Array.isArray(quotas)) return []; + const preferred = quotaOrderForProvider(provider); + if (preferred.length === 0) return [...quotas]; + return sortItemsByPreference(quotas, preferred, (quota) => quota && quota.name); +} + function setCodexPlanType(planType) { const normalized = normalizeCodexPlanType(planType); if (!normalized) return false; @@ -541,7 +577,6 @@ function setCodexPlanType(planType) { function filterCodexQuotasForPlan(quotas, planType) { if (!Array.isArray(quotas)) return []; const preferred = new Set(codexVisibleQuotaNames(planType)); - const order = ['five_hour', 'seven_day', 'code_review']; let filtered = quotas .filter(q => q && q.name && preferred.has(q.name)); @@ -554,15 +589,7 @@ function filterCodexQuotasForPlan(quotas, planType) { } } - return filtered - .sort((a, b) => { - const left = order.indexOf(a.name); - const right = order.indexOf(b.name); - if (left === -1 && right === -1) return String(a.name).localeCompare(String(b.name)); - if (left === -1) return 1; - if (right === -1) return -1; - return left - right; - }); + return sortQuotaEntriesForProvider(filtered, 'codex'); } // Codex chart colors keyed by quota name @@ -629,8 +656,8 @@ const minimaxChartColorFallback = [ const renewalCategories = { anthropic: [ { label: '5-Hour', groupBy: 'five_hour' }, - { label: 'Weekly', groupBy: 'seven_day' }, - { label: 'Sonnet', groupBy: 'seven_day_sonnet' }, + { label: 'Weekly All', groupBy: 'seven_day' }, + { label: 'Weekly Sonnet', groupBy: 'seven_day_sonnet' }, { label: 'Extra', groupBy: 'extra_usage' } ], synthetic: [ @@ -648,8 +675,8 @@ const renewalCategories = { ], codex: [ { label: '5-Hour', groupBy: 'five_hour' }, - { label: 'Weekly', groupBy: 'seven_day' }, - { label: 'Review', groupBy: 'code_review' } + { label: 'Weekly All', groupBy: 'seven_day' }, + { label: 'Review Requests', groupBy: 'code_review' } ], antigravity: [ { label: 'Claude+GPT', groupBy: 'antigravity_claude_gpt' }, @@ -667,9 +694,9 @@ const overviewQuotaDisplayNames = { tokens: 'Tokens', time: 'Time', five_hour: '5-Hour', // Default for Anthropic - seven_day: 'Weekly', - code_review: 'Review', - seven_day_sonnet: 'Sonnet', + seven_day: 'Weekly All', + code_review: 'Review Requests', + seven_day_sonnet: 'Weekly Sonnet', monthly_limit: 'Monthly', extra_usage: 'Extra', premium_interactions: 'Premium', @@ -2501,9 +2528,9 @@ async function fetchCurrent() { renderAnthropicQuotaCards(data.quotas, 'quota-grid-anthropic'); } data.quotas.forEach(q => updateAnthropicCard(q)); - // Store sorted quota names for session table headers (mirrors backend positional mapping) + // Store quota names for session table headers using Anthropic display order. if (State.anthropicSessionQuotas.length === 0) { - State.anthropicSessionQuotas = data.quotas.map(q => q.name).sort().slice(0, 3); + State.anthropicSessionQuotas = sortQuotaKeysForProvider(data.quotas.map(q => q.name), 'anthropic').slice(0, 3); updateAnthropicSessionHeaders(); } } @@ -3223,7 +3250,7 @@ async function fetchHistory(range) { historyRows.forEach(d => { Object.keys(d).forEach(k => { if (k !== 'capturedAt') quotaKeys.add(k); }); }); - const sortedKeys = [...quotaKeys].sort(); + const sortedKeys = sortQuotaKeysForProvider(quotaKeys, 'anthropic'); let fallbackIdx = 0; const datasets = []; sortedKeys.forEach((key) => { @@ -3333,7 +3360,7 @@ async function fetchHistory(range) { historyRows.forEach(d => { Object.keys(d).forEach(k => { if (k !== 'capturedAt') quotaKeys.add(k); }); }); - const sortedKeys = [...quotaKeys].sort(); + const sortedKeys = sortQuotaKeysForProvider(quotaKeys, 'codex'); let fallbackIdx = 0; const datasets = []; sortedKeys.forEach((key) => { @@ -3527,7 +3554,7 @@ function normalizeBothQuotas(provider, payload) { if (!Array.isArray(payload.quotas)) return []; const rawQuotas = provider === 'codex' ? filterCodexQuotasForPlan(payload.quotas, payload.planType || State.codexPlanType) - : payload.quotas; + : sortQuotaEntriesForProvider(payload.quotas, provider); return rawQuotas.map((quota) => { const percent = quota.cardPercent != null ? quota.cardPercent @@ -3815,7 +3842,7 @@ function buildDynamicDatasetsForRows(rows, range, labelMap, colorMap, colorFallb const datasets = []; let idx = 0; - [...keys].sort().forEach((key) => { + sortQuotaKeysForProvider(keys, providerKey).forEach((key) => { const color = colorMap[key] || colorFallback[idx++ % colorFallback.length]; const rawData = rows.map(d => ({ x: new Date(d.capturedAt), y: d[key] || 0 })); const processed = processDataWithGaps(rawData, range); @@ -4066,7 +4093,7 @@ function updateBothCharts(data, range = '6h') { rows.forEach(d => { Object.keys(d).forEach(k => { if (k !== 'capturedAt') keys.add(k); }); }); - const sorted = [...keys].sort(); + const sorted = sortQuotaKeysForProvider(keys, providerKey); const datasets = []; let idx = 0; sorted.forEach((key) => { @@ -6050,9 +6077,10 @@ function isSettingsPage() { return window.location.pathname === '/settings'; } -function initSettingsPage() { +async function initSettingsPage() { setupSettingsTabs(); - loadSettings(); + await setupMenubarSettings(); + await loadSettings(); setupSettingsSave(); setupProviderReload(); setupSMTPTest(); @@ -6063,6 +6091,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 +6130,42 @@ 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'); + 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 (settingsShell) settingsShell.hidden = !supported; + if (orderShell) orderShell.hidden = !supported; + if (divider) divider.hidden = !supported; +} + async function loadSettings() { try { const resp = await authFetch('/api/settings'); @@ -6152,12 +6222,44 @@ 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 === 'detailed' ? 'detailed' : 'standard'; + } + 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() : []; + State.menubarVisibleProviders = Array.isArray(settings.visible_providers) ? settings.visible_providers.slice() : []; + State.menubarStatusDisplay = settings.status_display && typeof settings.status_display === 'object' + ? JSON.parse(JSON.stringify(settings.status_display)) + : { mode: 'multi_provider', selected_quotas: [] }; + await populateMenubarProviderOrder(); +} + function setVal(id, val) { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.value = val; @@ -6293,6 +6395,211 @@ 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); + + const knownKeys = new Set(providers.map(provider => provider.key)); + const explicitVisible = Array.isArray(State.menubarVisibleProviders) + ? State.menubarVisibleProviders.filter((providerKey) => knownKeys.has(providerKey)) + : []; + const visibleSet = new Set(explicitVisible); + const showAll = visibleSet.size === 0; + + list.innerHTML = providers.map(provider => { + const visible = showAll || visibleSet.has(provider.key); + return ` + + `; + }).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.querySelectorAll('input[data-role="menubar-visible"]').forEach((input) => { + input.addEventListener('change', () => { + const toggles = [...list.querySelectorAll('input[data-role="menubar-visible"]')] + .filter((toggle) => toggle instanceof HTMLInputElement); + + let visibleProviders = toggles + .filter((toggle) => toggle.checked) + .map((toggle) => toggle.dataset.provider) + .filter(Boolean); + + if (visibleProviders.length === 0 && input instanceof HTMLInputElement) { + input.checked = true; + visibleProviders = [input.dataset.provider].filter(Boolean); + } + + const visibleSet = new Set(visibleProviders); + list.querySelectorAll('.menubar-order-item[data-provider]').forEach((row) => { + const rowProvider = row.dataset.provider; + const rowToggle = row.querySelector('input[data-role="menubar-visible"]'); + if (!rowProvider || !(rowToggle instanceof HTMLInputElement)) return; + const isVisible = visibleSet.has(rowProvider); + row.classList.toggle('is-hidden', !isVisible); + rowToggle.checked = isVisible; + const label = rowToggle.nextElementSibling; + if (label) { + label.textContent = isVisible ? 'Show' : 'Hide'; + } + }); + + if (visibleSet.size === State.menubarProviderOrder.length) { + State.menubarVisibleProviders = []; + } else { + State.menubarVisibleProviders = State.menubarProviderOrder.filter((provider) => visibleSet.has(provider)); + } + }); + }); + + 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); + + const visibleSet = new Set( + [...list.querySelectorAll('input[data-role="menubar-visible"]')] + .filter((input) => input instanceof HTMLInputElement && input.checked) + .map((input) => input.dataset.provider) + .filter(Boolean) + ); + + if (visibleSet.size === State.menubarProviderOrder.length) { + State.menubarVisibleProviders = []; + } else { + State.menubarVisibleProviders = State.menubarProviderOrder.filter((provider) => visibleSet.has(provider)); + } +} + function providerStatusBadge(configured, autoDetectable, isPolling) { if (!configured) { return autoDetectable @@ -6513,6 +6820,20 @@ 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], + visible_providers: [...State.menubarVisibleProviders], + status_display: State.menubarStatusDisplay ? JSON.parse(JSON.stringify(State.menubarStatusDisplay)) : { mode: 'multi_provider', selected_quotas: [] }, + }; + } + return settings; } @@ -6537,6 +6858,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 +7265,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (isSettingsPage()) { initTheme(); initLayoutToggle(); - initSettingsPage(); + await initSettingsPage(); return; } diff --git a/internal/web/static/icons/anthropic.svg b/internal/web/static/icons/anthropic.svg new file mode 100644 index 0000000..5b81844 --- /dev/null +++ b/internal/web/static/icons/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/internal/web/static/icons/antigravity.svg b/internal/web/static/icons/antigravity.svg new file mode 100644 index 0000000..784fc63 --- /dev/null +++ b/internal/web/static/icons/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/internal/web/static/icons/copilot.svg b/internal/web/static/icons/copilot.svg new file mode 100644 index 0000000..59872d0 --- /dev/null +++ b/internal/web/static/icons/copilot.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/internal/web/static/icons/minimax.svg b/internal/web/static/icons/minimax.svg new file mode 100644 index 0000000..1d32449 --- /dev/null +++ b/internal/web/static/icons/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/internal/web/static/icons/openai.svg b/internal/web/static/icons/openai.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/internal/web/static/icons/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/internal/web/static/icons/synthetic.svg b/internal/web/static/icons/synthetic.svg new file mode 100644 index 0000000..5c60ff7 --- /dev/null +++ b/internal/web/static/icons/synthetic.svg @@ -0,0 +1,4 @@ + + Synthetic + + diff --git a/internal/web/static/icons/zai.svg b/internal/web/static/icons/zai.svg new file mode 100644 index 0000000..04ba2d9 --- /dev/null +++ b/internal/web/static/icons/zai.svg @@ -0,0 +1 @@ +Z.ai \ No newline at end of file diff --git a/internal/web/static/menubar.html b/internal/web/static/menubar.html new file mode 100644 index 0000000..356495b --- /dev/null +++ b/internal/web/static/menubar.html @@ -0,0 +1,2713 @@ + + + + + + onWatch Menubar + + + + +
+ + + + + + + +
+ + + + diff --git a/internal/web/static/style.css b/internal/web/static/style.css index f7294c5..32aa2a9 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -2815,6 +2815,113 @@ 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-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; +} + +.menubar-order-item.is-hidden { + border-color: var(--border-default); +} + +.menubar-order-controls { + margin-left: auto; + display: inline-flex; + align-items: center; +} + +.menubar-order-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.menubar-order-toggle input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-teal); + cursor: pointer; +} + /* Settings page responsive */ @media (max-width: 768px) { .settings-main { padding: 16px; } @@ -2825,6 +2932,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..fa44272 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -30,6 +30,7 @@

Settings Beta

+ @@ -203,6 +204,64 @@

Provider Controls

+ + + + + + + + + @@ -3618,7 +3661,7 @@

Powered by onllm.dev · Built with Chart.js · modernc.org/sqlite + diff --git a/main.go b/main.go index 1c97e87..e11522e 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/onllm-dev/onwatch/v2/internal/agent" "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" @@ -44,7 +45,7 @@ func init() { } func main() { - if err := run(); err != nil { + if err := runWithCrashCapture(); err != nil { if !errors.Is(err, errCodexProfileRefreshAborted) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } @@ -95,6 +96,20 @@ func hasCommand(cmds ...string) bool { return false } +func printMenubarHelp() { + fmt.Print(menubarHelpText()) +} + +func menubarHelpText() string { + return "" + + "onWatch Menubar Companion\n\n" + + "Usage: onwatch menubar [OPTIONS]\n\n" + + "Options:\n" + + " --port PORT Dashboard port to connect to (default: 9211)\n" + + " --debug Run in foreground with verbose logging\n" + + " --help Show this help message\n" +} + // stopPreviousInstance stops any running onwatch instance using PID file + port check. // In test mode, only PID file is used (no port scanning) to avoid killing production. func stopPreviousInstance(port int, testMode bool) { @@ -358,7 +373,7 @@ func daemonize(cfg *config.Config) error { logName = ".onwatch-test.log" } logPath := filepath.Join(filepath.Dir(cfg.DBPath), logName) - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + logFile, err := config.OpenRotatingLogFile(logPath) if err != nil { return fmt.Errorf("failed to open log file for daemon: %w", err) } @@ -391,6 +406,18 @@ func daemonize(cfg *config.Config) error { return nil } +func runWithCrashCapture() (err error) { + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + slog.Error("Fatal panic", "panic", r, "stack", stack) + fmt.Fprintf(os.Stderr, "Fatal panic: %v\n%s\n", r, stack) + err = fmt.Errorf("panic: %v", r) + } + }() + return run() +} + func run() error { // Phase 1: Detect test mode early and configure PID file for isolation testMode := hasFlag("--test") @@ -403,6 +430,13 @@ func run() error { if hasCommand("codex") { return runCodexCommand() } + if hasCommand("menubar") { + if hasFlag("--help") || hasFlag("-h") { + printMenubarHelp() + return nil + } + return runMenubarCommand() + } if hasCommand("stop", "--stop") { return runStop(testMode) } @@ -472,6 +506,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 @@ -521,6 +556,14 @@ func run() error { })) slog.SetDefault(logger) + logger.Info("Runtime startup", + "pid", os.Getpid(), + "port", cfg.Port, + "db_path", cfg.DBPath, + "debug", cfg.DebugMode, + "test_mode", cfg.TestMode, + ) + // Warn if using default password if cfg.IsDefaultPassword() { logger.Warn("⚠️ USING DEFAULT PASSWORD - set ONWATCH_ADMIN_PASS in .env for production") @@ -976,6 +1019,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 +1065,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,11 +1181,68 @@ 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 } // runStatus reports the status of any running onwatch instance. // In test mode, only the test PID file is checked (no port scanning). +func statusLogCandidates(dbPath string, names ...string) []string { + if len(names) == 0 { + return nil + } + seen := make(map[string]struct{}, len(names)*3) + paths := make([]string, 0, len(names)*3) + appendPath := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + + if dbPath != "" { + dir := filepath.Dir(dbPath) + for _, name := range names { + appendPath(filepath.Join(dir, name)) + } + } + + home, err := os.UserHomeDir() + if err == nil && home != "" { + for _, name := range names { + appendPath(filepath.Join(home, ".onwatch", name)) + } + } + + if dbPath == "" { + for _, name := range names { + appendPath(filepath.Join(pidDir, name)) + } + } + + for _, name := range names { + appendPath(filepath.Join(".", name)) + } + + return paths +} + +func firstExistingFile(paths []string) (string, int64, bool) { + for _, path := range paths { + if info, err := os.Stat(path); err == nil { + return path, info.Size(), true + } + } + return "", 0, false +} + func runStatus(testMode bool) error { myPID := os.Getpid() label := "onwatch" @@ -1178,31 +1289,44 @@ func runStatus(testMode bool) error { } } - // Show PID file location fmt.Printf(" PID file: %s\n", pidFile) - - // Show log file if it exists - logPath := ".onwatch.log" - if testMode { - logPath = ".onwatch-test.log" - } - if info, err := os.Stat(logPath); err == nil { - fmt.Printf(" Log file: %s (%s)\n", logPath, humanSize(info.Size())) + menubarPID := readRuntimePID(menubarPIDPath(testMode)) + if processRunning(menubarPID) { + fmt.Printf(" Menubar: running (PID %d)\n", menubarPID) } - // Show DB file if it exists (check new default path first, then old) home, _ := os.UserHomeDir() - dbPaths := []string{ - filepath.Join(home, ".onwatch", "data", "onwatch.db"), - "./onwatch.db", + dbCandidates := []string{} + if home != "" { + dbCandidates = append(dbCandidates, filepath.Join(home, ".onwatch", "data", "onwatch.db")) } - for _, dbPath := range dbPaths { - if info, err := os.Stat(dbPath); err == nil { - fmt.Printf(" Database: %s (%s)\n", dbPath, humanSize(info.Size())) + dbCandidates = append(dbCandidates, "./onwatch.db") + dbPath := "" + var dbSize int64 + for _, candidate := range dbCandidates { + if info, err := os.Stat(candidate); err == nil { + dbPath = candidate + dbSize = info.Size() break } } + mainLogName := ".onwatch.log" + menubarNames := menubarLogNames(false) + if testMode { + mainLogName = ".onwatch-test.log" + menubarNames = menubarLogNames(true) + } + if logPath, logSize, ok := firstExistingFile(statusLogCandidates(dbPath, mainLogName)); ok { + fmt.Printf(" Log file: %s (%s)\n", logPath, humanSize(logSize)) + } + if logPath, logSize, ok := firstExistingFile(statusLogCandidates(dbPath, menubarNames...)); ok { + fmt.Printf(" Menubar log: %s (%s)\n", logPath, humanSize(logSize)) + } + if dbPath != "" { + fmt.Printf(" Database: %s (%s)\n", dbPath, humanSize(dbSize)) + } + return nil } } diff --git a/main_menubar_test.go b/main_menubar_test.go new file mode 100644 index 0000000..7931f51 --- /dev/null +++ b/main_menubar_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/onllm-dev/onwatch/v2/internal/config" +) + +func TestMenubarHelpText(t *testing.T) { + help := menubarHelpText() + for _, fragment := range []string{ + "onWatch Menubar Companion", + "Usage: onwatch menubar [OPTIONS]", + "--port PORT", + "--debug", + "--help", + } { + if !strings.Contains(help, fragment) { + t.Fatalf("expected help text to contain %q, got %q", fragment, help) + } + } +} + +func TestMenubarLogPath_UsesNewName(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{DBPath: filepath.Join(dir, "onwatch.db")} + + want := filepath.Join(dir, "menubar.log") + if got := menubarLogPath(cfg); got != want { + t.Fatalf("menubarLogPath() = %q, want %q", got, want) + } +} + +func TestMenubarLogPath_TestModeUsesNewTestName(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{DBPath: filepath.Join(dir, "onwatch.db"), TestMode: true} + + want := filepath.Join(dir, "menubar-test.log") + if got := menubarLogPath(cfg); got != want { + t.Fatalf("menubarLogPath() = %q, want %q", got, want) + } +} diff --git a/main_test.go b/main_test.go index 6a0d37e..6edfd17 100644 --- a/main_test.go +++ b/main_test.go @@ -114,6 +114,101 @@ func TestDeriveEncryptionKey_UsesEncryptionSalt(t *testing.T) { } } +func TestStatusLogCandidates(t *testing.T) { + t.Run("prefers db directory then home then cwd", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + dbPath := filepath.Join(t.TempDir(), "data", "onwatch.db") + got := statusLogCandidates(dbPath, "main.log", "menubar.log") + + want := []string{ + filepath.Join(filepath.Dir(dbPath), "main.log"), + filepath.Join(filepath.Dir(dbPath), "menubar.log"), + filepath.Join(homeDir, ".onwatch", "main.log"), + filepath.Join(homeDir, ".onwatch", "menubar.log"), + filepath.Join(".", "main.log"), + filepath.Join(".", "menubar.log"), + } + if len(got) != len(want) { + t.Fatalf("candidate count = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("adds pid dir when db path missing", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + oldPIDDir := pidDir + pidDir = t.TempDir() + t.Cleanup(func() { pidDir = oldPIDDir }) + + got := statusLogCandidates("", "main.log") + want := []string{ + filepath.Join(homeDir, ".onwatch", "main.log"), + filepath.Join(pidDir, "main.log"), + filepath.Join(".", "main.log"), + } + if len(got) != len(want) { + t.Fatalf("candidate count = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("deduplicates repeated names", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + dbPath := filepath.Join(t.TempDir(), "data", "onwatch.db") + got := statusLogCandidates(dbPath, "main.log", "main.log") + + for i := range got { + for j := i + 1; j < len(got); j++ { + if got[i] == got[j] { + t.Fatalf("duplicate candidate %q in %v", got[i], got) + } + } + } + }) +} + +func TestFirstExistingFile(t *testing.T) { + tmp := t.TempDir() + first := filepath.Join(tmp, "first.log") + second := filepath.Join(tmp, "second.log") + + if err := os.WriteFile(second, []byte("second"), 0o600); err != nil { + t.Fatalf("write second file: %v", err) + } + if err := os.WriteFile(first, []byte("first"), 0o600); err != nil { + t.Fatalf("write first file: %v", err) + } + + path, size, ok := firstExistingFile([]string{filepath.Join(tmp, "missing.log"), first, second}) + if !ok { + t.Fatal("expected to find existing file") + } + if path != first { + t.Fatalf("path = %q, want %q", path, first) + } + if size != int64(len("first")) { + t.Fatalf("size = %d, want %d", size, len("first")) + } + + if _, _, ok := firstExistingFile([]string{filepath.Join(tmp, "none-1"), filepath.Join(tmp, "none-2")}); ok { + t.Fatal("expected no file match") + } +} + func TestHumanSize(t *testing.T) { tests := []struct { name string diff --git a/menubar_runtime.go b/menubar_runtime.go new file mode 100644 index 0000000..9494531 --- /dev/null +++ b/menubar_runtime.go @@ -0,0 +1,285 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "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 + } + if processZombie(pid) { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} + +func processZombie(pid int) bool { + if pid <= 0 { + return false + } + out, err := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "stat=").Output() + if err != nil { + return false + } + return strings.Contains(strings.TrimSpace(string(out)), "Z") +} + +func menubarLogNames(testMode bool) []string { + if testMode { + return []string{"menubar-test.log", ".onwatch-menubar-test.log"} + } + return []string{"menubar.log", ".onwatch-menubar.log"} +} + +func menubarLogPath(cfg *config.Config) string { + testMode := cfg != nil && cfg.TestMode + name := menubarLogNames(testMode)[0] + + if cfg == nil || cfg.DBPath == "" { + return filepath.Join(pidDir, name) + } + return filepath.Join(filepath.Dir(cfg.DBPath), name) +} + +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 + } + logger.Info("Starting menubar companion process") + 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); pid > 0 { + if processRunning(pid) { + return nil + } + _ = os.Remove(path) + } + + 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.Env = os.Environ() + + logFile, err := config.OpenRotatingLogFile(menubarLogPath(cfg)) + if err != nil { + return fmt.Errorf("failed to open menubar log file: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + _ = logFile.Close() + return fmt.Errorf("failed to capture menubar stdout: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + _ = logFile.Close() + return fmt.Errorf("failed to capture menubar stderr: %w", err) + } + + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return err + } + if err := writeRuntimePID(path); err != nil { + _ = cmd.Process.Kill() + _ = logFile.Close() + return fmt.Errorf("failed to write menubar pid file: %w", err) + } + logger.Info("Menubar companion started", "pid", cmd.Process.Pid, "log_path", menubarLogPath(cfg)) + + var stderrBuf bytes.Buffer + stdoutDone := make(chan struct{}) + stderrDone := make(chan struct{}) + + go func() { + defer close(stdoutDone) + writer := io.Writer(logFile) + if cfg.DebugMode { + writer = io.MultiWriter(logFile, os.Stdout) + } + _, _ = io.Copy(writer, stdoutPipe) + }() + + go func() { + defer close(stderrDone) + if cfg.DebugMode { + writer := io.MultiWriter(logFile, os.Stderr, &stderrBuf) + _, _ = io.Copy(writer, stderrPipe) + return + } + writer := io.MultiWriter(logFile, &stderrBuf) + _, _ = io.Copy(writer, stderrPipe) + }() + + go func() { + err := cmd.Wait() + <-stdoutDone + <-stderrDone + _ = os.Remove(path) + _ = logFile.Close() + + if err != nil { + exitCode := -1 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + logger.Error("Menubar companion crashed", "exit_code", exitCode, "stderr", strings.TrimSpace(stderrBuf.String())) + return + } + logger.Info("Menubar companion exited normally") + }() + + 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) + logger.Info("Menubar runtime starting", "pid", os.Getpid(), "port", cfg.Port, "db_path", cfg.DBPath, "test_mode", cfg.TestMode) + + 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() + }() + + err = menubar.Init(mbCfg) + if err != nil { + logger.Error("Menubar runtime stopped with error", "error", err) + return err + } + logger.Info("Menubar runtime stopped") + return nil +} diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 0a29284..52068bf 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -116,8 +116,14 @@ def onwatch_server(mock_server: subprocess.Popen) -> Generator[subprocess.Popen, os.makedirs(E2E_HOME, exist_ok=True) # Build onwatch + build_cmd = ["go", "build"] + build_tags = os.environ.get("ONWATCH_E2E_GO_BUILD_TAGS", "").strip() + if build_tags: + build_cmd.extend(["-tags", build_tags]) + build_cmd.extend(["-o", ONWATCH_BINARY, "."]) + result = subprocess.run( - ["go", "build", "-o", ONWATCH_BINARY, "."], + build_cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, @@ -129,6 +135,7 @@ def onwatch_server(mock_server: subprocess.Popen) -> Generator[subprocess.Popen, env.update({ "HOME": E2E_HOME, "ONWATCH_ADMIN_PASS": PASSWORD, + "ONWATCH_TEST_MODE": "1", "SYNTHETIC_API_KEY": "syn_test_e2e_key", "ZAI_API_KEY": "zai_test_e2e_key", "ZAI_BASE_URL": f"http://localhost:{MOCK_PORT}", diff --git a/tests/e2e/page_objects/settings_page.py b/tests/e2e/page_objects/settings_page.py index 2cfd2b5..1f4e600 100644 --- a/tests/e2e/page_objects/settings_page.py +++ b/tests/e2e/page_objects/settings_page.py @@ -32,7 +32,7 @@ def get_active_tab(self) -> str: def get_tab_names(self) -> list[str]: """Return a list of all settings tab texts.""" - tabs = self.page.query_selector_all(".settings-tab") + tabs = self.page.query_selector_all(".settings-tab:not([hidden])") return [tab.inner_text().strip() for tab in tabs] def configure_smtp(self, config: dict) -> None: diff --git a/tests/e2e/tests/test_menubar.py b/tests/e2e/tests/test_menubar.py new file mode 100644 index 0000000..f3b7763 --- /dev/null +++ b/tests/e2e/tests/test_menubar.py @@ -0,0 +1,110 @@ +"""Browser-surface tests for the menubar companion UI.""" + +from playwright.sync_api import Page, expect + +BASE_URL = "http://localhost:19211" + + +def open_menubar(page: Page, view: str | None = None, expected_view: str | None = None) -> None: + url = f"{BASE_URL}/api/menubar/test" + if view: + url = f"{url}?view={view}" + page.goto(url) + resolved_view = expected_view or view + if resolved_view: + page.wait_for_selector(f"#menubar-shell.menubar-view-{resolved_view}", timeout=10000) + else: + page.wait_for_selector("#menubar-shell", timeout=10000) + + +class TestMenubarStandardView: + def test_minimal_query_preserves_minimal_view(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "minimal") + expect(authenticated_page.locator("#menubar-shell.menubar-view-minimal")).to_be_visible() + expect(authenticated_page.locator(".minimal-view")).to_have_count(1) + expect(authenticated_page.locator(".menubar-footer")).to_be_visible() + + def test_renders_provider_cards_and_per_quota_resets(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "standard") + expect(authenticated_page.locator("#menubar-shell.menubar-view-standard")).to_be_visible() + expect(authenticated_page.locator("#header-value")).to_be_visible() + first_card = authenticated_page.locator(".provider-card").first + expect(first_card).to_be_visible() + expect(first_card.locator(".provider-icon")).to_be_visible() + expect(authenticated_page.locator(".quota-meter").first).to_be_visible() + expect(authenticated_page.locator(".quota-reset-line").first).to_be_visible() + + def test_footer_refresh_and_links_are_visible(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "standard") + expect(authenticated_page.locator("#refresh-button")).to_be_visible() + expect(authenticated_page.locator("#footer-github")).to_have_attribute("href", "https://github.com/onllm-dev/onwatch") + expect(authenticated_page.locator("#footer-support")).to_have_attribute("href", "https://github.com/onllm-dev/onwatch/issues") + expect(authenticated_page.locator("#footer-onllm")).to_have_attribute("href", "https://onllm.dev") + + def test_settings_panel_only_shows_supported_status_modes(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "standard") + authenticated_page.locator("#settings-toggle").click() + expect(authenticated_page.locator('input[name="status-display"]')).to_have_count(3) + expect(authenticated_page.get_by_text("Multi-provider", exact=True)).to_be_visible() + expect(authenticated_page.get_by_text("Critical count", exact=True)).to_be_visible() + expect(authenticated_page.get_by_text("Icon only", exact=True)).to_be_visible() + expect(authenticated_page.locator("text=Highest %")).to_have_count(0) + expect(authenticated_page.locator("text=Aggregate")).to_have_count(0) + assert authenticated_page.locator('input[name="status-selection"]').count() > 0 + expect(authenticated_page.locator("#status-provider")).to_have_count(0) + expect(authenticated_page.locator("#status-quota")).to_have_count(0) + expect(authenticated_page.locator("text=Preview")).to_be_visible() + + def test_provider_order_arrow_controls_reorder_rows(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "standard") + authenticated_page.locator("#settings-toggle").click() + + rows = authenticated_page.locator("#provider-order-list .provider-order-item") + assert rows.count() >= 2 + + first_before = rows.nth(0).get_attribute("data-provider-id") + second_before = rows.nth(1).get_attribute("data-provider-id") + assert first_before + assert second_before + + move_down = rows.nth(0).locator('button[data-provider-move="down"]') + expect(move_down).to_be_visible() + move_down.click() + + rows_after = authenticated_page.locator("#provider-order-list .provider-order-item") + first_after = rows_after.nth(0).get_attribute("data-provider-id") + second_after = rows_after.nth(1).get_attribute("data-provider-id") + assert first_after == second_before + assert second_after == first_before + + def test_light_theme_switch_applies_root_theme_and_save(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "standard") + authenticated_page.locator("#settings-toggle").click() + + theme_select = authenticated_page.locator('select[name="theme-mode"]') + expect(theme_select).to_be_visible() + theme_select.select_option("light") + + html_root = authenticated_page.locator("html") + expect(html_root).to_have_attribute("data-theme", "light") + + authenticated_page.locator("#settings-save").click() + expect(authenticated_page.locator("#settings-panel")).to_be_hidden() + + +class TestMenubarDetailedView: + def test_shows_detailed_quota_rows(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page, "detailed") + expect(authenticated_page.locator("#menubar-shell.menubar-view-detailed")).to_be_visible() + expect(authenticated_page.locator(".provider-card").first).to_be_visible() + expect(authenticated_page.locator(".quota-detail-section").first).to_be_visible() + expect(authenticated_page.locator(".quota-bar-track").first).to_be_visible() + expect(authenticated_page.locator(".quota-detail-meta").first).to_be_visible() + + def test_unsaved_view_toggle_does_not_override_default_view(self, authenticated_page: Page) -> None: + open_menubar(authenticated_page) + expect(authenticated_page.locator("#menubar-shell.menubar-view-standard")).to_be_visible() + authenticated_page.locator("#view-toggle").click() + expect(authenticated_page.locator("#menubar-shell.menubar-view-detailed")).to_be_visible() + authenticated_page.reload() + expect(authenticated_page.locator("#menubar-shell.menubar-view-standard")).to_be_visible() diff --git a/tests/e2e/tests/test_settings.py b/tests/e2e/tests/test_settings.py index ecaab80..b517357 100644 --- a/tests/e2e/tests/test_settings.py +++ b/tests/e2e/tests/test_settings.py @@ -1,27 +1,39 @@ """E2E tests for the settings page. -8 tests covering tabs, SMTP form, thresholds, provider toggles, and save. +Tests covering tabs, macOS menubar behavior, SMTP form, thresholds, provider toggles, menubar visibility persistence, and save. """ +import platform + import pytest from playwright.sync_api import Page, expect from page_objects.settings_page import SettingsPage -BASE_URL = "http://localhost:19211" - class TestSettings: """Settings page interaction tests.""" def test_four_tabs_present(self, settings_page: Page) -> None: - """Settings page should have 4 tabs: Email, Notifications, Providers, General.""" + """Settings page should expose platform-appropriate visible tabs.""" sp = SettingsPage(settings_page) tabs = sp.get_tab_names() - assert len(tabs) == 4 - assert "Email (SMTP)" in tabs - assert "Notifications" in tabs - assert "Providers" in tabs - assert "General" in tabs + expected = {"Email (SMTP)", "Notifications", "Providers", "General"} + if platform.system() == "Darwin": + expected.add("Menubar") + assert set(tabs) == expected + + def test_menubar_tab_behavior_matches_capabilities(self, settings_page: Page) -> None: + """macOS builds should expose Menubar tab controls when menubar support is available.""" + if platform.system() != "Darwin": + pytest.skip("Menubar settings are only exposed on macOS") + + sp = SettingsPage(settings_page) + sp.select_tab("menubar") + + settings_hidden = settings_page.locator("#menubar-settings-shell").evaluate("el => el.hidden") + order_hidden = settings_page.locator("#menubar-order-shell").evaluate("el => el.hidden") + divider_hidden = settings_page.locator("#menubar-order-divider").evaluate("el => el.hidden") + assert settings_hidden == order_hidden == divider_hidden def test_smtp_form_fields(self, settings_page: Page) -> None: """The Email (SMTP) tab should display all SMTP configuration fields.""" @@ -84,6 +96,77 @@ def test_provider_toggles_tab(self, settings_page: Page) -> None: assert sp.is_panel_visible("providers") assert sp.is_provider_toggles_visible() + def test_menubar_provider_visibility_is_saved_via_global_settings(self, settings_page: Page) -> None: + """Menubar provider visibility should persist in settings payload.""" + if platform.system() != "Darwin": + pytest.skip("Menubar settings are only exposed on macOS") + + sp = SettingsPage(settings_page) + sp.select_tab("menubar") + + order_hidden = settings_page.locator("#menubar-order-shell").evaluate("el => el.hidden") + if order_hidden: + pytest.skip("Menubar order controls are hidden for this build") + + order_list = settings_page.locator("#menubar-provider-order") + expect(order_list).to_be_visible() + + initial_items = order_list.locator(".menubar-order-item[data-provider]") + assert initial_items.count() >= 2 + + first_provider = initial_items.nth(0).get_attribute("data-provider") + second_provider = initial_items.nth(1).get_attribute("data-provider") + assert first_provider + assert second_provider + + first_toggle = initial_items.nth(0).locator('input[data-role="menubar-visible"]') + if first_toggle.is_checked(): + first_toggle.click() + hidden_provider = first_provider + else: + second_toggle = initial_items.nth(1).locator('input[data-role="menubar-visible"]') + second_toggle.click() + hidden_provider = second_provider + + expected_visible = settings_page.evaluate( + """ + () => [...document.querySelectorAll('#menubar-provider-order input[data-role="menubar-visible"]')] + .filter((input) => input.checked) + .map((input) => input.dataset.provider) + """ + ) + + sp.save_settings() + expect(settings_page.locator("#settings-feedback")).to_contain_text("Settings saved successfully") + + prefs_after_save = settings_page.evaluate( + """ + async () => { + const response = await fetch('/api/menubar/preferences', { credentials: 'same-origin' }); + return await response.json(); + } + """ + ) + assert prefs_after_save.get("visible_providers") == expected_visible + + settings_page.reload() + sp = SettingsPage(settings_page) + sp.select_tab("menubar") + + hidden_toggle = settings_page.locator( + f'#menubar-provider-order .menubar-order-item[data-provider="{hidden_provider}"] input[data-role="menubar-visible"]' + ).first + assert not hidden_toggle.is_checked() + + checked_after_reload = settings_page.evaluate( + """ + () => [...document.querySelectorAll('#menubar-provider-order input[data-role="menubar-visible"]')] + .filter((input) => input.checked) + .map((input) => input.dataset.provider) + """ + ) + assert checked_after_reload == expected_visible + def test_timezone_setting(self, settings_page: Page) -> None: """General tab should have a timezone selector.""" sp = SettingsPage(settings_page)