├── .github
├── CODEOWNERS
├── renovate.json
├── changelog-generator.yaml
└── workflows
│ └── build.yml
├── assets
├── windows
│ ├── .gitignore
│ └── winres.json
├── darwin
│ ├── .gitignore
│ ├── cask
│ │ ├── nightscout-menu-bar.rb.tmpl
│ │ └── main.go
│ └── info
│ │ ├── main.go
│ │ └── info.plist
├── png
│ ├── icon_16x16.png
│ ├── icon_32x32.png
│ ├── icon_48x48.png
│ ├── icon_64x64.png
│ ├── icon_128x128.png
│ ├── icon_256x256.png
│ └── icon_512x512.png
└── icon.svg
├── .gitignore
├── internal
├── pprof
│ ├── disabled.go
│ └── enabled.go
├── assets
│ ├── dist
│ │ ├── gear-solid.ico
│ │ ├── gear-solid.png
│ │ ├── nightscout.ico
│ │ ├── nightscout.png
│ │ ├── xmark-solid.ico
│ │ ├── xmark-solid.png
│ │ ├── droplet-solid.ico
│ │ ├── droplet-solid.png
│ │ ├── github-brands-solid.ico
│ │ ├── github-brands-solid.png
│ │ ├── nightscout-transparent.png
│ │ ├── square-up-right-solid.ico
│ │ ├── square-up-right-solid.png
│ │ ├── rectangle-history-solid.ico
│ │ ├── rectangle-history-solid.png
│ │ ├── triangle-exclamation-solid.ico
│ │ └── triangle-exclamation-solid.png
│ ├── gear_windows.go
│ ├── quit_windows.go
│ ├── droplet_windows.go
│ ├── nightscout_windows.go
│ ├── about_windows.go
│ ├── gear.go
│ ├── open_windows.go
│ ├── quit.go
│ ├── droplet.go
│ ├── history_windows.go
│ ├── error_windows.go
│ ├── nightscout_darwin.go
│ ├── nightscout.go
│ ├── about.go
│ ├── open.go
│ ├── history.go
│ ├── error.go
│ ├── convert-icon.sh
│ └── src
│ │ ├── xmark-solid.svg
│ │ ├── droplet-solid.svg
│ │ ├── rectangle-history-solid.svg
│ │ ├── triangle-exclamation-solid.svg
│ │ ├── square-up-right-solid.svg
│ │ ├── gear-solid.svg
│ │ ├── github-brands-solid.svg
│ │ ├── nightscout-transparent.svg
│ │ └── nightscout.svg
├── dynamicicon
│ ├── RobotoCondensed-SemiBold.ttf
│ ├── encode.go
│ ├── encode_windows.go
│ └── dynamicicon.go
├── ticker
│ ├── sleep_notifier.go
│ ├── sleep_notifier_darwin.go
│ ├── render.go
│ ├── ticker.go
│ └── fetch.go
├── nightscout
│ ├── direction.go
│ ├── mgdl.go
│ ├── mgdl_test.go
│ ├── sgv.go
│ ├── properties.go
│ ├── mills.go
│ ├── delta.go
│ ├── properties_test.go
│ ├── delta_test.go
│ ├── reading.go
│ ├── mills_test.go
│ ├── testproperties
│ │ ├── fetch_test_properties.json
│ │ └── testproperties.go
│ └── reading_test.go
├── config
│ ├── unit.go
│ ├── options.go
│ ├── parser.go
│ ├── duration.go
│ ├── log.go
│ ├── default.go
│ ├── unit_enumer.go
│ ├── config.go
│ └── load.go
├── tray
│ ├── items
│ │ ├── quit.go
│ │ ├── last_reading.go
│ │ ├── error.go
│ │ ├── open_nightscout.go
│ │ ├── history.go
│ │ ├── about.go
│ │ ├── preferences
│ │ │ ├── socket.go
│ │ │ ├── dynamic_icon.go
│ │ │ ├── url.go
│ │ │ ├── token.go
│ │ │ ├── dynamic_icon_color.go
│ │ │ ├── units.go
│ │ │ └── preferences.go
│ │ └── items.go
│ ├── messages
│ │ └── messages.go
│ ├── systray_test.go
│ └── systray.go
├── util
│ ├── date.go
│ ├── http.go
│ ├── version.go
│ ├── path.go
│ └── date_test.go
├── generate
│ └── config_example
│ │ └── main.go
├── autostart
│ └── autostart.go
├── socket
│ ├── socket_test.go
│ └── socket.go
└── fetch
│ ├── fetch_test.go
│ └── fetch.go
├── contrib
└── powerlevel10k
│ ├── screenshot.webp
│ ├── README.md
│ └── nightscout.zsh
├── hack
├── rasterize-icons.sh
├── build-windows.sh
└── build-darwin.sh
├── .pre-commit-config.yaml
├── main.go
├── LICENSE
├── config_example.toml
├── go.mod
├── README.md
├── .golangci.yaml
└── go.sum
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @gabe565
2 |
--------------------------------------------------------------------------------
/assets/windows/.gitignore:
--------------------------------------------------------------------------------
1 | *.png
2 |
--------------------------------------------------------------------------------
/assets/darwin/.gitignore:
--------------------------------------------------------------------------------
1 | *.icns
2 | *.iconset/
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.syso
2 | .DS_Store
3 | .idea/
4 | /dist/
5 | coverage.out
6 | nightscout-menu-bar
7 |
--------------------------------------------------------------------------------
/internal/pprof/disabled.go:
--------------------------------------------------------------------------------
1 | //go:build !pprof
2 |
3 | package pprof
4 |
5 | func ListenAndServe() {}
6 |
--------------------------------------------------------------------------------
/assets/png/icon_16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_16x16.png
--------------------------------------------------------------------------------
/assets/png/icon_32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_32x32.png
--------------------------------------------------------------------------------
/assets/png/icon_48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_48x48.png
--------------------------------------------------------------------------------
/assets/png/icon_64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_64x64.png
--------------------------------------------------------------------------------
/assets/png/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_128x128.png
--------------------------------------------------------------------------------
/assets/png/icon_256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_256x256.png
--------------------------------------------------------------------------------
/assets/png/icon_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/assets/png/icon_512x512.png
--------------------------------------------------------------------------------
/internal/assets/dist/gear-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/gear-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/gear-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/gear-solid.png
--------------------------------------------------------------------------------
/internal/assets/dist/nightscout.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/nightscout.ico
--------------------------------------------------------------------------------
/internal/assets/dist/nightscout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/nightscout.png
--------------------------------------------------------------------------------
/contrib/powerlevel10k/screenshot.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/contrib/powerlevel10k/screenshot.webp
--------------------------------------------------------------------------------
/internal/assets/dist/xmark-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/xmark-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/xmark-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/xmark-solid.png
--------------------------------------------------------------------------------
/internal/assets/dist/droplet-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/droplet-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/droplet-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/droplet-solid.png
--------------------------------------------------------------------------------
/internal/assets/dist/github-brands-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/github-brands-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/github-brands-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/github-brands-solid.png
--------------------------------------------------------------------------------
/internal/assets/dist/nightscout-transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/nightscout-transparent.png
--------------------------------------------------------------------------------
/internal/assets/dist/square-up-right-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/square-up-right-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/square-up-right-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/square-up-right-solid.png
--------------------------------------------------------------------------------
/internal/assets/dist/rectangle-history-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/rectangle-history-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/rectangle-history-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/rectangle-history-solid.png
--------------------------------------------------------------------------------
/internal/dynamicicon/RobotoCondensed-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/dynamicicon/RobotoCondensed-SemiBold.ttf
--------------------------------------------------------------------------------
/internal/assets/dist/triangle-exclamation-solid.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/triangle-exclamation-solid.ico
--------------------------------------------------------------------------------
/internal/assets/dist/triangle-exclamation-solid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gabe565/nightscout-menu-bar/HEAD/internal/assets/dist/triangle-exclamation-solid.png
--------------------------------------------------------------------------------
/internal/ticker/sleep_notifier.go:
--------------------------------------------------------------------------------
1 | //go:build !darwin
2 |
3 | package ticker
4 |
5 | import "context"
6 |
7 | func (t *Ticker) beginSleepNotifier(_ context.Context) {}
8 |
--------------------------------------------------------------------------------
/internal/nightscout/direction.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | type Direction struct {
4 | Entity string `json:"entity"`
5 | Label string `json:"label"`
6 | Value string `json:"value"`
7 | }
8 |
--------------------------------------------------------------------------------
/internal/assets/gear_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/gear-solid.svg dist/gear-solid.ico
6 | //go:embed dist/gear-solid.ico
7 | var Gear []byte
8 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "local>gabe565/renovate-config",
5 | "local>gabe565/renovate-config:golang"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/internal/assets/quit_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/xmark-solid.svg dist/xmark-solid.ico
6 | //go:embed dist/xmark-solid.ico
7 | var Quit []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/droplet_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/droplet-solid.svg dist/droplet-solid.ico
6 | //go:embed dist/droplet-solid.ico
7 | var Droplet []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/nightscout_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/nightscout.svg dist/nightscout.ico 0
6 | //go:embed dist/nightscout.ico
7 | var Nightscout []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/about_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/github-brands-solid.svg dist/github-brands-solid.ico
6 | //go:embed dist/github-brands-solid.ico
7 | var About []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/gear.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/gear-solid.svg dist/gear-solid.png
8 | //go:embed dist/gear-solid.png
9 | var Gear []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/open_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/square-up-right-solid.svg dist/square-up-right-solid.ico
6 | //go:embed dist/square-up-right-solid.ico
7 | var Open []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/quit.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/xmark-solid.svg dist/xmark-solid.png
8 | //go:embed dist/xmark-solid.png
9 | var Quit []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/droplet.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/droplet-solid.svg dist/droplet-solid.png
8 | //go:embed dist/droplet-solid.png
9 | var Droplet []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/history_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/rectangle-history-solid.svg dist/rectangle-history-solid.ico
6 | //go:embed dist/rectangle-history-solid.ico
7 | var History []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/error_windows.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/triangle-exclamation-solid.svg dist/triangle-exclamation-solid.ico
6 | //go:embed dist/triangle-exclamation-solid.ico
7 | var Error []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/nightscout_darwin.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import _ "embed"
4 |
5 | //go:generate ./convert-icon.sh src/nightscout-transparent.svg dist/nightscout-transparent.png 4
6 | //go:embed dist/nightscout-transparent.png
7 | var Nightscout []byte
8 |
--------------------------------------------------------------------------------
/internal/assets/nightscout.go:
--------------------------------------------------------------------------------
1 | //go:build !(darwin || windows)
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/nightscout.svg dist/nightscout.png 0
8 | //go:embed dist/nightscout.png
9 | var Nightscout []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/about.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/github-brands-solid.svg dist/github-brands-solid.png
8 | //go:embed dist/github-brands-solid.png
9 | var About []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/open.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/square-up-right-solid.svg dist/square-up-right-solid.png
8 | //go:embed dist/square-up-right-solid.png
9 | var Open []byte
10 |
--------------------------------------------------------------------------------
/internal/assets/history.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/rectangle-history-solid.svg dist/rectangle-history-solid.png
8 | //go:embed dist/rectangle-history-solid.png
9 | var History []byte
10 |
--------------------------------------------------------------------------------
/internal/config/unit.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const MmolConversionFactor = 0.0555
4 |
5 | //go:generate go tool enumer -type Unit -linecomment -text
6 |
7 | type Unit uint8
8 |
9 | const (
10 | UnitMgdl Unit = iota // mg/dL
11 | UnitMmol // mmol/L
12 | )
13 |
--------------------------------------------------------------------------------
/internal/assets/error.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package assets
4 |
5 | import _ "embed"
6 |
7 | //go:generate ./convert-icon.sh src/triangle-exclamation-solid.svg dist/triangle-exclamation-solid.png
8 | //go:embed dist/triangle-exclamation-solid.png
9 | var Error []byte
10 |
--------------------------------------------------------------------------------
/internal/nightscout/mgdl.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import "gabe565.com/nightscout-menu-bar/internal/config"
4 |
5 | type Mgdl int
6 |
7 | func (m Mgdl) Mgdl() int { return int(m) }
8 |
9 | func (m Mgdl) Mmol() float64 { return float64(m) * config.MmolConversionFactor }
10 |
--------------------------------------------------------------------------------
/internal/dynamicicon/encode.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package dynamicicon
4 |
5 | import (
6 | "image"
7 | "image/png"
8 | "io"
9 | )
10 |
11 | func encode(w io.Writer, img image.Image) error {
12 | encoder := png.Encoder{CompressionLevel: png.BestSpeed}
13 | return encoder.Encode(w, img)
14 | }
15 |
--------------------------------------------------------------------------------
/internal/tray/items/quit.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | )
7 |
8 | func NewQuit() *systray.MenuItem {
9 | item := systray.AddMenuItem("Quit", "")
10 | item.SetTemplateIcon(assets.Quit, assets.Quit)
11 | return item
12 | }
13 |
--------------------------------------------------------------------------------
/internal/config/options.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Option func(conf *Config)
4 |
5 | func WithVersion(version string) Option {
6 | return func(conf *Config) {
7 | conf.Version = version
8 | }
9 | }
10 |
11 | func WithData(data Data) Option {
12 | return func(conf *Config) {
13 | conf.data.Store(&data)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/internal/tray/items/last_reading.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | )
7 |
8 | func NewLastReading() *systray.MenuItem {
9 | item := systray.AddMenuItem("Last Reading", "")
10 | item.SetTemplateIcon(assets.Droplet, assets.Droplet)
11 | return item
12 | }
13 |
--------------------------------------------------------------------------------
/internal/tray/items/error.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | )
7 |
8 | func NewError() *systray.MenuItem {
9 | item := systray.AddMenuItem("", "")
10 | item.SetTemplateIcon(assets.Error, assets.Error)
11 | item.Disable()
12 | item.Hide()
13 | return item
14 | }
15 |
--------------------------------------------------------------------------------
/internal/tray/items/open_nightscout.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | )
7 |
8 | func NewOpenNightscout(title string) *systray.MenuItem {
9 | item := systray.AddMenuItem("Open "+title, "")
10 | item.SetTemplateIcon(assets.Open, assets.Open)
11 | return item
12 | }
13 |
--------------------------------------------------------------------------------
/internal/tray/messages/messages.go:
--------------------------------------------------------------------------------
1 | package messages
2 |
3 | import "gabe565.com/nightscout-menu-bar/internal/nightscout"
4 |
5 | type ReloadConfigMsg struct{}
6 |
7 | type RenderType uint8
8 |
9 | const (
10 | RenderTypeFetch RenderType = iota
11 | RenderTypeTimestamp
12 | )
13 |
14 | type RenderMessage struct {
15 | Type RenderType
16 | Properties *nightscout.Properties
17 | }
18 |
--------------------------------------------------------------------------------
/internal/config/parser.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/pelletier/go-toml/v2"
5 | )
6 |
7 | type TOMLParser struct{}
8 |
9 | func (p TOMLParser) Unmarshal(b []byte) (map[string]any, error) {
10 | var data map[string]any
11 | err := toml.Unmarshal(b, &data)
12 | return data, err
13 | }
14 |
15 | func (p TOMLParser) Marshal(o map[string]any) ([]byte, error) {
16 | return toml.Marshal(o)
17 | }
18 |
--------------------------------------------------------------------------------
/.github/changelog-generator.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://gabe565.github.io/changelog-generator/config.schema.json
2 | filters:
3 | exclude:
4 | - "^docs"
5 | - "^test"
6 | groups:
7 | - title: Features
8 | order: 0
9 | regexp: "^(feat)"
10 | - title: Fixes
11 | order: 1
12 | regexp: "^(fix|perf)"
13 | - title: Dependencies
14 | order: 999
15 | regexp: "\\(deps\\):"
16 | - title: Others
17 | order: 998
18 |
--------------------------------------------------------------------------------
/internal/tray/items/history.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | )
7 |
8 | type History struct {
9 | *systray.MenuItem
10 | Subitems []*systray.MenuItem
11 | }
12 |
13 | func NewHistory() History {
14 | item := systray.AddMenuItem("History", "")
15 | item.SetTemplateIcon(assets.History, assets.History)
16 | vals := make([]*systray.MenuItem, 0, 4)
17 | return History{item, vals}
18 | }
19 |
--------------------------------------------------------------------------------
/internal/config/duration.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Duration struct {
8 | time.Duration
9 | }
10 |
11 | func (d Duration) MarshalText() ([]byte, error) {
12 | s := d.String()
13 | return []byte(s), nil
14 | }
15 |
16 | func (d *Duration) UnmarshalText(text []byte) error {
17 | duration, err := time.ParseDuration(string(text))
18 | if err != nil {
19 | return err
20 | }
21 |
22 | d.Duration = duration
23 | return nil
24 | }
25 |
--------------------------------------------------------------------------------
/internal/util/date.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | func GetNextMinChange(t time.Time, round bool) time.Duration {
8 | if round {
9 | // Offset time by 30s since output is rounded
10 | t = t.Add(-30 * time.Second)
11 | }
12 | // Time since last update
13 | duration := time.Since(t)
14 | // Only keep seconds
15 | duration %= time.Minute
16 | // Time until rounded output changes
17 | duration = time.Minute - duration
18 | return duration
19 | }
20 |
--------------------------------------------------------------------------------
/internal/assets/convert-icon.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 |
5 | HEIGHT=64
6 | PAD="${3:-8}"
7 | DENSITY=144
8 |
9 | BASEHEIGHT="$(bc <<< "$HEIGHT - $PAD")"
10 |
11 | SRC="$1"
12 | DEST="$2"
13 |
14 | inkscape "$SRC" \
15 | --export-height="$BASEHEIGHT" \
16 | --export-type=png \
17 | --export-filename=- \
18 | | magick - \
19 | -strip \
20 | -background transparent \
21 | -gravity center \
22 | -extent "${HEIGHT}x${HEIGHT}" \
23 | -units PixelsPerInch \
24 | -density "$DENSITY" \
25 | "$DEST"
26 |
--------------------------------------------------------------------------------
/internal/assets/src/xmark-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/assets/src/droplet-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/tray/items/about.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "strings"
5 |
6 | "fyne.io/systray"
7 | "gabe565.com/nightscout-menu-bar/internal/assets"
8 | )
9 |
10 | func NewAbout(version string) *systray.MenuItem {
11 | title := "Nightscout Menu Bar"
12 | if version != "" {
13 | if strings.HasPrefix(version, "v") {
14 | title += " " + version
15 | } else {
16 | title += " (" + version + ")"
17 | }
18 | }
19 | item := systray.AddMenuItem(title, "")
20 | item.SetTemplateIcon(assets.About, assets.About)
21 | return item
22 | }
23 |
--------------------------------------------------------------------------------
/assets/darwin/cask/nightscout-menu-bar.rb.tmpl:
--------------------------------------------------------------------------------
1 | cask "nightscout-menu-bar" do
2 | desc "Display live blood sugar data from Nightscout in your system menu bar"
3 | homepage "https://github.com/gabe565/nightscout-menu-bar"
4 | {{- if .Version }}
5 | version "{{ trimPrefix "v" .Version }}"
6 | {{- else }}
7 | version :latest
8 | {{- end }}
9 |
10 | url "https://github.com/gabe565/nightscout-menu-bar/releases/download/{{ .Version }}/nightscout-menu-bar_darwin.tar.gz"
11 | sha256 "{{ .SHA256 }}"
12 |
13 | app "Nightscout Menu Bar.app"
14 | end
15 |
--------------------------------------------------------------------------------
/internal/nightscout/mgdl_test.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestMgdl_ToMmol(t *testing.T) {
10 | t.Parallel()
11 | tests := []struct {
12 | name string
13 | m Mgdl
14 | want float64
15 | }{
16 | {"100", Mgdl(100), 5.55},
17 | {"50", Mgdl(50), 2.775},
18 | {"300", Mgdl(300), 16.65},
19 | }
20 | for _, tt := range tests {
21 | t.Run(tt.name, func(t *testing.T) {
22 | t.Parallel()
23 | assert.InDelta(t, tt.want, tt.m.Mmol(), 0.001)
24 | })
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/internal/util/http.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "runtime"
5 | "strings"
6 |
7 | "gabe565.com/utils/httpx"
8 | )
9 |
10 | func NewUserAgentTransport(name, version string) *httpx.UserAgentTransport {
11 | ua := name + "/"
12 | commit := strings.TrimPrefix(GetCommit(), "*")
13 | if version != "" {
14 | ua += version
15 | if commit != "" {
16 | ua += "-" + commit
17 | }
18 | } else if commit != "" {
19 | ua += commit
20 | }
21 | ua += " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
22 |
23 | return httpx.NewUserAgentTransport(nil, ua)
24 | }
25 |
--------------------------------------------------------------------------------
/internal/assets/src/rectangle-history-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/hack/rasterize-icons.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | cd "$(git rev-parse --show-toplevel)/assets"
5 |
6 | rm -rf png
7 | mkdir -p png
8 |
9 | for SIZE in 16 32 48 64 128 256 512; do (
10 | DEST="png/icon_${SIZE}x${SIZE}.png"
11 | basename "$DEST"
12 |
13 | inkscape icon.svg \
14 | --export-height="$SIZE" \
15 | --export-type=png \
16 | --export-filename=- \
17 | | convert - \
18 | -strip \
19 | -background transparent \
20 | -gravity center \
21 | -extent "${SIZE}x${SIZE}" \
22 | "$DEST"
23 | ) done
24 |
--------------------------------------------------------------------------------
/internal/nightscout/sgv.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import "encoding/json"
4 |
5 | type SGV struct {
6 | ID string `json:"_id"`
7 | Device string `json:"device"`
8 | Direction string `json:"direction"`
9 | Filtered json.Number `json:"filtered"`
10 | Mgdl Mgdl `json:"mgdl"`
11 | Mills Mills `json:"mills"`
12 | Noise json.Number `json:"noise"`
13 | Rssi json.Number `json:"rssi"`
14 | Scaled json.Number `json:"scaled"`
15 | Type string `json:"type"`
16 | Unfiltered json.Number `json:"unfiltered"`
17 | }
18 |
--------------------------------------------------------------------------------
/internal/assets/src/triangle-exclamation-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/assets/src/square-up-right-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/util/version.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "runtime/debug"
4 |
5 | func GetCommit() string {
6 | var commit string
7 | var modified bool
8 | if info, ok := debug.ReadBuildInfo(); ok {
9 | for _, setting := range info.Settings {
10 | switch setting.Key {
11 | case "vcs.revision":
12 | commit = setting.Value
13 | case "vcs.modified":
14 | if setting.Value == "true" {
15 | modified = true
16 | }
17 | }
18 | }
19 | }
20 |
21 | if commit != "" {
22 | if len(commit) > 8 {
23 | commit = commit[:8]
24 | }
25 | if modified {
26 | commit = "*" + commit
27 | }
28 | }
29 | return commit
30 | }
31 |
--------------------------------------------------------------------------------
/internal/pprof/enabled.go:
--------------------------------------------------------------------------------
1 | //go:build pprof
2 |
3 | package pprof
4 |
5 | import (
6 | "log/slog"
7 | "net/http"
8 | _ "net/http/pprof" //nolint:gosec
9 | "os"
10 | "time"
11 | )
12 |
13 | func ListenAndServe() {
14 | go func() {
15 | addr := "127.0.0.1:6060"
16 | if env := os.Getenv("PPROF_ADDR"); env != "" {
17 | addr = env
18 | }
19 |
20 | server := &http.Server{
21 | Addr: addr,
22 | ReadTimeout: 10 * time.Second,
23 | }
24 | slog.Info("Starting pprof server", "address", server.Addr)
25 | if err := server.ListenAndServe(); err != nil {
26 | slog.Error("Failed to start pprof server", "error", err.Error())
27 | }
28 | }()
29 | }
30 |
--------------------------------------------------------------------------------
/internal/generate/config_example/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/pelletier/go-toml/v2"
9 | )
10 |
11 | func main() {
12 | if err := createConfig(); err != nil {
13 | slog.Error(err.Error())
14 | os.Exit(1)
15 | }
16 | }
17 |
18 | func createConfig() error {
19 | conf := config.New()
20 | conf.InitLog(os.Stderr)
21 | data := conf.Data()
22 |
23 | f, err := os.Create("config_example.toml")
24 | if err != nil {
25 | return err
26 | }
27 |
28 | if err := toml.NewEncoder(f).Encode(&data); err != nil {
29 | return err
30 | }
31 |
32 | return f.Close()
33 | }
34 |
--------------------------------------------------------------------------------
/internal/util/path.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strings"
7 | )
8 |
9 | func ResolvePath(path string) string {
10 | var prefixHome bool
11 | if strings.HasPrefix(path, "$HOME") {
12 | path = strings.TrimPrefix(path, "$HOME")
13 | prefixHome = true
14 | } else if strings.HasPrefix(path, "~") {
15 | path = strings.TrimPrefix(path, "~")
16 | prefixHome = true
17 | }
18 |
19 | if prefixHome {
20 | if home, err := os.UserHomeDir(); err == nil {
21 | path = filepath.Join(home, path)
22 | }
23 | }
24 |
25 | if strings.HasPrefix(path, "$TMPDIR") {
26 | path = strings.TrimPrefix(path, "$TMPDIR")
27 | path = filepath.Join(os.TempDir(), path)
28 | }
29 |
30 | return path
31 | }
32 |
--------------------------------------------------------------------------------
/assets/darwin/info/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | _ "embed"
5 | "io"
6 | "os"
7 | "strings"
8 | "text/template"
9 |
10 | "github.com/Masterminds/sprig/v3"
11 | flag "github.com/spf13/pflag"
12 | )
13 |
14 | //go:embed info.plist
15 | var spec string
16 |
17 | type SpecVars struct {
18 | Version string
19 | }
20 |
21 | func main() {
22 | var values SpecVars
23 | flag.StringVar(&values.Version, "version", "", "Version")
24 | flag.Parse()
25 |
26 | tmpl, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(spec)
27 | if err != nil {
28 | panic(err)
29 | }
30 |
31 | var buf strings.Builder
32 | if err := tmpl.Execute(&buf, values); err != nil {
33 | panic(err)
34 | }
35 |
36 | _, _ = io.WriteString(os.Stdout, buf.String())
37 | }
38 |
--------------------------------------------------------------------------------
/internal/nightscout/properties.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "gabe565.com/nightscout-menu-bar/internal/config"
5 | )
6 |
7 | type Properties struct {
8 | Bgnow Reading `json:"bgnow"`
9 | Buckets []Reading `json:"buckets"`
10 | Delta Delta `json:"delta"`
11 | Direction Direction `json:"direction"`
12 | }
13 |
14 | func (p Properties) String(data config.Data) string {
15 | result := p.Bgnow.DisplayBg(data.Units) +
16 | " " + p.Bgnow.Arrow(data.Arrows)
17 | if delta := p.Delta.Display(data.Units); delta != "" {
18 | result += " " + p.Delta.Display(data.Units)
19 | }
20 | if rel := p.Bgnow.Mills.Relative(data.Advanced.RoundAge); rel != "" {
21 | result += " [" + p.Bgnow.Mills.Relative(data.Advanced.RoundAge) + "]"
22 | }
23 | return result
24 | }
25 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v5.0.0
4 | hooks:
5 | - id: trailing-whitespace
6 | - id: end-of-file-fixer
7 | exclude_types: [svg]
8 | - id: fix-byte-order-marker
9 | - id: mixed-line-ending
10 | - id: check-merge-conflict
11 | - id: check-case-conflict
12 |
13 | - repo: https://github.com/TekWizely/pre-commit-golang
14 | rev: v1.0.0-rc.1
15 | hooks:
16 | - id: go-mod-tidy-repo
17 | - id: golangci-lint-mod
18 | args: [--fix]
19 |
20 | - repo: local
21 | hooks:
22 | - id: config-example
23 | name: config-example
24 | entry: go run ./internal/generate/config_example
25 | language: system
26 | types: [go]
27 | pass_filenames: false
28 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/socket.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/config"
6 | )
7 |
8 | func NewSocket(conf *config.Config, parent *systray.MenuItem) Socket {
9 | item := Socket{config: conf}
10 | item.MenuItem = parent.AddSubMenuItemCheckbox(
11 | "Expose readings over local socket",
12 | "",
13 | conf.Data().Socket.Enabled,
14 | )
15 | return item
16 | }
17 |
18 | type Socket struct {
19 | config *config.Config
20 | *systray.MenuItem
21 | }
22 |
23 | func (s Socket) Toggle() error {
24 | if s.Checked() {
25 | s.Uncheck()
26 | } else {
27 | s.Check()
28 | }
29 |
30 | data := s.config.Data()
31 | data.Socket.Enabled = s.Checked()
32 | if err := s.config.Write(data); err != nil {
33 | return err
34 | }
35 | return nil
36 | }
37 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/dynamic_icon.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/config"
6 | )
7 |
8 | func NewDynamicIcon(conf *config.Config, parent *systray.MenuItem) DynamicIcon {
9 | item := DynamicIcon{config: conf}
10 | item.MenuItem = parent.AddSubMenuItemCheckbox(
11 | "Enabled",
12 | "",
13 | conf.Data().DynamicIcon.Enabled,
14 | )
15 | return item
16 | }
17 |
18 | type DynamicIcon struct {
19 | config *config.Config
20 | *systray.MenuItem
21 | }
22 |
23 | func (l DynamicIcon) Toggle() error {
24 | if l.Checked() {
25 | l.Uncheck()
26 | } else {
27 | l.Check()
28 | }
29 |
30 | data := l.config.Data()
31 | data.DynamicIcon.Enabled = l.Checked()
32 | if err := l.config.Write(data); err != nil {
33 | return err
34 | }
35 | return nil
36 | }
37 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 | "os/signal"
8 | "syscall"
9 |
10 | "gabe565.com/nightscout-menu-bar/internal/config"
11 | "gabe565.com/nightscout-menu-bar/internal/pprof"
12 | "gabe565.com/nightscout-menu-bar/internal/tray"
13 | "gabe565.com/nightscout-menu-bar/internal/util"
14 | "gabe565.com/utils/slogx"
15 | )
16 |
17 | var version string
18 |
19 | func main() {
20 | config.InitLog(os.Stderr, slogx.LevelInfo, slogx.FormatAuto)
21 |
22 | if version == "" {
23 | version = "beta"
24 | }
25 | slog.Info("Nightscout Menu Bar", "version", version, "commit", util.GetCommit())
26 |
27 | pprof.ListenAndServe()
28 |
29 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT)
30 | defer cancel()
31 |
32 | tray.New(version).Run(ctx)
33 | }
34 |
--------------------------------------------------------------------------------
/internal/tray/systray_test.go:
--------------------------------------------------------------------------------
1 | package tray
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestNew(t *testing.T) {
12 | t.Parallel()
13 | tray := New("")
14 | assert.NotNil(t, tray)
15 | assert.NotNil(t, tray.config)
16 | assert.NotNil(t, tray.ticker)
17 | }
18 |
19 | func TestTray_onError(t *testing.T) {
20 | t.Parallel()
21 | tray := New("")
22 |
23 | ctx, cancel := context.WithTimeout(t.Context(), time.Second)
24 | t.Cleanup(cancel)
25 |
26 | go func() {
27 | select {
28 | case msg := <-tray.bus:
29 | err, ok := msg.(error)
30 | assert.True(t, ok)
31 | assert.Error(t, err)
32 | case <-ctx.Done():
33 | return
34 | }
35 | }()
36 |
37 | select {
38 | case tray.bus <- context.DeadlineExceeded:
39 | case <-ctx.Done():
40 | }
41 | assert.NoError(t, ctx.Err())
42 | }
43 |
--------------------------------------------------------------------------------
/internal/ticker/sleep_notifier_darwin.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "time"
7 |
8 | "github.com/prashantgupta24/mac-sleep-notifier/notifier"
9 | )
10 |
11 | func (t *Ticker) beginSleepNotifier(ctx context.Context) {
12 | go func() {
13 | notify := ¬ifier.Notifier{}
14 | notifyCh := notify.Start()
15 | defer close(notifyCh)
16 | defer notify.Quit()
17 |
18 | for {
19 | select {
20 | case <-ctx.Done():
21 | return
22 | case activity := <-notifyCh:
23 | logger := slog.With("reason", activity.Type)
24 | switch activity.Type {
25 | case notifier.Awake:
26 | logger.Info("Starting timers")
27 | t.renderTicker.Reset(time.Second)
28 | t.fetchTicker.Reset(time.Second)
29 | case notifier.Sleep:
30 | logger.Info("Stopping timers")
31 | t.fetchTicker.Stop()
32 | t.renderTicker.Stop()
33 | }
34 | }
35 | }
36 | }()
37 | }
38 |
--------------------------------------------------------------------------------
/internal/nightscout/mills.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | "time"
7 | )
8 |
9 | type Mills struct {
10 | time.Time
11 | }
12 |
13 | func (m *Mills) UnmarshalJSON(bytes []byte) error {
14 | var mills int64
15 | if err := json.Unmarshal(bytes, &mills); err != nil {
16 | return err
17 | }
18 | m.Time = time.UnixMilli(mills)
19 | return nil
20 | }
21 |
22 | func (m *Mills) MarshalJSON() ([]byte, error) {
23 | return json.Marshal(m.UnixMilli())
24 | }
25 |
26 | func (m *Mills) Relative(round bool) string {
27 | if m.Unix() == 0 {
28 | return ""
29 | }
30 |
31 | // Drop resolution to minutes
32 | duration := time.Since(m.Time)
33 | if round {
34 | duration = duration.Round(time.Minute)
35 | } else {
36 | duration = duration.Truncate(time.Minute)
37 | }
38 |
39 | str := duration.String()
40 | str = strings.TrimSuffix(str, "0s")
41 | if str == "" {
42 | str = "0m"
43 | }
44 | return str
45 | }
46 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/url.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "errors"
5 |
6 | "fyne.io/systray"
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/ncruces/zenity"
9 | )
10 |
11 | func NewURL(conf *config.Config, parent *systray.MenuItem) URL {
12 | item := URL{config: conf}
13 | item.MenuItem = parent.AddSubMenuItem("Nightscout URL", "")
14 | return item
15 | }
16 |
17 | type URL struct {
18 | config *config.Config
19 | *systray.MenuItem
20 | }
21 |
22 | func (n URL) Prompt() error {
23 | url, err := zenity.Entry(
24 | "Enter new Nightscout URL:",
25 | zenity.Title("Nightscout URL"),
26 | zenity.EntryText(n.config.Data().URL),
27 | )
28 | if err != nil {
29 | if errors.Is(err, zenity.ErrCanceled) {
30 | return nil
31 | }
32 | return err
33 | }
34 |
35 | data := n.config.Data()
36 | data.URL = url
37 | if err := n.config.Write(data); err != nil {
38 | return err
39 | }
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/internal/tray/items/items.go:
--------------------------------------------------------------------------------
1 | package items
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/config"
6 | "gabe565.com/nightscout-menu-bar/internal/tray/items/preferences"
7 | )
8 |
9 | type Items struct {
10 | LastReading *systray.MenuItem
11 | Error *systray.MenuItem
12 | OpenNightscout *systray.MenuItem
13 | History History
14 | Preferences preferences.Preferences
15 | About *systray.MenuItem
16 | Quit *systray.MenuItem
17 | }
18 |
19 | func New(conf *config.Config) Items {
20 | var items Items
21 |
22 | items.LastReading = NewLastReading()
23 | items.Error = NewError()
24 | systray.AddSeparator()
25 |
26 | items.OpenNightscout = NewOpenNightscout(conf.Data().Title)
27 | items.History = NewHistory()
28 | systray.AddSeparator()
29 |
30 | items.Preferences = preferences.New(conf)
31 | items.About = NewAbout(conf.Version)
32 | items.Quit = NewQuit()
33 |
34 | return items
35 | }
36 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/token.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "errors"
5 |
6 | "fyne.io/systray"
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/ncruces/zenity"
9 | )
10 |
11 | func NewToken(config *config.Config, parent *systray.MenuItem) Token {
12 | token := Token{config: config}
13 | token.MenuItem = parent.AddSubMenuItem("API Token", "")
14 | return token
15 | }
16 |
17 | type Token struct {
18 | config *config.Config
19 | *systray.MenuItem
20 | }
21 |
22 | func (n Token) Prompt() error {
23 | token, err := zenity.Entry(
24 | "Enter new Nightscout API token:",
25 | zenity.Title("Nightscout API Token"),
26 | zenity.EntryText(n.config.Data().Token),
27 | )
28 | if err != nil {
29 | if errors.Is(err, zenity.ErrCanceled) {
30 | return nil
31 | }
32 | return err
33 | }
34 |
35 | data := n.config.Data()
36 | data.Token = token
37 | if err := n.config.Write(data); err != nil {
38 | return err
39 | }
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/dynamic_icon_color.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "errors"
5 |
6 | "fyne.io/systray"
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/ncruces/zenity"
9 | )
10 |
11 | func NewDynamicIconColor(conf *config.Config, parent *systray.MenuItem) DynamicIconColor {
12 | item := DynamicIconColor{config: conf}
13 | item.MenuItem = parent.AddSubMenuItem("Color", "")
14 | return item
15 | }
16 |
17 | type DynamicIconColor struct {
18 | config *config.Config
19 | *systray.MenuItem
20 | }
21 |
22 | func (l DynamicIconColor) Choose() error {
23 | c, err := zenity.SelectColor(
24 | zenity.Title("Dynamic Icon Color"),
25 | zenity.Color(l.config.Data().DynamicIcon.FontColor),
26 | )
27 | if err != nil {
28 | if errors.Is(err, zenity.ErrCanceled) {
29 | return nil
30 | }
31 | return err
32 | }
33 |
34 | data := l.config.Data()
35 | data.DynamicIcon.FontColor.Color = c
36 | if err := l.config.Write(data); err != nil {
37 | return err
38 | }
39 | return nil
40 | }
41 |
--------------------------------------------------------------------------------
/internal/autostart/autostart.go:
--------------------------------------------------------------------------------
1 | package autostart
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | "github.com/emersion/go-autostart"
8 | )
9 |
10 | func NewApp() (autostart.App, error) {
11 | executable, err := os.Executable()
12 | if err != nil {
13 | return autostart.App{}, err
14 | }
15 |
16 | return autostart.App{
17 | Name: "com.gabe565.nightscout-menu-bar",
18 | DisplayName: "Nightscout Menu Bar",
19 | Exec: []string{executable},
20 | }, nil
21 | }
22 |
23 | func Enable() error {
24 | app, err := NewApp()
25 | if err != nil {
26 | return err
27 | }
28 | slog.Debug("Enabling autostart")
29 | return app.Enable()
30 | }
31 |
32 | func Disable() error {
33 | app, err := NewApp()
34 | if err != nil {
35 | return err
36 | }
37 | slog.Debug("Disabling autostart")
38 | return app.Disable()
39 | }
40 |
41 | func IsEnabled() (bool, error) {
42 | app, err := NewApp()
43 | if err != nil {
44 | return false, err
45 | }
46 | v := app.IsEnabled()
47 | slog.Debug("Detected autostart status", "value", v)
48 | return v, nil
49 | }
50 |
--------------------------------------------------------------------------------
/internal/config/log.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io"
5 | "log/slog"
6 | "os"
7 | "time"
8 |
9 | "gabe565.com/utils/slogx"
10 | "github.com/lmittmann/tint"
11 | "github.com/mattn/go-isatty"
12 | )
13 |
14 | func (conf *Config) InitLog(w io.Writer) {
15 | data := conf.Data()
16 | InitLog(w, data.Log.Level, data.Log.Format)
17 | }
18 |
19 | func InitLog(w io.Writer, level slogx.Level, format slogx.Format) {
20 | switch format {
21 | case slogx.FormatJSON:
22 | slog.SetDefault(slog.New(
23 | slog.NewJSONHandler(w, &slog.HandlerOptions{
24 | Level: slog.Level(level),
25 | }),
26 | ))
27 | default:
28 | var color bool
29 | switch format {
30 | case slogx.FormatAuto:
31 | if f, ok := w.(*os.File); ok {
32 | color = isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
33 | }
34 | case slogx.FormatColor:
35 | color = true
36 | }
37 |
38 | slog.SetDefault(slog.New(
39 | tint.NewHandler(w, &tint.Options{
40 | Level: slog.Level(level),
41 | TimeFormat: time.DateTime,
42 | NoColor: !color,
43 | }),
44 | ))
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/units.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "errors"
5 |
6 | "fyne.io/systray"
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/ncruces/zenity"
9 | )
10 |
11 | func NewUnits(config *config.Config, parent *systray.MenuItem) Units {
12 | item := Units{config: config}
13 | item.MenuItem = parent.AddSubMenuItem("Units", "")
14 | return item
15 | }
16 |
17 | type Units struct {
18 | config *config.Config
19 | *systray.MenuItem
20 | }
21 |
22 | func (n Units) Prompt() error {
23 | unit, err := zenity.List(
24 | "Select units:",
25 | config.UnitStrings(),
26 | zenity.Title("Nightscout Units"),
27 | zenity.DisallowEmpty(),
28 | zenity.DefaultItems(n.config.Data().Units.String()),
29 | )
30 | if err != nil {
31 | if errors.Is(err, zenity.ErrCanceled) {
32 | return nil
33 | }
34 | return err
35 | }
36 |
37 | data := n.config.Data()
38 | if err := data.Units.UnmarshalText([]byte(unit)); err != nil {
39 | return err
40 | }
41 | if err := n.config.Write(data); err != nil {
42 | return err
43 | }
44 | return nil
45 | }
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Gabe Cook
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/hack/build-windows.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | BINARY_NAME='Nightscout Menu Bar'
4 | VERSION="${VERSION:-}"
5 | WIN_VERSION="$VERSION"
6 | if [[ -n "$WIN_VERSION" ]]; then
7 | WIN_VERSION="${WIN_VERSION#v}"
8 | WIN_VERSION+='.0'
9 | fi
10 |
11 | set -euo pipefail
12 |
13 | cd "$(git rev-parse --show-toplevel)"
14 |
15 | rm -rf -- dist/*.exe assets/windows/*.png *.syso
16 | mkdir -p dist
17 |
18 | command -v go-winres &>/dev/null || go install github.com/tc-hib/go-winres@latest
19 |
20 | # Generate metadata
21 | cp -a assets/{png/icon_16x16,windows/icon16}.png
22 | cp -a assets/{png/icon_32x32,windows/icon32}.png
23 | cp -a assets/{png/icon_48x48,windows/icon48}.png
24 | cp -a assets/{png/icon_64x64,windows/icon64}.png
25 | cp -a assets/{png/icon_128x128,windows/icon128}.png
26 | cp -a assets/{png/icon_256x256,windows/icon256}.png
27 | go-winres make --arch=amd64,arm64 --in=assets/windows/winres.json --product-version="$WIN_VERSION" --file-version="$WIN_VERSION"
28 |
29 | # Build binary
30 | export GOOS=windows CGO_ENABLED=1
31 | echo Build "$BINARY_NAME.exe"
32 | go build -ldflags="-w -s -H=windowsgui -X main.version=$VERSION" -trimpath -o "dist/$BINARY_NAME.exe" .
33 |
--------------------------------------------------------------------------------
/internal/nightscout/delta.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "encoding/json"
5 | "math"
6 | "strconv"
7 |
8 | "gabe565.com/nightscout-menu-bar/internal/config"
9 | )
10 |
11 | type Times struct {
12 | Previous Mills `json:"previous"`
13 | Recent Mills `json:"recent"`
14 | }
15 |
16 | type Delta struct {
17 | Absolute json.Number `json:"absolute"`
18 | DisplayVal string `json:"display"`
19 | ElapsedMins json.Number `json:"elapsedMins"`
20 | Interpolated bool `json:"interpolated"`
21 | Mean5MinsAgo json.Number `json:"mean5MinsAgo"`
22 | Mgdl Mgdl `json:"mgdl"`
23 | Previous Reading `json:"previous"`
24 | Scaled json.Number `json:"scaled"`
25 | Times Times `json:"times"`
26 | }
27 |
28 | func (d Delta) Display(units config.Unit) string {
29 | if units == config.UnitMmol {
30 | mmol := d.Mgdl.Mmol()
31 | mmol = math.Round(mmol*10) / 10
32 | f := strconv.FormatFloat(mmol, 'f', -1, 64)
33 | if mmol >= 0 {
34 | return "+" + f
35 | }
36 | return f
37 | }
38 |
39 | mgdl := d.Mgdl.Mgdl()
40 | val := strconv.Itoa(mgdl)
41 | if mgdl >= 0 {
42 | return "+" + val
43 | }
44 | return val
45 | }
46 |
--------------------------------------------------------------------------------
/assets/darwin/cask/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/sha256"
5 | _ "embed"
6 | "encoding/hex"
7 | "io"
8 | "os"
9 | "strings"
10 | "text/template"
11 |
12 | "github.com/Masterminds/sprig/v3"
13 | flag "github.com/spf13/pflag"
14 | )
15 |
16 | //go:embed nightscout-menu-bar.rb.tmpl
17 | var spec string
18 |
19 | type SpecVars struct {
20 | Path string
21 | Version string
22 | SHA256 string
23 | }
24 |
25 | func main() {
26 | var values SpecVars
27 | flag.StringVar(&values.Path, "path", "", "Binary path")
28 | flag.StringVar(&values.Version, "version", "", "Version")
29 | flag.Parse()
30 |
31 | tmpl, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(spec)
32 | if err != nil {
33 | panic(err)
34 | }
35 |
36 | binary, err := os.Open(values.Path)
37 | if err != nil {
38 | panic(err)
39 | }
40 |
41 | h := sha256.New()
42 | if _, err := io.Copy(h, binary); err != nil {
43 | panic(err)
44 | }
45 | _ = binary.Close()
46 | values.SHA256 = hex.EncodeToString(h.Sum(nil))
47 |
48 | var buf strings.Builder
49 | if err := tmpl.Execute(&buf, values); err != nil {
50 | panic(err)
51 | }
52 |
53 | _, _ = io.WriteString(os.Stdout, buf.String())
54 | }
55 |
--------------------------------------------------------------------------------
/assets/darwin/info/info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundlePackageType
6 | APPL
7 | CFBundleInfoDictionaryVersion
8 | 6.0
9 | CFBundleName
10 | Nightscout Menu Bar
11 | CFBundleExecutable
12 | MacOS/nightscout-menu-bar
13 | CFBundleIdentifier
14 | com.gabe565.nightscout-menu-bar
15 | CFBundleGetInfoString
16 | Nightscout Menu Bar by gabe565
17 | {{- with .Version }}
18 | {{- $version := trimPrefix "v" . }}
19 | CFBundleVersion
20 | {{ $version }}
21 | CFBundleShortVersionString
22 | {{ $version }}
23 | {{- end }}
24 | CFBundleIconFile
25 | Nightscout.icns
26 | LSUIElement
27 |
28 | LSRequiresNativeExecution
29 |
30 | NSHumanReadableCopyright
31 | © {{ now | date "2006" }} Gabe Cook
32 |
33 |
34 |
--------------------------------------------------------------------------------
/internal/nightscout/properties_test.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestProperties_String(t *testing.T) {
12 | t.Parallel()
13 | type fields struct {
14 | Bgnow Reading
15 | Buckets []Reading
16 | Delta Delta
17 | Direction Direction
18 | }
19 | type args struct {
20 | data config.Data
21 | }
22 | tests := []struct {
23 | name string
24 | fields fields
25 | args args
26 | want string
27 | }{
28 | {
29 | "mgdl",
30 | fields{
31 | Bgnow: Reading{
32 | Last: 100,
33 | Mills: Mills{time.Now()},
34 | Sgvs: []SGV{{Direction: "Flat"}},
35 | },
36 | Delta: Delta{Mgdl: 1},
37 | },
38 | args{config.New().Data()},
39 | "100 → +1 [0m]",
40 | },
41 | }
42 | for _, tt := range tests {
43 | t.Run(tt.name, func(t *testing.T) {
44 | t.Parallel()
45 | p := Properties{
46 | Bgnow: tt.fields.Bgnow,
47 | Buckets: tt.fields.Buckets,
48 | Delta: tt.fields.Delta,
49 | Direction: tt.fields.Direction,
50 | }
51 | assert.Equal(t, tt.want, p.String(tt.args.data))
52 | })
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/internal/assets/src/gear-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/ticker/render.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "time"
7 |
8 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
9 | "gabe565.com/nightscout-menu-bar/internal/tray/messages"
10 | "gabe565.com/nightscout-menu-bar/internal/util"
11 | )
12 |
13 | func (t *Ticker) beginRender(ctx context.Context) chan<- *nightscout.Properties {
14 | renderCh := make(chan *nightscout.Properties)
15 | go func() {
16 | defer close(renderCh)
17 | t.renderTicker = time.NewTicker(5 * time.Minute)
18 | defer t.renderTicker.Stop()
19 | var properties *nightscout.Properties
20 | for {
21 | var renderType messages.RenderType
22 | select {
23 | case <-ctx.Done():
24 | return
25 | case p := <-renderCh:
26 | renderType = messages.RenderTypeFetch
27 | properties = p
28 | case <-t.renderTicker.C:
29 | renderType = messages.RenderTypeTimestamp
30 | }
31 | if properties != nil {
32 | t.bus <- messages.RenderMessage{
33 | Type: renderType,
34 | Properties: properties,
35 | }
36 | d := util.GetNextMinChange(properties.Bgnow.Mills.Time, t.config.Data().Advanced.RoundAge)
37 | t.renderTicker.Reset(d)
38 | slog.Debug("Scheduled next render", "in", d)
39 | } else {
40 | t.renderTicker.Reset(5 * time.Minute)
41 | }
42 | }
43 | }()
44 | return renderCh
45 | }
46 |
--------------------------------------------------------------------------------
/internal/ticker/ticker.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "time"
7 |
8 | "gabe565.com/nightscout-menu-bar/internal/config"
9 | "gabe565.com/nightscout-menu-bar/internal/fetch"
10 | "gabe565.com/nightscout-menu-bar/internal/socket"
11 | )
12 |
13 | func New(conf *config.Config, updateCh chan<- any) *Ticker {
14 | t := &Ticker{
15 | config: conf,
16 | fetch: fetch.NewFetch(conf),
17 | socket: socket.New(conf),
18 | bus: updateCh,
19 | }
20 |
21 | conf.AddCallback(t.reloadConfig)
22 | return t
23 | }
24 |
25 | type Ticker struct {
26 | cancel context.CancelFunc
27 |
28 | config *config.Config
29 | fetch *fetch.Fetch
30 | socket *socket.Socket
31 |
32 | fetchTicker *time.Ticker
33 | renderTicker *time.Ticker
34 | bus chan<- any
35 | }
36 |
37 | func (t *Ticker) Start(ctx context.Context) {
38 | ctx, t.cancel = context.WithCancel(ctx)
39 | renderCh := t.beginRender(ctx)
40 | t.beginFetch(ctx, renderCh)
41 | t.beginSleepNotifier(ctx)
42 | }
43 |
44 | func (t *Ticker) reloadConfig() {
45 | if t.renderTicker != nil {
46 | t.renderTicker.Reset(time.Millisecond)
47 | }
48 | t.fetch.Reset()
49 | if t.fetchTicker != nil {
50 | t.fetchTicker.Reset(time.Millisecond)
51 | }
52 | }
53 |
54 | func (t *Ticker) Close() {
55 | if t.cancel != nil {
56 | t.cancel()
57 | }
58 | if err := t.socket.Close(); err != nil {
59 | slog.Error("Failed to cleanup socket", "error", err)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/internal/ticker/fetch.go:
--------------------------------------------------------------------------------
1 | package ticker
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log/slog"
7 | "time"
8 |
9 | "gabe565.com/nightscout-menu-bar/internal/fetch"
10 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
11 | )
12 |
13 | func (t *Ticker) beginFetch(ctx context.Context, render chan<- *nightscout.Properties) {
14 | go func() {
15 | t.fetchTicker = time.NewTicker(time.Millisecond)
16 | defer t.fetchTicker.Stop()
17 | for {
18 | select {
19 | case <-ctx.Done():
20 | return
21 | case <-t.fetchTicker.C:
22 | next := t.Fetch(render)
23 | t.fetchTicker.Reset(next)
24 | slog.Debug("Scheduled next fetch", "in", next)
25 | }
26 | }
27 | }()
28 | }
29 |
30 | func (t *Ticker) Fetch(render chan<- *nightscout.Properties) time.Duration {
31 | properties, err := t.fetch.Do(context.Background())
32 | if err != nil && !errors.Is(err, fetch.ErrNotModified) {
33 | t.bus <- err
34 | }
35 | data := t.config.Data()
36 | if properties != nil {
37 | if render != nil {
38 | render <- properties
39 | }
40 | if data.Socket.Enabled {
41 | t.socket.Write(properties)
42 | }
43 | if len(properties.Buckets) != 0 {
44 | bucket := properties.Buckets[0]
45 | lastDiff := bucket.ToMills.Sub(bucket.FromMills.Time)
46 | nextRead := properties.Bgnow.Mills.Add(lastDiff + data.Advanced.FetchDelay.Duration)
47 | if until := time.Until(nextRead); until > 0 {
48 | return until
49 | }
50 | }
51 | }
52 | return data.Advanced.FallbackInterval.Duration
53 | }
54 |
--------------------------------------------------------------------------------
/internal/assets/src/github-brands-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/config/default.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "image/color"
5 | "path/filepath"
6 | "runtime"
7 | "time"
8 |
9 | "gabe565.com/utils/colorx"
10 | "gabe565.com/utils/slogx"
11 | flag "github.com/spf13/pflag"
12 | )
13 |
14 | const SocketFormatCSV = "csv"
15 |
16 | func New(opts ...Option) *Config {
17 | data := &Data{
18 | Title: "Nightscout",
19 | Units: UnitMgdl,
20 | DynamicIcon: DynamicIcon{
21 | Enabled: true,
22 | FontColor: colorx.Hex{Color: color.White},
23 | MaxFontSize: 40,
24 | },
25 | Arrows: Arrows{
26 | DoubleUp: "⇈",
27 | SingleUp: "↑",
28 | FortyFiveUp: "↗",
29 | Flat: "→",
30 | FortyFiveDown: "↘",
31 | SingleDown: "↓",
32 | DoubleDown: "⇊",
33 | Unknown: "-",
34 | },
35 | Socket: Socket{
36 | Format: SocketFormatCSV,
37 | Path: filepath.Join("$TMPDIR", "nightscout.sock"),
38 | },
39 | Log: Log{
40 | Level: slogx.LevelInfo,
41 | Format: slogx.FormatAuto,
42 | },
43 | Advanced: Advanced{
44 | FetchDelay: Duration{30 * time.Second},
45 | FallbackInterval: Duration{30 * time.Second},
46 | RoundAge: true,
47 | },
48 | }
49 |
50 | switch runtime.GOOS {
51 | case "darwin":
52 | data.DynamicIcon.Enabled = false
53 | case "windows":
54 | data.DynamicIcon.FontColor = colorx.Hex{Color: color.Black}
55 | }
56 |
57 | conf := &Config{}
58 | conf.data.Store(data)
59 |
60 | conf.Flags = flag.NewFlagSet("", flag.ContinueOnError)
61 | conf.RegisterFlags()
62 |
63 | for _, opt := range opts {
64 | opt(conf)
65 | }
66 |
67 | return conf
68 | }
69 |
--------------------------------------------------------------------------------
/internal/tray/items/preferences/preferences.go:
--------------------------------------------------------------------------------
1 | package preferences
2 |
3 | import (
4 | "fyne.io/systray"
5 | "gabe565.com/nightscout-menu-bar/internal/assets"
6 | "gabe565.com/nightscout-menu-bar/internal/autostart"
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | )
9 |
10 | func New(conf *config.Config) Preferences {
11 | item := systray.AddMenuItem("Preferences", "")
12 | item.SetTemplateIcon(assets.Gear, assets.Gear)
13 |
14 | url := NewURL(conf, item)
15 | token := NewToken(conf, item)
16 | units := NewUnits(conf, item)
17 |
18 | autostartEnabled, _ := autostart.IsEnabled()
19 | startOnLogin := item.AddSubMenuItemCheckbox(
20 | "Start on login",
21 | "",
22 | autostartEnabled,
23 | )
24 |
25 | dynamicIconMenu := item.AddSubMenuItem("Dynamic icon", "")
26 | dynamicIcon := NewDynamicIcon(conf, dynamicIconMenu)
27 | dynamicIconColor := NewDynamicIconColor(conf, dynamicIconMenu)
28 | socket := NewSocket(conf, item)
29 |
30 | return Preferences{
31 | MenuItem: item,
32 | URL: url,
33 | Token: token,
34 | Units: units,
35 | StartOnLogin: startOnLogin,
36 | DynamicIcon: dynamicIcon,
37 | DynamicIconColor: dynamicIconColor,
38 | Socket: socket,
39 | }
40 | }
41 |
42 | type Preferences struct {
43 | *systray.MenuItem
44 | URL URL
45 | Token Token
46 | Units Units
47 | StartOnLogin *systray.MenuItem
48 | DynamicIcon DynamicIcon
49 | DynamicIconColor DynamicIconColor
50 | Socket Socket
51 | }
52 |
53 | type Item interface {
54 | MenuItem() *systray.MenuItem
55 | GetTitle() string
56 | UpdateTitle()
57 | Prompt() error
58 | }
59 |
--------------------------------------------------------------------------------
/internal/nightscout/delta_test.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "gabe565.com/nightscout-menu-bar/internal/config"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestDelta_Display(t *testing.T) {
12 | t.Parallel()
13 | type fields struct {
14 | Absolute json.Number
15 | DisplayVal string
16 | ElapsedMins json.Number
17 | Interpolated bool
18 | Mean5MinsAgo json.Number
19 | Mgdl Mgdl
20 | Previous Reading
21 | Scaled json.Number
22 | Times Times
23 | }
24 | type args struct {
25 | units config.Unit
26 | }
27 | tests := []struct {
28 | name string
29 | args args
30 | fields fields
31 | want string
32 | }{
33 | {
34 | "mgdl",
35 | args{config.UnitMgdl},
36 | fields{Mgdl: 1},
37 | "+1",
38 | },
39 | {
40 | "mmol",
41 | args{config.UnitMmol},
42 | fields{Mgdl: 9},
43 | "+0.5",
44 | },
45 | {
46 | "mmol no decimal",
47 | args{config.UnitMmol},
48 | fields{Mgdl: 0},
49 | "+0",
50 | },
51 | {
52 | "mmol negative",
53 | args{config.UnitMmol},
54 | fields{Mgdl: -9},
55 | "-0.5",
56 | },
57 | }
58 | for _, tt := range tests {
59 | t.Run(tt.name, func(t *testing.T) {
60 | t.Parallel()
61 | d := Delta{
62 | Absolute: tt.fields.Absolute,
63 | DisplayVal: tt.fields.DisplayVal,
64 | ElapsedMins: tt.fields.ElapsedMins,
65 | Interpolated: tt.fields.Interpolated,
66 | Mean5MinsAgo: tt.fields.Mean5MinsAgo,
67 | Mgdl: tt.fields.Mgdl,
68 | Previous: tt.fields.Previous,
69 | Scaled: tt.fields.Scaled,
70 | Times: tt.fields.Times,
71 | }
72 | assert.Equal(t, tt.want, d.Display(tt.args.units))
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/internal/dynamicicon/encode_windows.go:
--------------------------------------------------------------------------------
1 | package dynamicicon
2 |
3 | import (
4 | "bytes"
5 | "encoding/binary"
6 | "image"
7 | "image/png"
8 | "io"
9 | )
10 |
11 | type iconDir struct {
12 | reserved uint16
13 | imageType uint16
14 | numImages uint16
15 | }
16 |
17 | type iconDirEntry struct {
18 | imageWidth uint8
19 | imageHeight uint8
20 | numColors uint8
21 | reserved uint8
22 | colorPlanes uint16
23 | bitsPerPixel uint16
24 | sizeInBytes uint32
25 | offset uint32
26 | }
27 |
28 | func newIcondir() iconDir {
29 | return iconDir{
30 | imageType: 1,
31 | numImages: 1,
32 | }
33 | }
34 |
35 | func newIcondirentry() iconDirEntry {
36 | return iconDirEntry{
37 | colorPlanes: 1, // windows is supposed to not mind 0 or 1, but other icon files seem to have 1 here
38 | bitsPerPixel: 32, // can be 24 for bitmap or 24/32 for png. Set to 32 for now
39 | offset: 22, // 6 iconDir + 16 iconDirEntry, next image will be this image size + 16 iconDirEntry, etc
40 | }
41 | }
42 |
43 | func encode(w io.Writer, img image.Image) error {
44 | dir := newIcondir()
45 | entry := newIcondirentry()
46 |
47 | var buf bytes.Buffer
48 | encoder := png.Encoder{CompressionLevel: png.BestSpeed}
49 | if err := encoder.Encode(&buf, img); err != nil {
50 | return err
51 | }
52 | entry.sizeInBytes = uint32(buf.Len())
53 |
54 | bounds := img.Bounds()
55 | entry.imageWidth = uint8(bounds.Dx())
56 | entry.imageHeight = uint8(bounds.Dy())
57 |
58 | if err := binary.Write(w, binary.LittleEndian, dir); err != nil {
59 | return err
60 | }
61 | if err := binary.Write(w, binary.LittleEndian, entry); err != nil {
62 | return err
63 | }
64 |
65 | if _, err := buf.WriteTo(w); err != nil {
66 | return err
67 | }
68 |
69 | return nil
70 | }
71 |
--------------------------------------------------------------------------------
/hack/build-darwin.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | BINARY_NAME='nightscout-menu-bar'
4 | APP_NAME='Nightscout Menu Bar'
5 | VERSION="${VERSION:-}"
6 | ICONSET=darwin/Nightscout.iconset
7 | ICNS=darwin/Nightscout.icns
8 |
9 | set -euo pipefail
10 |
11 | cd "$(git rev-parse --show-toplevel)"
12 |
13 | rm -rf dist/*.app assets/{"$ICONSET","$ICNS"}
14 | mkdir -p dist
15 |
16 | # Generate icns
17 | cp -a assets/{png,"$ICONSET"}
18 | cp -a assets/"$ICONSET"/icon_{32x32,16x16@2x}.png
19 | rm assets/"$ICONSET"/icon_48x48.png
20 | cp -a assets/"$ICONSET"/icon_{64x64,32x32@2x}.png
21 | cp -a assets/"$ICONSET"/icon_{128x128,64x64@2x}.png
22 | cp -a assets/"$ICONSET"/icon_{256x256,128x128@2x}.png
23 | cp -a assets/"$ICONSET"/icon_{512x512,256x256@2x}.png
24 | iconutil --convert icns --output "assets/$ICNS" "assets/$ICONSET"
25 |
26 | export GOOS=darwin CGO_ENABLED=1
27 | for ARCH in amd64 arm64; do
28 | echo Build "$BINARY_NAME-$ARCH"
29 | GOARCH="$ARCH" go build -ldflags="-w -s -X main.version=$VERSION" -trimpath -o "dist/$BINARY_NAME-$ARCH" .
30 | done
31 | lipo -create -output "dist/$BINARY_NAME" "dist/$BINARY_NAME-amd64" "dist/$BINARY_NAME-arm64"
32 | rm "dist/$BINARY_NAME-amd64" "dist/$BINARY_NAME-arm64"
33 | echo ...done
34 |
35 | echo Generate "$APP_NAME.app"
36 | APP_CONTENTS="dist/$APP_NAME.app/Contents"
37 | mkdir -p "$APP_CONTENTS"
38 | go run ./assets/darwin/info --version="$VERSION" > "$APP_CONTENTS/info.plist"
39 | mkdir "$APP_CONTENTS/Resources"
40 | cp "assets/$ICNS" "$APP_CONTENTS/Resources"
41 | mkdir "$APP_CONTENTS/MacOS"
42 | mv "dist/$BINARY_NAME" "$APP_CONTENTS/MacOS"
43 |
44 | echo Compress "$APP_NAME.app"
45 | tar_name="dist/nightscout-menu-bar_darwin.tar.gz"
46 | tar -czvf "$tar_name" -C dist "$APP_NAME.app"
47 | go run ./assets/darwin/cask --path="$tar_name" --version="$VERSION" > dist/nightscout-menu-bar.rb
48 | echo ...done
49 |
--------------------------------------------------------------------------------
/assets/windows/winres.json:
--------------------------------------------------------------------------------
1 | {
2 | "RT_GROUP_ICON": {
3 | "APP": {
4 | "0000": [
5 | "icon16.png",
6 | "icon32.png",
7 | "icon48.png",
8 | "icon64.png",
9 | "icon128.png",
10 | "icon256.png"
11 | ]
12 | }
13 | },
14 | "RT_MANIFEST": {
15 | "#1": {
16 | "0409": {
17 | "identity": {
18 | "name": "Nightscout Menu Bar",
19 | "version": ""
20 | },
21 | "description": "Nightscout Menu Bar",
22 | "minimum-os": "win8",
23 | "execution-level": "as invoker",
24 | "ui-access": false,
25 | "auto-elevate": false,
26 | "dpi-awareness": "system",
27 | "disable-theming": false,
28 | "disable-window-filtering": false,
29 | "high-resolution-scrolling-aware": false,
30 | "ultra-high-resolution-scrolling-aware": false,
31 | "long-path-aware": false,
32 | "printer-driver-isolation": false,
33 | "gdi-scaling": false,
34 | "segment-heap": false,
35 | "use-common-controls-v6": false
36 | }
37 | }
38 | },
39 | "RT_VERSION": {
40 | "#1": {
41 | "0000": {
42 | "fixed": {
43 | "file_version": "0.0.0.0",
44 | "product_version": "0.0.0.0"
45 | },
46 | "info": {
47 | "0409": {
48 | "Comments": "",
49 | "CompanyName": "gabe565",
50 | "FileDescription": "Nightscout Menu Bar",
51 | "FileVersion": "",
52 | "InternalName": "",
53 | "LegalCopyright": "© Gabe Cook",
54 | "LegalTrademarks": "",
55 | "OriginalFilename": "",
56 | "PrivateBuild": "",
57 | "ProductName": "Nightscout Menu Bar",
58 | "ProductVersion": "",
59 | "SpecialBuild": ""
60 | }
61 | }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/config_example.toml:
--------------------------------------------------------------------------------
1 | # Tray title.
2 | title = 'Nightscout'
3 | # Nightscout URL. (required)
4 | url = ''
5 | # Nightscout token. Using an access token is recommended instead of the API secret.
6 | token = ''
7 | # Blood sugar unit. (one of: mg/dL, mmol/L)
8 | units = 'mg/dL'
9 |
10 | # Makes the tray icon show the current blood sugar reading.
11 | [dynamic-icon]
12 | enabled = false
13 | # Hex code used to render text.
14 | font-color = '#fff'
15 | # Font path or filename of a system font. If left blank, an embedded font will be used.
16 | font-file = ''
17 | # Maximum font size in points.
18 | max-font-size = 40.0
19 |
20 | # Customize the arrows.
21 | [arrows]
22 | double-up = '⇈'
23 | single-up = '↑'
24 | forty-five-up = '↗'
25 | flat = '→'
26 | forty-five-down = '↘'
27 | single-down = '↓'
28 | double-down = '⇊'
29 | unknown = '-'
30 |
31 | # Exposes the latest reading to other applications over a local socket.
32 | [socket]
33 | enabled = false
34 | # Local file format. (one of: csv)
35 | format = 'csv'
36 | # File path. $TMPDIR will be replaced with the current temp directory.
37 | path = '$TMPDIR/nightscout.sock'
38 |
39 | # Log configuration
40 | [log]
41 | # Values: trace, debug, info, warn, error, fatal, panic
42 | level = 'info'
43 | # Values: auto, color, plain, json
44 | format = 'auto'
45 |
46 | # Advanced settings.
47 | [advanced]
48 | # Time to wait before the next reading should be ready.
49 | # In testing, this seems to be about 20s behind, so the default is 30s to be safe.
50 | # Your results may vary.
51 | fetch-delay = '30s'
52 | # Normally, readings will be fetched when ready (after ~5m).
53 | # This interval will be used if the next reading time cannot be estimated due to sensor warm-up, missed readings, errors, etc.
54 | fallback-interval = '30s'
55 | # If enabled, the reading's age will be rounded up to the nearest minute.
56 | # Nightscout rounds the age, so enable this if you want the values to match.
57 | round-age = true
58 |
--------------------------------------------------------------------------------
/internal/socket/socket_test.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "io"
5 | "net"
6 | "path/filepath"
7 | "strconv"
8 | "testing"
9 | "time"
10 |
11 | "gabe565.com/nightscout-menu-bar/internal/config"
12 | "gabe565.com/nightscout-menu-bar/internal/nightscout/testproperties"
13 | "gabe565.com/nightscout-menu-bar/internal/util"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestNew(t *testing.T) {
19 | t.Parallel()
20 | socket := New(config.New())
21 | require.NotNil(t, socket)
22 | assert.NotNil(t, socket.config)
23 | }
24 |
25 | func TestSocket(t *testing.T) {
26 | t.Parallel()
27 |
28 | temp := t.TempDir()
29 |
30 | conf := config.New(config.WithData(config.Data{
31 | Socket: config.Socket{
32 | Enabled: true,
33 | Path: filepath.Join(temp, "nightscout.sock"),
34 | },
35 | }))
36 |
37 | socket := New(conf)
38 | require.NotNil(t, socket)
39 | t.Cleanup(func() {
40 | _ = socket.Close()
41 | })
42 |
43 | socket.Write(testproperties.Properties)
44 |
45 | path := util.ResolvePath(conf.Data().Socket.Path)
46 |
47 | conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: path, Net: "unix"})
48 | require.NoError(t, err)
49 | t.Cleanup(func() {
50 | _ = conn.Close()
51 | })
52 |
53 | b, err := io.ReadAll(conn)
54 | require.NoError(t, err)
55 |
56 | assert.Equal(t, socket.Format(testproperties.Properties), string(b))
57 |
58 | require.NoError(t, socket.Close())
59 | assert.NoFileExists(t, path)
60 | }
61 |
62 | func TestSocket_Format(t *testing.T) {
63 | conf := config.New()
64 | socket := New(conf)
65 | t.Cleanup(func() {
66 | _ = socket.Close()
67 | })
68 |
69 | timeAgo := testproperties.Properties.Bgnow.Mills.Relative(conf.Data().Advanced.RoundAge)
70 | relative := strconv.Itoa(int(time.Since(testproperties.Properties.Bgnow.Mills.Time).Seconds()))
71 | assert.Equal(t, "123,→,+1,"+timeAgo+","+relative+"\n", socket.Format(testproperties.Properties))
72 | }
73 |
--------------------------------------------------------------------------------
/internal/util/date_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestGetNextMinChange(t *testing.T) {
11 | t.Parallel()
12 | type args struct {
13 | t time.Time
14 | round bool
15 | }
16 | tests := []struct {
17 | name string
18 | args args
19 | want time.Duration
20 | }{
21 | {"now rounded", args{time.Now(), true}, 30 * time.Second},
22 | {"1s ago rounded", args{time.Now().Add(-time.Second), true}, 29 * time.Second},
23 | {"15s ago rounded", args{time.Now().Add(-15 * time.Second), true}, 15 * time.Second},
24 | {"29s ago rounded", args{time.Now().Add(-29 * time.Second), true}, time.Second},
25 | {"30s ago rounded", args{time.Now().Add(-30 * time.Second), true}, time.Minute},
26 | {"31s ago rounded", args{time.Now().Add(-31 * time.Second), true}, 59 * time.Second},
27 | {"45s ago rounded", args{time.Now().Add(-45 * time.Second), true}, 45 * time.Second},
28 | {"59s ago rounded", args{time.Now().Add(-59 * time.Second), true}, 31 * time.Second},
29 | {"1m ago rounded", args{time.Now().Add(-time.Minute), true}, 30 * time.Second},
30 | {"4m40s ago rounded", args{time.Now().Add(-4*time.Minute - 40*time.Second), true}, 50 * time.Second},
31 |
32 | {"now", args{time.Now(), false}, time.Minute},
33 | {"1s ago", args{time.Now().Add(-time.Second), false}, 59 * time.Second},
34 | {"15s ago", args{time.Now().Add(-15 * time.Second), false}, 45 * time.Second},
35 | {"29s ago", args{time.Now().Add(-29 * time.Second), false}, 31 * time.Second},
36 | {"30s ago", args{time.Now().Add(-30 * time.Second), false}, 30 * time.Second},
37 | {"31s ago", args{time.Now().Add(-31 * time.Second), false}, 29 * time.Second},
38 | {"45s ago", args{time.Now().Add(-45 * time.Second), false}, 15 * time.Second},
39 | {"59s ago", args{time.Now().Add(-59 * time.Second), false}, time.Second},
40 | {"1m ago", args{time.Now().Add(-time.Minute), false}, time.Minute},
41 | {"4m40s ago", args{time.Now().Add(-4*time.Minute - 40*time.Second), false}, 20 * time.Second},
42 | }
43 | for _, tt := range tests {
44 | t.Run(tt.name, func(t *testing.T) {
45 | t.Parallel()
46 | tt.want = tt.want.Round(time.Second)
47 | got := GetNextMinChange(tt.args.t, tt.args.round).Round(time.Second)
48 | assert.Equal(t, tt.want, got)
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/contrib/powerlevel10k/README.md:
--------------------------------------------------------------------------------
1 | # Powerlevel10k Segment
2 |
3 | Adds a custom segment to the [Powerlevel10k](https://github.com/romkatv/powerlevel10k) zsh theme.
4 |
5 | 
6 |
7 | ## Install
8 |
9 | ### Prerequisites
10 |
11 | #### Enable local socket
12 | 1. Open the nightscout-menu-bar menu.
13 | 2. Hover over "Preferences".
14 | 3. Check "Expose readings over local socket".[^1]
15 |
16 | ### Install the Powerlevel10k segment
17 |
18 | The Nightscout segment can either be downloaded to a separate file, and sourced from `~/.p10k.zsh`, or its contents can be pasted directly into `~/.p10k.zsh`.
19 |
20 | #### Install directly into `~/.p10k.zsh`
21 | This method adds the custom segment's code and configuration directly into `~/.p10k.zsh`. It is easier to set up, but may be harder to update in the future.
22 |
23 |
24 | Click to expand
25 |
26 | 1. Copy the contents of the [segment script](nightscout.zsh).
27 | 2. Edit `~/.p10k.zsh`.
28 | 3. Search for `p10k reload`.
29 | 4. Somewhere before this line, paste the segment file contents.
30 | 5. Search for `POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS`.
31 | 6. Add `nightscout` somewhere in this array, depending on where you would like the widget to be rendered.
32 | 7. Open a new shell, or restart your current shell with `exec zsh`.
33 | 8. Nightscout data should be rendered as a right-segment!
34 |
35 |
36 | #### Install to a separate file
37 | This method places the custom segment's code and configuration in a separate file. It is less standard, but makes it easier to update the segment in the future.
38 |
39 |
40 | Click to expand
41 |
42 | 1. Download the [segment script](nightscout.zsh) to a local file. This file will be sourced during Powerlevel10k initialization.
43 | 1. For example: `~/.p10k/nightscout.zsh`.
44 | 2. Edit `~/.p10k.zsh`.
45 | 3. Search for `p10k reload`.
46 | 4. Somewhere before this line, source the segment file.
47 | 1. For example: `source ~/.p10k/nightscout.zsh`
48 | 5. Search for `POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS`.
49 | 6. Add `nightscout` somewhere in this array, depending on where you would like the widget to be rendered.
50 | 7. Open a new shell, or restart your current shell with `exec zsh`.
51 | 8. Nightscout data should be rendered as a segment!
52 |
53 |
54 | [^1]: If this option is unavailable, update nightscout-menu-bar.
55 |
--------------------------------------------------------------------------------
/internal/nightscout/reading.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "encoding/json"
5 | "math"
6 | "strconv"
7 |
8 | "gabe565.com/nightscout-menu-bar/internal/config"
9 | )
10 |
11 | const (
12 | LowReading = 39
13 | HighReading = 401
14 | )
15 |
16 | type Reading struct {
17 | Mean json.Number `json:"mean"`
18 | Last Mgdl `json:"last"`
19 | Mills Mills `json:"mills"`
20 | Index json.Number `json:"index,omitempty"`
21 | FromMills Mills `json:"fromMills,omitempty"`
22 | ToMills Mills `json:"toMills,omitempty"`
23 | Sgvs []SGV `json:"sgvs"`
24 | }
25 |
26 | func (r *Reading) Arrow(conf config.Arrows) string {
27 | var direction string
28 | if len(r.Sgvs) > 0 {
29 | direction = r.Sgvs[0].Direction
30 | }
31 | switch direction {
32 | case "DoubleUp", "TripleUp":
33 | direction = conf.DoubleUp
34 | case "SingleUp":
35 | direction = conf.SingleUp
36 | case "FortyFiveUp":
37 | direction = conf.FortyFiveUp
38 | case "Flat":
39 | direction = conf.Flat
40 | case "FortyFiveDown":
41 | direction = conf.FortyFiveDown
42 | case "SingleDown":
43 | direction = conf.SingleDown
44 | case "DoubleDown", "TripleDown":
45 | direction = conf.DoubleDown
46 | default:
47 | direction = conf.Unknown
48 | }
49 | return direction
50 | }
51 |
52 | func (r *Reading) String(data config.Data) string {
53 | if r.Last == 0 {
54 | return ""
55 | }
56 |
57 | result := r.DisplayBg(data.Units) +
58 | " " + r.Arrow(data.Arrows)
59 | if rel := r.Mills.Relative(data.Advanced.RoundAge); rel != "" {
60 | result += " [" + r.Mills.Relative(data.Advanced.RoundAge) + "]"
61 | }
62 | return result
63 | }
64 |
65 | func (r *Reading) UnmarshalJSON(bytes []byte) error {
66 | type rawReading Reading
67 | if err := json.Unmarshal(bytes, (*rawReading)(r)); err != nil {
68 | return err
69 | }
70 |
71 | // Last is unset if reading is out of range.
72 | // Will be set from sgvs.
73 | if r.Last == 0 && len(r.Sgvs) > 0 {
74 | r.Last = r.Sgvs[0].Mgdl
75 | r.Mills = r.Sgvs[0].Mills
76 | }
77 |
78 | return nil
79 | }
80 |
81 | func (r *Reading) DisplayBg(units config.Unit) string {
82 | switch r.Last {
83 | case LowReading:
84 | return "LOW"
85 | case HighReading:
86 | return "HIGH"
87 | }
88 |
89 | if units == config.UnitMmol {
90 | mmol := r.Last.Mmol()
91 | mmol = math.Round(mmol*10) / 10
92 | return strconv.FormatFloat(mmol, 'f', 1, 64)
93 | }
94 |
95 | return strconv.Itoa(r.Last.Mgdl())
96 | }
97 |
--------------------------------------------------------------------------------
/internal/config/unit_enumer.go:
--------------------------------------------------------------------------------
1 | // Code generated by "enumer -type Unit -linecomment -text"; DO NOT EDIT.
2 |
3 | package config
4 |
5 | import (
6 | "fmt"
7 | "strings"
8 | )
9 |
10 | const _UnitName = "mg/dLmmol/L"
11 |
12 | var _UnitIndex = [...]uint8{0, 5, 11}
13 |
14 | const _UnitLowerName = "mg/dlmmol/l"
15 |
16 | func (i Unit) String() string {
17 | if i >= Unit(len(_UnitIndex)-1) {
18 | return fmt.Sprintf("Unit(%d)", i)
19 | }
20 | return _UnitName[_UnitIndex[i]:_UnitIndex[i+1]]
21 | }
22 |
23 | // An "invalid array index" compiler error signifies that the constant values have changed.
24 | // Re-run the stringer command to generate them again.
25 | func _UnitNoOp() {
26 | var x [1]struct{}
27 | _ = x[UnitMgdl-(0)]
28 | _ = x[UnitMmol-(1)]
29 | }
30 |
31 | var _UnitValues = []Unit{UnitMgdl, UnitMmol}
32 |
33 | var _UnitNameToValueMap = map[string]Unit{
34 | _UnitName[0:5]: UnitMgdl,
35 | _UnitLowerName[0:5]: UnitMgdl,
36 | _UnitName[5:11]: UnitMmol,
37 | _UnitLowerName[5:11]: UnitMmol,
38 | }
39 |
40 | var _UnitNames = []string{
41 | _UnitName[0:5],
42 | _UnitName[5:11],
43 | }
44 |
45 | // UnitString retrieves an enum value from the enum constants string name.
46 | // Throws an error if the param is not part of the enum.
47 | func UnitString(s string) (Unit, error) {
48 | if val, ok := _UnitNameToValueMap[s]; ok {
49 | return val, nil
50 | }
51 |
52 | if val, ok := _UnitNameToValueMap[strings.ToLower(s)]; ok {
53 | return val, nil
54 | }
55 | return 0, fmt.Errorf("%s does not belong to Unit values", s)
56 | }
57 |
58 | // UnitValues returns all values of the enum
59 | func UnitValues() []Unit {
60 | return _UnitValues
61 | }
62 |
63 | // UnitStrings returns a slice of all String values of the enum
64 | func UnitStrings() []string {
65 | strs := make([]string, len(_UnitNames))
66 | copy(strs, _UnitNames)
67 | return strs
68 | }
69 |
70 | // IsAUnit returns "true" if the value is listed in the enum definition. "false" otherwise
71 | func (i Unit) IsAUnit() bool {
72 | for _, v := range _UnitValues {
73 | if i == v {
74 | return true
75 | }
76 | }
77 | return false
78 | }
79 |
80 | // MarshalText implements the encoding.TextMarshaler interface for Unit
81 | func (i Unit) MarshalText() ([]byte, error) {
82 | return []byte(i.String()), nil
83 | }
84 |
85 | // UnmarshalText implements the encoding.TextUnmarshaler interface for Unit
86 | func (i *Unit) UnmarshalText(text []byte) error {
87 | var err error
88 | *i, err = UnitString(string(text))
89 | return err
90 | }
91 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module gabe565.com/nightscout-menu-bar
2 |
3 | go 1.24.6
4 |
5 | require (
6 | fyne.io/systray v1.11.0
7 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c
8 | github.com/Masterminds/sprig/v3 v3.3.0
9 | github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2
10 | github.com/flopp/go-findfont v0.1.0
11 | github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
12 | github.com/knadh/koanf/providers/file v1.2.0
13 | github.com/knadh/koanf/providers/rawbytes v1.0.0
14 | github.com/knadh/koanf/providers/structs v1.0.0
15 | github.com/knadh/koanf/v2 v2.2.2
16 | github.com/lmittmann/tint v1.1.2
17 | github.com/mattn/go-isatty v0.0.20
18 | github.com/ncruces/zenity v0.10.14
19 | github.com/pelletier/go-toml/v2 v2.2.4
20 | github.com/prashantgupta24/mac-sleep-notifier v1.0.1
21 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
22 | github.com/spf13/pflag v1.0.7
23 | github.com/stretchr/testify v1.10.0
24 | golang.org/x/image v0.29.0
25 | )
26 |
27 | require (
28 | dario.cat/mergo v1.0.1 // indirect
29 | github.com/Masterminds/goutils v1.1.1 // indirect
30 | github.com/Masterminds/semver/v3 v3.3.0 // indirect
31 | github.com/akavel/rsrc v0.10.2 // indirect
32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
33 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect
34 | github.com/dmarkham/enumer v1.5.11 // indirect
35 | github.com/fatih/structs v1.1.0 // indirect
36 | github.com/fsnotify/fsnotify v1.9.0 // indirect
37 | github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
38 | github.com/godbus/dbus/v5 v5.1.0 // indirect
39 | github.com/google/uuid v1.6.0 // indirect
40 | github.com/huandu/xstrings v1.5.0 // indirect
41 | github.com/josephspurrier/goversioninfo v1.4.1 // indirect
42 | github.com/knadh/koanf/maps v0.1.2 // indirect
43 | github.com/mitchellh/copystructure v1.2.0 // indirect
44 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
45 | github.com/pascaldekloe/name v1.0.0 // indirect
46 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
47 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect
48 | github.com/shopspring/decimal v1.4.0 // indirect
49 | github.com/spf13/cast v1.7.0 // indirect
50 | github.com/tj/assert v0.0.3 // indirect
51 | golang.org/x/crypto v0.35.0 // indirect
52 | golang.org/x/mod v0.25.0 // indirect
53 | golang.org/x/sync v0.16.0 // indirect
54 | golang.org/x/sys v0.33.0 // indirect
55 | golang.org/x/text v0.27.0 // indirect
56 | golang.org/x/tools v0.34.0 // indirect
57 | gopkg.in/yaml.v3 v3.0.1 // indirect
58 | )
59 |
60 | tool github.com/dmarkham/enumer
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nightscout Menu Bar
2 |
3 | [](https://github.com/gabe565/nightscout-menu-bar/actions/workflows/build.yml)
4 | [](https://goreportcard.com/report/github.com/gabe565/nightscout-menu-bar)
5 | [](https://sonarcloud.io/summary/new_code?id=gabe565_nightscout-menu-bar)
6 |
7 | A small application that displays live blood sugar data from Nightscout on your menu bar.
8 |
9 | Works on Windows, MacOS, and Linux.
10 |
11 |
12 |
13 |
14 |
15 |
16 | ## Install
17 |
18 | ### Brew (macOS)
19 |
20 | ```shell
21 | brew install gabe565/tap/nightscout-menu-bar --no-quarantine
22 | ```
23 |
24 | ### Binary
25 |
26 | Automated builds are uploaded during the release process. See the [latest release](https://github.com/gabe565/nightscout-menu-bar/releases/latest) for download links.
27 |
28 | ## Usage
29 |
30 | After launching Nightscout Menu Bar, you will need to open its tray menu, then hover over "Preferences" to configure the integration.
31 |
32 | The preferences menu contains the following options:
33 | - Nightscout URL (required)
34 | - API Token
35 | - Units: mg/dL or mmol/L
36 | - Start on login
37 | - Write to a local file (see [`contrib/powerlevel10k`](contrib/powerlevel10k))
38 |
39 | Additional configuration is available in a configuration file, which can be found in the following locations:
40 | - **Windows:** `%AppData%\nightscout-menu-bar\config.toml`
41 | - **macOS:** `~/Library/Application Support/nightscout-menu-bar/config.toml`
42 | - **Linux:** `~/.config/nightscout-menu-bar/config.toml`
43 |
44 | An example configuration is available at [`config_example.toml`](config_example.toml).
45 |
46 | ## Contrib
47 |
48 | Integrations with external tools are available in the [contrib](contrib) directory.
49 |
50 | ## Development
51 |
52 | The systray menu is provided by
53 | [fyne.io/systray](https://github.com/fyne-io/systray). See
54 | [systray's platform notes](https://github.com/getlantern/systray#platform-notes)
55 | for required dependencies.
56 |
57 | #### macOS
58 |
59 | To generate a Mac app, run [hack/build-darwin.sh](hack/build-darwin.sh).
60 | An app will be created in the `dist` directory.
61 |
--------------------------------------------------------------------------------
/internal/fetch/fetch_test.go:
--------------------------------------------------------------------------------
1 | package fetch
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "gabe565.com/nightscout-menu-bar/internal/config"
11 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
12 | "gabe565.com/nightscout-menu-bar/internal/nightscout/testproperties"
13 | "github.com/hhsnopek/etag"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestNewFetch(t *testing.T) {
19 | t.Parallel()
20 | fetch := NewFetch(config.New())
21 | require.NotNil(t, fetch)
22 | assert.NotNil(t, fetch.config)
23 | }
24 |
25 | func TestFetch_Do(t *testing.T) {
26 | t.Parallel()
27 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 | etag := etag.Generate(testproperties.JSON, true)
29 |
30 | if reqEtag := r.Header.Get("If-None-Match"); reqEtag == etag {
31 | w.WriteHeader(http.StatusNotModified)
32 | return
33 | }
34 |
35 | w.Header().Set("Etag", etag)
36 | _, _ = w.Write(testproperties.JSON)
37 | }))
38 | t.Cleanup(server.Close)
39 |
40 | type fields struct {
41 | config *config.Config
42 | url string
43 | tokenChecksum string
44 | etag string
45 | }
46 | type args struct {
47 | ctx context.Context
48 | }
49 | tests := []struct {
50 | name string
51 | fields fields
52 | args args
53 | want *nightscout.Properties
54 | wantEtag string
55 | wantErr require.ErrorAssertionFunc
56 | }{
57 | {
58 | "no url",
59 | fields{config: &config.Config{}},
60 | args{t.Context()},
61 | nil,
62 | "",
63 | require.Error,
64 | },
65 | {
66 | "success",
67 | fields{config: config.New(config.WithData(config.Data{URL: server.URL}))},
68 | args{t.Context()},
69 | testproperties.Properties,
70 | testproperties.Etag,
71 | require.NoError,
72 | },
73 | {
74 | "same etag",
75 | fields{config: config.New(config.WithData(config.Data{URL: server.URL})), etag: testproperties.Etag},
76 | args{t.Context()},
77 | nil,
78 | testproperties.Etag,
79 | require.Error,
80 | },
81 | {
82 | "different etag",
83 | fields{
84 | config: config.New(config.WithData(config.Data{URL: server.URL})),
85 | etag: etag.Generate([]byte("test"), true),
86 | },
87 | args{t.Context()},
88 | testproperties.Properties,
89 | testproperties.Etag,
90 | require.NoError,
91 | },
92 | }
93 | for _, tt := range tests {
94 | t.Run(tt.name, func(t *testing.T) {
95 | t.Parallel()
96 | f := NewFetch(tt.fields.config)
97 | f.url = tt.fields.url
98 | f.tokenChecksum = tt.fields.tokenChecksum
99 | f.etag = tt.fields.etag
100 |
101 | got, err := f.Do(tt.args.ctx)
102 | tt.wantErr(t, err)
103 | assert.Equal(t, tt.want, got)
104 | assert.Equal(t, tt.wantEtag, f.etag)
105 | })
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/internal/socket/socket.go:
--------------------------------------------------------------------------------
1 | package socket
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "log/slog"
7 | "net"
8 | "strconv"
9 | "sync"
10 | "time"
11 |
12 | "gabe565.com/nightscout-menu-bar/internal/config"
13 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
14 | "gabe565.com/nightscout-menu-bar/internal/util"
15 | )
16 |
17 | func New(conf *config.Config) *Socket {
18 | l := &Socket{
19 | config: conf,
20 | }
21 | l.reloadConfig()
22 |
23 | conf.AddCallback(l.reloadConfig)
24 | return l
25 | }
26 |
27 | type Socket struct {
28 | config *config.Config
29 | listener *net.UnixListener
30 | last *nightscout.Properties
31 | mu sync.RWMutex
32 | }
33 |
34 | func (s *Socket) Format(last *nightscout.Properties) string {
35 | data := s.config.Data()
36 |
37 | switch data.Socket.Format {
38 | case config.SocketFormatCSV:
39 | return last.Bgnow.DisplayBg(data.Units) + "," +
40 | last.Bgnow.Arrow(data.Arrows) + "," +
41 | last.Delta.Display(data.Units) + "," +
42 | last.Bgnow.Mills.Relative(data.Advanced.RoundAge) + "," +
43 | strconv.Itoa(int(time.Since(last.Bgnow.Mills.Time).Seconds())) +
44 | "\n"
45 | default:
46 | slog.Error("Unknown socket format", "value", data.Socket.Format)
47 | return ""
48 | }
49 | }
50 |
51 | func (s *Socket) reloadConfig() {
52 | s.mu.Lock()
53 | defer s.mu.Unlock()
54 |
55 | if err := s.Close(); err != nil {
56 | slog.Error("Failed to close socket", "error", err)
57 | }
58 |
59 | if s.config.Data().Socket.Enabled {
60 | if err := s.Listen(); err != nil {
61 | slog.Error("Failed to listen on socket", "error", err)
62 | }
63 | }
64 | }
65 |
66 | func (s *Socket) Write(last *nightscout.Properties) {
67 | s.mu.Lock()
68 | defer s.mu.Unlock()
69 | s.last = last
70 | }
71 |
72 | func (s *Socket) Listen() error {
73 | if err := s.Close(); err != nil {
74 | slog.Error("Failed to close socket", "error", err)
75 | }
76 |
77 | var err error
78 | s.listener, err = net.ListenUnix("unix", &net.UnixAddr{
79 | Name: util.ResolvePath(s.config.Data().Socket.Path),
80 | Net: "unix",
81 | })
82 | if err != nil {
83 | return err
84 | }
85 |
86 | go func() {
87 | for {
88 | conn, err := s.listener.Accept()
89 | if err != nil {
90 | if !errors.Is(err, net.ErrClosed) {
91 | slog.Error("Failed to accept connection", "error", err)
92 | }
93 | return
94 | }
95 |
96 | go func() {
97 | defer func() {
98 | _ = conn.Close()
99 | }()
100 | _ = conn.SetDeadline(time.Now().Add(time.Second))
101 | s.mu.RLock()
102 | last := s.last
103 | s.mu.RUnlock()
104 | _, _ = io.WriteString(conn, s.Format(last))
105 | }()
106 | }
107 | }()
108 |
109 | return nil
110 | }
111 |
112 | func (s *Socket) Close() error {
113 | var err error
114 | if s.listener != nil {
115 | err = s.listener.Close()
116 | }
117 | s.listener = nil
118 | return err
119 | }
120 |
--------------------------------------------------------------------------------
/contrib/powerlevel10k/nightscout.zsh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env zsh
2 |
3 | #################################[ nightscout: blood sugar ]#################################
4 | # Nightscout state file. Typically does not need to be changed.
5 | typeset -g NIGHTSCOUT_SOCKET="$TMPDIR/nightscout.sock"
6 |
7 | # Nightscout styling will be chosen if the reading is below a given value.
8 | typeset -g NIGHTSCOUT_THRESHOLD_OLD_MINS=10
9 | typeset -g NIGHTSCOUT_THRESHOLD_URGENT_LOW=55
10 | typeset -g NIGHTSCOUT_THRESHOLD_LOW=80
11 | typeset -g NIGHTSCOUT_THRESHOLD_IN_RANGE=160
12 | typeset -g NIGHTSCOUT_THRESHOLD_HIGH=260
13 |
14 | # Show/hide Nightscout parts.
15 | typeset -g NIGHTSCOUT_SHOW_ARROW=true
16 | typeset -g NIGHTSCOUT_SHOW_DELTA=true
17 | typeset -g NIGHTSCOUT_SHOW_TIMESTAMP=true
18 |
19 | # Nightscout colors.
20 | # Urgent low styling.
21 | typeset -g POWERLEVEL9K_NIGHTSCOUT_URGENT_LOW_BACKGROUND=1
22 | typeset -g POWERLEVEL9K_NIGHTSCOUT_URGENT_LOW_FOREGROUND=7
23 | # Low styling.
24 | typeset -g POWERLEVEL9K_NIGHTSCOUT_LOW_BACKGROUND=1
25 | typeset -g POWERLEVEL9K_NIGHTSCOUT_LOW_FOREGROUND=7
26 | # In range styling.
27 | typeset -g POWERLEVEL9K_NIGHTSCOUT_IN_RANGE_BACKGROUND=2
28 | typeset -g POWERLEVEL9K_NIGHTSCOUT_IN_RANGE_FOREGROUND=0
29 | # High styling.
30 | typeset -g POWERLEVEL9K_NIGHTSCOUT_HIGH_BACKGROUND=3
31 | typeset -g POWERLEVEL9K_NIGHTSCOUT_HIGH_FOREGROUND=0
32 | # Urgent high styling.
33 | typeset -g POWERLEVEL9K_NIGHTSCOUT_URGENT_HIGH_BACKGROUND=1
34 | typeset -g POWERLEVEL9K_NIGHTSCOUT_URGENT_HIGH_FOREGROUND=7
35 | # Old reading styling.
36 | typeset -g POWERLEVEL9K_NIGHTSCOUT_OLD_BACKGROUND=243
37 | typeset -g POWERLEVEL9K_NIGHTSCOUT_OLD_FOREGROUND=0
38 | # Custom icon.
39 | # typeset -g POWERLEVEL9K_NIGHTSCOUT_VISUAL_IDENTIFIER_EXPANSION='⭐'
40 |
41 | zmodload -F zsh/net/socket b:zsocket
42 |
43 | typeset -g NIGHTSCOUT_THRESHOLD_OLD_SECS=$(( NIGHTSCOUT_THRESHOLD_OLD_MINS * 60 ))
44 |
45 | # Creates segment with Nightscout blood sugar data.
46 | #
47 | # Example output: 120 → -1 [1m]
48 | function prompt_nightscout() {
49 | emulate -L zsh
50 | setopt err_return
51 |
52 | if [[ -S "$NIGHTSCOUT_SOCKET" ]]; then
53 | # Read socket into local variables.
54 | typeset REPLY bgnow arrow delta timeago relative
55 | zsocket "$NIGHTSCOUT_SOCKET" 2>/dev/null
56 | {
57 | IFS=, read -t 0.25 -r bgnow arrow delta timeago relative <&$REPLY
58 | } always {
59 | exec {REPLY}>&-
60 | }
61 |
62 | # State file is invalid. Segment will be hidden.
63 | if [[ -z "$bgnow" || -z "$relative" ]]; then
64 | p10k segment -c ''
65 | return
66 | fi
67 |
68 | # Choose current state for styling.
69 | if (( relative > NIGHTSCOUT_THRESHOLD_OLD_SECS )); then
70 | typeset state=OLD
71 | elif (( bgnow <= NIGHTSCOUT_THRESHOLD_URGENT_LOW )); then
72 | typeset state=URGENT_LOW
73 | elif (( bgnow < NIGHTSCOUT_THRESHOLD_LOW )); then
74 | typeset state=LOW
75 | elif (( bgnow < NIGHTSCOUT_THRESHOLD_IN_RANGE )); then
76 | typeset state=IN_RANGE
77 | elif (( bgnow < NIGHTSCOUT_THRESHOLD_HIGH )); then
78 | typeset state=HIGH
79 | else
80 | typeset state=URGENT_HIGH
81 | fi
82 |
83 | # Generate text
84 | typeset text="$bgnow"
85 | [[ "$NIGHTSCOUT_SHOW_ARROW" == true ]] && text+=" $arrow"
86 | [[ "$NIGHTSCOUT_SHOW_DELTA" == true && -n "$delta" ]] && text+=" $delta"
87 | [[ "$NIGHTSCOUT_SHOW_TIMESTAMP" == true ]] && text+=" [${timeago}]"
88 |
89 | # Write segment.
90 | p10k segment -s "$state" -i $'\UF058C' -t "$text"
91 | else
92 | # State file does not exist. Segment will be hidden.
93 | p10k segment -c ''
94 | fi
95 | }
96 |
--------------------------------------------------------------------------------
/internal/nightscout/mills_test.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "strconv"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestMills_MarshalJSON(t *testing.T) {
13 | t.Parallel()
14 | unix0 := time.Unix(0, 0)
15 |
16 | now := time.Now()
17 | nowStr := strconv.Itoa(int(now.UnixMilli()))
18 |
19 | type fields struct {
20 | Time time.Time
21 | }
22 | tests := []struct {
23 | name string
24 | fields fields
25 | want []byte
26 | wantErr require.ErrorAssertionFunc
27 | }{
28 | {"0", fields{unix0}, []byte("0"), require.NoError},
29 | {"now", fields{now}, []byte(nowStr), require.NoError},
30 | }
31 | for _, tt := range tests {
32 | t.Run(tt.name, func(t *testing.T) {
33 | t.Parallel()
34 | m := &Mills{
35 | Time: tt.fields.Time,
36 | }
37 | got, err := m.MarshalJSON()
38 | tt.wantErr(t, err)
39 | assert.Equal(t, tt.want, got)
40 | })
41 | }
42 | }
43 |
44 | func TestMills_UnmarshalJSON(t *testing.T) {
45 | t.Parallel()
46 | now := time.Now().Truncate(time.Millisecond)
47 |
48 | type args struct {
49 | bytes []byte
50 | }
51 | tests := []struct {
52 | name string
53 | args args
54 | want Mills
55 | wantErr require.ErrorAssertionFunc
56 | }{
57 | {"now", args{[]byte(strconv.Itoa(int(now.UnixMilli())))}, Mills{now}, require.NoError},
58 | {"error", args{[]byte("a")}, Mills{time.Time{}}, require.Error},
59 | }
60 | for _, tt := range tests {
61 | t.Run(tt.name, func(t *testing.T) {
62 | t.Parallel()
63 | var m Mills
64 | tt.wantErr(t, m.UnmarshalJSON(tt.args.bytes))
65 | assert.Equal(t, tt.want, m)
66 | })
67 | }
68 | }
69 |
70 | func TestMills_Relative(t *testing.T) {
71 | t.Parallel()
72 | type fields struct {
73 | Time time.Time
74 | }
75 | type args struct {
76 | round bool
77 | }
78 | tests := []struct {
79 | name string
80 | fields fields
81 | args args
82 | want string
83 | }{
84 | {"0m rounded", fields{time.Now()}, args{true}, "0m"},
85 | {"59s rounded", fields{time.Now().Add(-59 * time.Second)}, args{true}, "1m"},
86 | {"1m rounded", fields{time.Now().Add(-time.Minute)}, args{true}, "1m"},
87 | {"1m30s rounded", fields{time.Now().Add(-time.Minute - 30*time.Second)}, args{true}, "2m"},
88 | {"2m35s rounded", fields{time.Now().Add(-2*time.Minute - 35*time.Second)}, args{true}, "3m"},
89 | {"4m15s rounded", fields{time.Now().Add(-4*time.Minute - 15*time.Second)}, args{true}, "4m"},
90 | {"5m1s rounded", fields{time.Now().Add(-5*time.Minute - time.Second)}, args{true}, "5m"},
91 | {"now rounded", fields{time.Now()}, args{true}, "0m"},
92 | {"unix 0 rounded", fields{time.Unix(0, 0)}, args{true}, ""},
93 |
94 | {"0m", fields{time.Now()}, args{false}, "0m"},
95 | {"59s", fields{time.Now().Add(-59 * time.Second)}, args{false}, "0m"},
96 | {"1m", fields{time.Now().Add(-time.Minute)}, args{false}, "1m"},
97 | {"1m30s", fields{time.Now().Add(-time.Minute - 30*time.Second)}, args{false}, "1m"},
98 | {"2m35s", fields{time.Now().Add(-2*time.Minute - 35*time.Second)}, args{false}, "2m"},
99 | {"4m15s", fields{time.Now().Add(-4*time.Minute - 15*time.Second)}, args{false}, "4m"},
100 | {"5m1s", fields{time.Now().Add(-5*time.Minute - time.Second)}, args{false}, "5m"},
101 | {"now", fields{time.Now()}, args{false}, "0m"},
102 | {"unix 0", fields{time.Unix(0, 0)}, args{false}, ""},
103 | }
104 | for _, tt := range tests {
105 | t.Run(tt.name, func(t *testing.T) {
106 | t.Parallel()
107 | m := &Mills{Time: tt.fields.Time}
108 | assert.Equal(t, tt.want, m.Relative(tt.args.round))
109 | })
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/internal/dynamicicon/dynamicicon.go:
--------------------------------------------------------------------------------
1 | package dynamicicon
2 |
3 | import (
4 | "bytes"
5 | _ "embed"
6 | "errors"
7 | "image"
8 | "log/slog"
9 | "os"
10 | "path/filepath"
11 | "slices"
12 | "sync"
13 | "time"
14 |
15 | "gabe565.com/nightscout-menu-bar/internal/config"
16 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
17 | "gabe565.com/nightscout-menu-bar/internal/util"
18 | "gabe565.com/utils/bytefmt"
19 | "github.com/flopp/go-findfont"
20 | "golang.org/x/image/font"
21 | "golang.org/x/image/font/opentype"
22 | "golang.org/x/image/math/fixed"
23 | )
24 |
25 | const (
26 | width, height = 64, 64
27 | widthF, heightF = fixed.Int26_6(width << 6), fixed.Int26_6(height << 6)
28 | )
29 |
30 | //go:embed RobotoCondensed-SemiBold.ttf
31 | var defaultFont []byte
32 |
33 | type DynamicIcon struct {
34 | config *config.Config
35 | mu sync.Mutex
36 |
37 | font *opentype.Font
38 | }
39 |
40 | func New(conf *config.Config) *DynamicIcon {
41 | return &DynamicIcon{config: conf}
42 | }
43 |
44 | var ErrFontSize = errors.New("unable to determine the correct font size")
45 |
46 | func (d *DynamicIcon) Generate(p *nightscout.Properties) ([]byte, error) {
47 | d.mu.Lock()
48 | defer d.mu.Unlock()
49 |
50 | data := d.config.Data()
51 |
52 | if d.font == nil {
53 | var b []byte
54 | if data.DynamicIcon.FontFile == "" {
55 | b = defaultFont
56 | } else {
57 | path := util.ResolvePath(data.DynamicIcon.FontFile)
58 |
59 | if !filepath.IsAbs(path) {
60 | dir, err := config.GetDir()
61 | if err != nil {
62 | return nil, err
63 | }
64 |
65 | path = filepath.Join(dir, path)
66 | }
67 |
68 | var err error
69 | if b, err = os.ReadFile(path); err != nil {
70 | if !os.IsNotExist(err) {
71 | return nil, err
72 | }
73 |
74 | path, findErr := findfont.Find(data.DynamicIcon.FontFile)
75 | if findErr != nil {
76 | return nil, errors.Join(err, findErr)
77 | }
78 |
79 | if b, err = os.ReadFile(path); err != nil {
80 | return nil, err
81 | }
82 | }
83 | }
84 |
85 | f, err := opentype.Parse(b)
86 | if err != nil {
87 | return nil, err
88 | }
89 |
90 | d.font = f
91 | }
92 |
93 | start := time.Now()
94 | bgnow := p.Bgnow.DisplayBg(data.Units)
95 |
96 | var face font.Face
97 | defer func() {
98 | if face != nil {
99 | _ = face.Close()
100 | }
101 | }()
102 |
103 | img := image.NewRGBA(image.Rect(0, 0, width, height))
104 | drawer := &font.Drawer{
105 | Dst: img,
106 | Src: image.NewUniform(data.DynamicIcon.FontColor),
107 | }
108 |
109 | fontSize := data.DynamicIcon.MaxFontSize * 2
110 | for {
111 | var err error
112 | if face, err = opentype.NewFace(d.font, &opentype.FaceOptions{
113 | Size: fontSize,
114 | DPI: 72,
115 | }); err != nil {
116 | return nil, err
117 | }
118 |
119 | drawer.Face = face
120 |
121 | if textWidth := drawer.MeasureString(bgnow); textWidth <= widthF+fixed.I(2) {
122 | break
123 | }
124 |
125 | _ = face.Close()
126 | if fontSize <= 1 {
127 | return nil, ErrFontSize
128 | }
129 | fontSize -= 1.0
130 | }
131 |
132 | metrics := face.Metrics()
133 |
134 | drawer.Dot.X = (widthF - drawer.MeasureString(bgnow)) / 2
135 | drawer.Dot.Y = (heightF + metrics.Ascent - metrics.Descent) / 2
136 | drawer.DrawString(bgnow)
137 |
138 | var buf bytes.Buffer
139 | buf.Grow(2 * bytefmt.KiB)
140 | if err := encode(&buf, img); err != nil {
141 | return nil, err
142 | }
143 |
144 | slog.Debug("Generated dynamic icon",
145 | "took", time.Since(start),
146 | "size", bytefmt.Encode(int64(buf.Len())),
147 | "font_size", fontSize,
148 | "value", bgnow,
149 | "size", buf.Len(),
150 | )
151 | return slices.Clip(buf.Bytes()), nil
152 | }
153 |
--------------------------------------------------------------------------------
/internal/nightscout/testproperties/fetch_test_properties.json:
--------------------------------------------------------------------------------
1 | {
2 | "bgnow": {
3 | "mean": 123,
4 | "last": 123,
5 | "mills": 1664764258417,
6 | "sgvs": [
7 | {
8 | "_id": "633a49639fc610138697ba4d",
9 | "mgdl": 123,
10 | "mills": 1664764258417,
11 | "device": "xDrip-DexcomG5",
12 | "direction": "Flat",
13 | "filtered": 0,
14 | "unfiltered": 0,
15 | "noise": 1,
16 | "rssi": 100,
17 | "type": "sgv",
18 | "scaled": 123
19 | }
20 | ]
21 | },
22 | "buckets": [
23 | {
24 | "mean": 123,
25 | "last": 123,
26 | "mills": 1664764258417,
27 | "index": 0,
28 | "fromMills": 1664764108417,
29 | "toMills": 1664764408417,
30 | "sgvs": [
31 | {
32 | "_id": "633a49639fc610138697ba4d",
33 | "mgdl": 123,
34 | "mills": 1664764258417,
35 | "device": "xDrip-DexcomG5",
36 | "direction": "Flat",
37 | "filtered": 0,
38 | "unfiltered": 0,
39 | "noise": 1,
40 | "rssi": 100,
41 | "type": "sgv",
42 | "scaled": 123
43 | }
44 | ]
45 | },
46 | {
47 | "mean": 122,
48 | "last": 122,
49 | "mills": 1664763959159,
50 | "index": 1,
51 | "fromMills": 1664763808417,
52 | "toMills": 1664764108417,
53 | "sgvs": [
54 | {
55 | "_id": "633a48389fc610138697b95b",
56 | "mgdl": 122,
57 | "mills": 1664763959159,
58 | "device": "xDrip-DexcomG5",
59 | "direction": "Flat",
60 | "filtered": 0,
61 | "unfiltered": 0,
62 | "noise": 1,
63 | "rssi": 100,
64 | "type": "sgv",
65 | "scaled": 122
66 | }
67 | ]
68 | },
69 | {
70 | "mean": 119,
71 | "last": 119,
72 | "mills": 1664763659528,
73 | "index": 2,
74 | "fromMills": 1664763508417,
75 | "toMills": 1664763808417,
76 | "sgvs": [
77 | {
78 | "_id": "633a470d9fc610138697b86a",
79 | "mgdl": 119,
80 | "mills": 1664763659528,
81 | "device": "xDrip-DexcomG5",
82 | "direction": "Flat",
83 | "filtered": 0,
84 | "unfiltered": 0,
85 | "noise": 1,
86 | "rssi": 100,
87 | "type": "sgv",
88 | "scaled": 119
89 | }
90 | ]
91 | },
92 | {
93 | "mean": 116,
94 | "last": 116,
95 | "mills": 1664763359256,
96 | "index": 3,
97 | "fromMills": 1664763208417,
98 | "toMills": 1664763508417,
99 | "sgvs": [
100 | {
101 | "_id": "633a45e09fc610138697b779",
102 | "mgdl": 116,
103 | "mills": 1664763359256,
104 | "device": "xDrip-DexcomG5",
105 | "direction": "Flat",
106 | "filtered": 0,
107 | "unfiltered": 0,
108 | "noise": 1,
109 | "rssi": 100,
110 | "type": "sgv",
111 | "scaled": 116
112 | }
113 | ]
114 | }
115 | ],
116 | "delta": {
117 | "absolute": 1,
118 | "elapsedMins": 4.987633333333333,
119 | "interpolated": false,
120 | "mean5MinsAgo": 122,
121 | "times": {
122 | "recent": 1664764258417,
123 | "previous": 1664763959159
124 | },
125 | "mgdl": 1,
126 | "scaled": 1,
127 | "display": "+1",
128 | "previous": {
129 | "mean": 122,
130 | "last": 122,
131 | "mills": 1664763959159,
132 | "sgvs": [
133 | {
134 | "_id": "633a48389fc610138697b95b",
135 | "mgdl": 122,
136 | "mills": 1664763959159,
137 | "device": "xDrip-DexcomG5",
138 | "direction": "Flat",
139 | "filtered": 0,
140 | "unfiltered": 0,
141 | "noise": 1,
142 | "rssi": 100,
143 | "type": "sgv",
144 | "scaled": 122
145 | }
146 | ]
147 | }
148 | },
149 | "direction": {
150 | "display": null,
151 | "value": "Flat",
152 | "label": "→",
153 | "entity": "→"
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | //nolint:golines
2 | package config
3 |
4 | import (
5 | "os"
6 | "path/filepath"
7 | "runtime"
8 | "sync/atomic"
9 |
10 | "gabe565.com/utils/colorx"
11 | "gabe565.com/utils/slogx"
12 | "github.com/spf13/pflag"
13 | )
14 |
15 | type Config struct {
16 | File string
17 | Flags *pflag.FlagSet
18 | Version string
19 | callbacks []func()
20 | data atomic.Pointer[Data]
21 | }
22 |
23 | func (conf *Config) Data() Data {
24 | d := conf.data.Load()
25 | if d != nil {
26 | return *d
27 | }
28 | return Data{}
29 | }
30 |
31 | type Data struct {
32 | Title string `toml:"title" comment:"Tray title."`
33 | URL string `toml:"url" comment:"Nightscout URL. (required)"`
34 | Token string `toml:"token" comment:"Nightscout token. Using an access token is recommended instead of the API secret."`
35 | Units Unit `toml:"units" comment:"Blood sugar unit. (one of: mg/dL, mmol/L)"`
36 | DynamicIcon DynamicIcon `toml:"dynamic-icon" comment:"Makes the tray icon show the current blood sugar reading."`
37 | Arrows Arrows `toml:"arrows" comment:"Customize the arrows."`
38 | Socket Socket `toml:"socket" comment:"Exposes the latest reading to other applications over a local socket."`
39 | Log Log `toml:"log" comment:"Log configuration"`
40 | Advanced Advanced `toml:"advanced" comment:"Advanced settings."`
41 | }
42 |
43 | type DynamicIcon struct {
44 | Enabled bool `toml:"enabled"`
45 | FontColor colorx.Hex `toml:"font-color" comment:"Hex code used to render text."`
46 | FontFile string `toml:"font-file" comment:"Font path or filename of a system font. If left blank, an embedded font will be used."`
47 | MaxFontSize float64 `toml:"max-font-size" comment:"Maximum font size in points."`
48 | }
49 |
50 | type Arrows struct {
51 | DoubleUp string `toml:"double-up"`
52 | SingleUp string `toml:"single-up"`
53 | FortyFiveUp string `toml:"forty-five-up"`
54 | Flat string `toml:"flat"`
55 | FortyFiveDown string `toml:"forty-five-down"`
56 | SingleDown string `toml:"single-down"`
57 | DoubleDown string `toml:"double-down"`
58 | Unknown string `toml:"unknown"`
59 | }
60 |
61 | type Socket struct {
62 | Enabled bool `toml:"enabled"`
63 | Format string `toml:"format" comment:"Local file format. (one of: csv)"`
64 | Path string `toml:"path" comment:"File path. $TMPDIR will be replaced with the current temp directory."`
65 | }
66 |
67 | type Log struct {
68 | Level slogx.Level `toml:"level" comment:"Values: trace, debug, info, warn, error, fatal, panic"`
69 | Format slogx.Format `toml:"format" comment:"Values: auto, color, plain, json"`
70 | }
71 |
72 | type Advanced struct {
73 | FetchDelay Duration `toml:"fetch-delay" comment:"Time to wait before the next reading should be ready.\nIn testing, this seems to be about 20s behind, so the default is 30s to be safe.\nYour results may vary."`
74 | FallbackInterval Duration `toml:"fallback-interval" comment:"Normally, readings will be fetched when ready (after ~5m).\nThis interval will be used if the next reading time cannot be estimated due to sensor warm-up, missed readings, errors, etc."`
75 | RoundAge bool `toml:"round-age" comment:"If enabled, the reading's age will be rounded up to the nearest minute.\nNightscout rounds the age, so enable this if you want the values to match."`
76 | }
77 |
78 | const configDir = "nightscout-menu-bar"
79 |
80 | func GetDir() (string, error) {
81 | switch runtime.GOOS {
82 | case "darwin":
83 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
84 | return filepath.Join(xdgConfigHome, configDir), nil
85 | }
86 |
87 | homeDir, err := os.UserHomeDir()
88 | if err != nil {
89 | return "", err
90 | }
91 | return filepath.Join(homeDir, ".config", configDir), nil
92 | default:
93 | dir, err := os.UserConfigDir()
94 | if err != nil {
95 | return "", err
96 | }
97 |
98 | dir = filepath.Join(dir, configDir)
99 | return dir, nil
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/internal/fetch/fetch.go:
--------------------------------------------------------------------------------
1 | package fetch
2 |
3 | import (
4 | "context"
5 | "crypto/sha1" //nolint:gosec
6 | "encoding/hex"
7 | "encoding/json"
8 | "errors"
9 | "fmt"
10 | "io"
11 | "log/slog"
12 | "net/http"
13 | "net/url"
14 | "path"
15 | "sync"
16 | "time"
17 |
18 | "gabe565.com/nightscout-menu-bar/internal/config"
19 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
20 | "gabe565.com/nightscout-menu-bar/internal/util"
21 | )
22 |
23 | var (
24 | ErrHTTP = errors.New("unexpected HTTP error")
25 | ErrNotModified = errors.New("not modified")
26 | ErrNoURL = errors.New("please configure your Nightscout URL")
27 | )
28 |
29 | func NewFetch(conf *config.Config) *Fetch {
30 | return &Fetch{
31 | config: conf,
32 | client: &http.Client{
33 | Transport: util.NewUserAgentTransport("nightscout-menu-bar", conf.Version),
34 | Timeout: time.Minute,
35 | },
36 | }
37 | }
38 |
39 | type Fetch struct {
40 | mu sync.Mutex
41 | config *config.Config
42 | client *http.Client
43 | url string
44 | tokenChecksum string
45 | etag string
46 | }
47 |
48 | func (f *Fetch) Do(ctx context.Context) (*nightscout.Properties, error) {
49 | f.mu.Lock()
50 | defer f.mu.Unlock()
51 |
52 | start := time.Now()
53 |
54 | if f.url == "" {
55 | if err := f.updateURLLocked(); err != nil {
56 | return nil, err
57 | }
58 | }
59 |
60 | // Fetch JSON
61 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.url, nil)
62 | if err != nil {
63 | return nil, err
64 | }
65 | if f.etag != "" {
66 | req.Header.Set("If-None-Match", f.etag)
67 | }
68 |
69 | if f.tokenChecksum != "" {
70 | req.Header.Set("Api-Secret", f.tokenChecksum)
71 | }
72 |
73 | slog.Debug("Fetching data",
74 | "etag", f.etag != "",
75 | "secret", f.tokenChecksum != "",
76 | )
77 |
78 | resp, err := f.client.Do(req)
79 | if err != nil {
80 | return nil, err
81 | }
82 | defer func() {
83 | _, _ = io.Copy(io.Discard, resp.Body)
84 | _ = resp.Body.Close()
85 | }()
86 |
87 | switch resp.StatusCode {
88 | case http.StatusNotModified:
89 | slog.Debug("Data was not modified", "took", time.Since(start))
90 | return nil, ErrNotModified
91 | case http.StatusOK:
92 | // Decode JSON
93 | var properties nightscout.Properties
94 | if err := json.NewDecoder(resp.Body).Decode(&properties); err != nil {
95 | return nil, err
96 | }
97 |
98 | slog.Debug("Parsed response", "took", time.Since(start), "data", properties)
99 |
100 | f.etag = resp.Header.Get("etag")
101 | return &properties, nil
102 | default:
103 | f.etag = ""
104 | return nil, fmt.Errorf("%w: %d", ErrHTTP, resp.StatusCode)
105 | }
106 | }
107 |
108 | func (f *Fetch) UpdateURL() error {
109 | f.mu.Lock()
110 | defer f.mu.Unlock()
111 | return f.updateURLLocked()
112 | }
113 |
114 | func (f *Fetch) updateURLLocked() error {
115 | data := f.config.Data()
116 |
117 | u, err := BuildURL(data)
118 | if err != nil {
119 | return err
120 | }
121 |
122 | u.Path = path.Join(u.Path, "api", "v2", "properties", "bgnow,buckets,delta,direction")
123 | f.url = u.String()
124 | slog.Debug("Generated URL", "value", f.url)
125 |
126 | if token := data.Token; token != "" {
127 | rawChecksum := sha1.Sum([]byte(token)) //nolint:gosec
128 | f.tokenChecksum = hex.EncodeToString(rawChecksum[:])
129 | slog.Debug("Generated token checksum", "value", f.tokenChecksum)
130 | } else {
131 | f.tokenChecksum = ""
132 | }
133 |
134 | return nil
135 | }
136 |
137 | func (f *Fetch) Reset() {
138 | f.mu.Lock()
139 | defer f.mu.Unlock()
140 |
141 | slog.Debug("Resetting fetch cache")
142 | f.url = ""
143 | f.tokenChecksum = ""
144 | f.etag = ""
145 | }
146 |
147 | func BuildURL(conf config.Data) (*url.URL, error) {
148 | if conf.URL == "" {
149 | return nil, ErrNoURL
150 | }
151 |
152 | return url.Parse(conf.URL)
153 | }
154 |
155 | func BuildURLWithToken(conf config.Data) (*url.URL, error) {
156 | u, err := BuildURL(conf)
157 | if err != nil {
158 | return u, err
159 | }
160 |
161 | if token := conf.Token; token != "" {
162 | query := u.Query()
163 | query.Set("token", conf.Token)
164 | u.RawQuery = query.Encode()
165 | }
166 |
167 | return u, nil
168 | }
169 |
--------------------------------------------------------------------------------
/internal/assets/src/nightscout-transparent.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 |
3 | issues:
4 | max-same-issues: 50
5 |
6 | formatters:
7 | enable:
8 | - gci
9 | - gofmt
10 | - goimports
11 | - golines
12 | exclusions:
13 | generated: lax
14 | settings:
15 | golines:
16 | max-len: 120
17 |
18 | linters:
19 | enable:
20 | - asasalint
21 | - asciicheck
22 | - bidichk
23 | - bodyclose
24 | - canonicalheader
25 | - copyloopvar
26 | - decorder
27 | - depguard
28 | - dupl
29 | - durationcheck
30 | - err113
31 | - errcheck
32 | - errname
33 | - errorlint
34 | - exptostd
35 | - fatcontext
36 | - forbidigo
37 | - gocheckcompilerdirectives
38 | - gochecknoglobals
39 | - gochecknoinits
40 | - gochecksumtype
41 | - goconst
42 | - gocritic
43 | - godox
44 | - goheader
45 | - godot
46 | - gomoddirectives
47 | - goprintffuncname
48 | - gosec
49 | - importas
50 | - inamedparam
51 | - interfacebloat
52 | - govet
53 | - iface
54 | - ineffassign
55 | - intrange
56 | - ireturn
57 | - loggercheck
58 | - makezero
59 | - mirror
60 | - musttag
61 | - nakedret
62 | - nilerr
63 | - nilnesserr
64 | - nilnil
65 | - noctx
66 | - nolintlint
67 | - nonamedreturns
68 | - nosprintfhostport
69 | - perfsprint
70 | - prealloc
71 | - predeclared
72 | - promlinter
73 | - protogetter
74 | - reassign
75 | - revive
76 | - rowserrcheck
77 | - sloglint
78 | - spancheck
79 | - sqlclosecheck
80 | - staticcheck
81 | - testableexamples
82 | - testifylint
83 | - unconvert
84 | - unparam
85 | - unused
86 | - usestdlibvars
87 | - usetesting
88 | - wastedassign
89 | - whitespace
90 | settings:
91 | cyclop:
92 | max-complexity: 30
93 | depguard:
94 | rules:
95 | "deprecated":
96 | files:
97 | - "$all"
98 | deny:
99 | - pkg: github.com/golang/protobuf
100 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
101 | - pkg: github.com/satori/go.uuid
102 | desc: Use github.com/google/uuid instead, satori's package is not maintained
103 | - pkg: github.com/gofrs/uuid$
104 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5
105 | "non-test files":
106 | files:
107 | - "!$test"
108 | deny:
109 | - pkg: math/rand$
110 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
111 | "non-main files":
112 | files:
113 | - "!**/main.go"
114 | deny:
115 | - pkg: log$
116 | desc: Use log/slog instead, see https://go.dev/blog/slog
117 | errcheck:
118 | check-type-assertions: true
119 | gocritic:
120 | settings:
121 | captLocal:
122 | paramsOnly: false
123 | underef:
124 | skipRecvDeref: false
125 | gosec:
126 | excludes:
127 | - G306
128 | govet:
129 | enable-all: true
130 | disable:
131 | - fieldalignment
132 | - shadow
133 | inamedparam:
134 | skip-single-param: true
135 | nakedret:
136 | max-func-lines: 0
137 | nestif:
138 | min-complexity: 15
139 | nolintlint:
140 | allow-no-explanation: [golines]
141 | require-specific: true
142 | perfsprint:
143 | strconcat: false
144 | usetesting:
145 | os-temp-dir: true
146 |
147 | exclusions:
148 | warn-unused: true
149 | generated: lax
150 | presets:
151 | - comments
152 | - common-false-positives
153 | - legacy
154 | - std-error-handling
155 | rules:
156 | - linters:
157 | - revive
158 | text: var-naming
159 | - linters:
160 | - noctx
161 | text: .*log/slog.* must not be called. use .*log/slog.*Context
162 | - path: _test\.go
163 | linters:
164 | - dupl
165 | - err113
166 | - errcheck
167 | - gochecknoglobals
168 | - gosec
169 |
--------------------------------------------------------------------------------
/internal/config/load.go:
--------------------------------------------------------------------------------
1 | //go:build !wasm
2 |
3 | package config
4 |
5 | import (
6 | "bytes"
7 | "context"
8 | "errors"
9 | "log/slog"
10 | "os"
11 | "path/filepath"
12 | "slices"
13 | "time"
14 |
15 | "github.com/knadh/koanf/providers/file"
16 | "github.com/knadh/koanf/providers/rawbytes"
17 | "github.com/knadh/koanf/providers/structs"
18 | "github.com/knadh/koanf/v2"
19 | "github.com/pelletier/go-toml/v2"
20 | )
21 |
22 | func (conf *Config) RegisterFlags() {
23 | conf.Flags.StringVarP(&conf.File, "config", "c", "", "Config file")
24 | }
25 |
26 | func (conf *Config) Load() error {
27 | k := koanf.New(".")
28 |
29 | // Load conf config
30 | if err := k.Load(structs.Provider(conf, "toml"), nil); err != nil {
31 | return err
32 | }
33 |
34 | // Find config file
35 | if conf.File == "" {
36 | cfgDir, err := GetDir()
37 | if err != nil {
38 | return err
39 | }
40 |
41 | conf.File = filepath.Join(cfgDir, "config.toml")
42 | }
43 |
44 | // Load config file if exists
45 | cfgContents, err := os.ReadFile(conf.File)
46 | if err != nil && !errors.Is(err, os.ErrNotExist) {
47 | return err
48 | }
49 |
50 | // Parse config file
51 | parser := TOMLParser{}
52 | if err := k.Load(rawbytes.Provider(cfgContents), parser); err != nil {
53 | return err
54 | }
55 |
56 | if err := migrateConfig(k); err != nil {
57 | return err
58 | }
59 |
60 | data := conf.Data()
61 |
62 | if err := k.UnmarshalWithConf("", &data, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
63 | return err
64 | }
65 |
66 | if err := conf.Write(data); err != nil {
67 | return err
68 | }
69 |
70 | conf.InitLog(os.Stderr)
71 |
72 | slog.Info("Loaded config", "file", conf.File)
73 | return nil
74 | }
75 |
76 | func (conf *Config) Write(data Data) error {
77 | // Find config file
78 | if conf.File == "" {
79 | cfgDir, err := GetDir()
80 | if err != nil {
81 | return err
82 | }
83 |
84 | conf.File = filepath.Join(cfgDir, "config.toml")
85 | }
86 |
87 | var cfgNotExists bool
88 | // Load config file if exists
89 | cfgContents, err := os.ReadFile(conf.File)
90 | if err != nil {
91 | if errors.Is(err, os.ErrNotExist) {
92 | cfgNotExists = true
93 | } else {
94 | return err
95 | }
96 | }
97 |
98 | newCfg, err := toml.Marshal(&data)
99 | if err != nil {
100 | return err
101 | }
102 |
103 | if !bytes.Equal(cfgContents, newCfg) {
104 | logger := slog.With("file", conf.File)
105 | if cfgNotExists {
106 | logger.Info("Creating config")
107 |
108 | if err := os.MkdirAll(filepath.Dir(conf.File), 0o777); err != nil {
109 | return err
110 | }
111 | } else {
112 | logger.Info("Updating config")
113 | }
114 |
115 | if err := os.WriteFile(conf.File, newCfg, 0o666); err != nil {
116 | return err
117 | }
118 | }
119 |
120 | conf.data.Store(&data)
121 | return nil
122 | }
123 |
124 | func (conf *Config) Watch(ctx context.Context) error {
125 | logger := slog.With("file", conf.File)
126 | logger.Info("Watching config")
127 | f := file.Provider(conf.File)
128 | return f.Watch(func(_ any, err error) {
129 | if err != nil {
130 | logger.Error("Config watcher failed", "error", err)
131 | if ctx.Err() != nil {
132 | clear(conf.callbacks)
133 | conf.callbacks = conf.callbacks[:0]
134 | return
135 | }
136 | time.Sleep(time.Second)
137 | defer func() {
138 | _ = conf.Watch(ctx)
139 | }()
140 | }
141 |
142 | logger.Debug("Config watcher triggered")
143 | if err := conf.Load(); err != nil {
144 | logger.Error("Failed to load config", "error", err)
145 | }
146 |
147 | for _, fn := range conf.callbacks {
148 | fn()
149 | }
150 | })
151 | }
152 |
153 | func (conf *Config) AddCallback(fn func()) int {
154 | conf.callbacks = append(conf.callbacks, fn)
155 | return len(conf.callbacks) - 1
156 | }
157 |
158 | func (conf *Config) RemoveCallback(idx int) {
159 | conf.callbacks = slices.Delete(conf.callbacks, idx, idx+1)
160 | }
161 |
162 | func migrateConfig(k *koanf.Koanf) error {
163 | if k.Exists("interval") {
164 | slog.Info("Migrating config: interval to advanced.fallback-interval")
165 | if err := k.Set("advanced.fallback-interval", k.Get("interval")); err != nil {
166 | return err
167 | }
168 | }
169 |
170 | return nil
171 | }
172 |
--------------------------------------------------------------------------------
/assets/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/assets/src/nightscout.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/tray/systray.go:
--------------------------------------------------------------------------------
1 | package tray
2 |
3 | import (
4 | "context"
5 | "image/color"
6 | "io"
7 | "log/slog"
8 | "os"
9 |
10 | "fyne.io/systray"
11 | "gabe565.com/nightscout-menu-bar/internal/assets"
12 | "gabe565.com/nightscout-menu-bar/internal/autostart"
13 | "gabe565.com/nightscout-menu-bar/internal/config"
14 | "gabe565.com/nightscout-menu-bar/internal/dynamicicon"
15 | "gabe565.com/nightscout-menu-bar/internal/fetch"
16 | "gabe565.com/nightscout-menu-bar/internal/ticker"
17 | "gabe565.com/nightscout-menu-bar/internal/tray/items"
18 | "gabe565.com/nightscout-menu-bar/internal/tray/messages"
19 | "github.com/skratchdot/open-golang/open"
20 | )
21 |
22 | const AboutURL = "https://github.com/gabe565/nightscout-menu-bar"
23 |
24 | func New(version string) *Tray {
25 | t := &Tray{
26 | config: config.New(config.WithVersion(version)),
27 | bus: make(chan any, 1),
28 | }
29 | if err := t.config.Flags.Parse(os.Args[1:]); err != nil {
30 | _, _ = io.WriteString(os.Stderr, err.Error()+"\n")
31 | os.Exit(2)
32 | }
33 |
34 | if err := t.config.Load(); err != nil {
35 | t.onError(err)
36 | }
37 |
38 | t.ticker = ticker.New(t.config, t.bus)
39 |
40 | if t.config.Data().DynamicIcon.Enabled {
41 | t.dynamicIcon = dynamicicon.New(t.config)
42 | }
43 |
44 | t.config.AddCallback(func() {
45 | t.bus <- messages.ReloadConfigMsg{}
46 | })
47 | return t
48 | }
49 |
50 | type Tray struct {
51 | config *config.Config
52 | ticker *ticker.Ticker
53 | dynamicIcon *dynamicicon.DynamicIcon
54 | bus chan any
55 | items items.Items
56 | }
57 |
58 | func (t *Tray) Run(ctx context.Context) {
59 | t.ticker.Start(ctx)
60 | if err := t.config.Watch(ctx); err != nil {
61 | t.onError(err)
62 | }
63 | systray.Run(t.onReady(ctx), t.onExit)
64 | }
65 |
66 | func (t *Tray) Quit() {
67 | systray.Quit()
68 | }
69 |
70 | func (t *Tray) onReady(ctx context.Context) func() {
71 | return func() {
72 | systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout)
73 | if t.dynamicIcon == nil {
74 | systray.SetTitle(t.config.Data().Title)
75 | }
76 | systray.SetTooltip(t.config.Data().Title)
77 |
78 | t.items = items.New(t.config)
79 |
80 | for {
81 | select {
82 | case <-ctx.Done():
83 | t.Quit()
84 | case <-t.items.OpenNightscout.ClickedCh:
85 | u, err := fetch.BuildURLWithToken(t.config.Data())
86 | if err != nil {
87 | t.onError(err)
88 | return
89 | }
90 | slog.Debug("Opening Nightscout", "url", u)
91 | if err := open.Run(u.String()); err != nil {
92 | t.onError(err)
93 | }
94 | case <-t.items.Preferences.URL.ClickedCh:
95 | go func() {
96 | if err := t.items.Preferences.URL.Prompt(); err != nil {
97 | t.onError(err)
98 | }
99 | }()
100 | case <-t.items.About.ClickedCh:
101 | if err := open.Run(AboutURL); err != nil {
102 | t.onError(err)
103 | }
104 | case <-t.items.Preferences.Token.ClickedCh:
105 | go func() {
106 | if err := t.items.Preferences.Token.Prompt(); err != nil {
107 | t.onError(err)
108 | }
109 | }()
110 | case <-t.items.Preferences.Units.ClickedCh:
111 | go func() {
112 | if err := t.items.Preferences.Units.Prompt(); err != nil {
113 | t.onError(err)
114 | }
115 | }()
116 | case <-t.items.Preferences.StartOnLogin.ClickedCh:
117 | if t.items.Preferences.StartOnLogin.Checked() {
118 | if err := autostart.Disable(); err != nil {
119 | t.onError(err)
120 | continue
121 | }
122 | t.items.Preferences.StartOnLogin.Uncheck()
123 | } else {
124 | if err := autostart.Enable(); err != nil {
125 | t.onError(err)
126 | continue
127 | }
128 | t.items.Preferences.StartOnLogin.Check()
129 | }
130 | case <-t.items.Preferences.Socket.ClickedCh:
131 | if err := t.items.Preferences.Socket.Toggle(); err != nil {
132 | t.onError(err)
133 | }
134 | case <-t.items.Preferences.DynamicIcon.ClickedCh:
135 | if err := t.items.Preferences.DynamicIcon.Toggle(); err != nil {
136 | t.onError(err)
137 | }
138 | case <-t.items.Preferences.DynamicIconColor.ClickedCh:
139 | if err := t.items.Preferences.DynamicIconColor.Choose(); err != nil {
140 | t.onError(err)
141 | }
142 | case <-t.items.Quit.ClickedCh:
143 | t.Quit()
144 | case msg := <-t.bus:
145 | switch msg := msg.(type) {
146 | case messages.RenderMessage:
147 | if msg.Type == messages.RenderTypeFetch {
148 | t.items.Error.Hide()
149 | }
150 |
151 | value := msg.Properties.String(t.config.Data())
152 | slog.Debug("Updating reading", "value", value)
153 | if t.dynamicIcon == nil {
154 | systray.SetTitle(value)
155 | } else {
156 | if icon, err := t.dynamicIcon.Generate(msg.Properties); err == nil {
157 | systray.SetTitle("")
158 | if t.config.Data().DynamicIcon.FontColor.Color == color.White {
159 | systray.SetTemplateIcon(icon, icon)
160 | } else {
161 | systray.SetIcon(icon)
162 | }
163 | } else {
164 | t.onError(err)
165 | systray.SetTitle(value)
166 | systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout)
167 | }
168 | }
169 | systray.SetTooltip(value)
170 | t.items.LastReading.SetTitle(value)
171 |
172 | for i, reading := range msg.Properties.Buckets {
173 | if i < len(t.items.History.Subitems) {
174 | t.items.History.Subitems[i].SetTitle(reading.String(t.config.Data()))
175 | } else {
176 | entry := t.items.History.AddSubMenuItem(reading.String(t.config.Data()), "")
177 | entry.Disable()
178 | t.items.History.Subitems = append(t.items.History.Subitems, entry)
179 | }
180 | }
181 | case error:
182 | slog.Error("Displaying error", "error", msg)
183 | t.items.Error.SetTitle(msg.Error())
184 | t.items.Error.Show()
185 | case messages.ReloadConfigMsg:
186 | if t.config.Data().DynamicIcon.Enabled {
187 | t.dynamicIcon = dynamicicon.New(t.config)
188 | } else if t.dynamicIcon != nil {
189 | t.dynamicIcon = nil
190 | systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout)
191 | }
192 | }
193 | }
194 | }
195 | }
196 | }
197 |
198 | func (t *Tray) onError(err error) {
199 | select {
200 | case t.bus <- err:
201 | default:
202 | slog.Error("Unable to display error due to full bus", "error", err)
203 | }
204 | }
205 |
206 | func (t *Tray) onExit() {
207 | slog.Info("Exiting")
208 | t.ticker.Close()
209 | close(t.bus)
210 | }
211 |
--------------------------------------------------------------------------------
/internal/nightscout/testproperties/testproperties.go:
--------------------------------------------------------------------------------
1 | //nolint:gochecknoglobals,gosmopolitan
2 | package testproperties
3 |
4 | import (
5 | _ "embed"
6 | "time"
7 |
8 | "gabe565.com/nightscout-menu-bar/internal/nightscout"
9 | )
10 |
11 | var (
12 | //go:embed fetch_test_properties.json
13 | JSON []byte
14 |
15 | Properties = &nightscout.Properties{
16 | Bgnow: nightscout.Reading{
17 | Mean: "123",
18 | Last: 123,
19 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)},
20 | Index: "",
21 | FromMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)},
22 | ToMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)},
23 | Sgvs: []nightscout.SGV{{
24 | ID: "633a49639fc610138697ba4d",
25 | Device: "xDrip-DexcomG5",
26 | Direction: "Flat",
27 | Filtered: "0",
28 | Mgdl: 123,
29 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)},
30 | Noise: "1",
31 | Rssi: "100",
32 | Scaled: "123",
33 | Type: "sgv",
34 | Unfiltered: "0",
35 | }},
36 | },
37 | Buckets: []nightscout.Reading{
38 | {
39 | Mean: "123",
40 | Last: 123,
41 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)},
42 | Index: "0",
43 | FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)},
44 | ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 33, 28, 417000000, time.Local)},
45 | Sgvs: []nightscout.SGV{{
46 | ID: "633a49639fc610138697ba4d",
47 | Device: "xDrip-DexcomG5",
48 | Direction: "Flat",
49 | Filtered: "0",
50 | Mgdl: 123,
51 | Mills: nightscout.Mills{
52 | Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local),
53 | },
54 | Noise: "1",
55 | Rssi: "100",
56 | Scaled: "123",
57 | Type: "sgv",
58 | Unfiltered: "0",
59 | }},
60 | },
61 | {
62 | Mean: "122",
63 | Last: 122,
64 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)},
65 | Index: "1",
66 | FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)},
67 | ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)},
68 | Sgvs: []nightscout.SGV{{
69 | ID: "633a48389fc610138697b95b",
70 | Device: "xDrip-DexcomG5",
71 | Direction: "Flat",
72 | Filtered: "0",
73 | Mgdl: 122,
74 | Mills: nightscout.Mills{
75 | Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local),
76 | },
77 | Noise: "1",
78 | Rssi: "100",
79 | Scaled: "122",
80 | Type: "sgv",
81 | Unfiltered: "0",
82 | }},
83 | },
84 | {
85 | Mean: "119",
86 | Last: 119,
87 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local)},
88 | Index: "2",
89 | FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)},
90 | ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)},
91 | Sgvs: []nightscout.SGV{{
92 | ID: "633a470d9fc610138697b86a",
93 | Device: "xDrip-DexcomG5",
94 | Direction: "Flat",
95 | Filtered: "0",
96 | Mgdl: 119,
97 | Mills: nightscout.Mills{
98 | Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local),
99 | },
100 | Noise: "1",
101 | Rssi: "100",
102 | Scaled: "119",
103 | Type: "sgv",
104 | Unfiltered: "0",
105 | }},
106 | },
107 | {
108 | Mean: "116",
109 | Last: 116,
110 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local)},
111 | Index: "3",
112 | FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 13, 28, 417000000, time.Local)},
113 | ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)},
114 | Sgvs: []nightscout.SGV{{
115 | ID: "633a45e09fc610138697b779",
116 | Device: "xDrip-DexcomG5",
117 | Direction: "Flat",
118 | Filtered: "0",
119 | Mgdl: 116,
120 | Mills: nightscout.Mills{
121 | Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local),
122 | },
123 | Noise: "1",
124 | Rssi: "100",
125 | Scaled: "116",
126 | Type: "sgv",
127 | Unfiltered: "0",
128 | }},
129 | },
130 | },
131 | Delta: nightscout.Delta{
132 | Absolute: "1",
133 | DisplayVal: "+1",
134 | ElapsedMins: "4.987633333333333",
135 | Interpolated: false,
136 | Mean5MinsAgo: "122",
137 | Mgdl: 1,
138 | Previous: nightscout.Reading{
139 | Mean: "122",
140 | Last: 122,
141 | Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)},
142 | Index: "",
143 | FromMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)},
144 | ToMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)},
145 | Sgvs: []nightscout.SGV{{
146 | ID: "633a48389fc610138697b95b",
147 | Device: "xDrip-DexcomG5",
148 | Direction: "Flat",
149 | Filtered: "0",
150 | Mgdl: 122,
151 | Mills: nightscout.Mills{
152 | Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local),
153 | },
154 | Noise: "1",
155 | Rssi: "100",
156 | Scaled: "122",
157 | Type: "sgv",
158 | Unfiltered: "0",
159 | }},
160 | },
161 | Scaled: "1",
162 | Times: nightscout.Times{
163 | Previous: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)},
164 | Recent: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)},
165 | },
166 | },
167 | Direction: nightscout.Direction{
168 | Entity: "→",
169 | Label: "→",
170 | Value: "Flat",
171 | },
172 | }
173 |
174 | Etag = `W/"20-8b9f9edb2e2b1a9f5a8ffbf92a1a1c42f170a654"`
175 | )
176 |
--------------------------------------------------------------------------------
/internal/nightscout/reading_test.go:
--------------------------------------------------------------------------------
1 | package nightscout
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "testing"
7 | "time"
8 |
9 | "gabe565.com/nightscout-menu-bar/internal/config"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestReading_Arrow(t *testing.T) {
15 | t.Parallel()
16 | defaultArrows := config.New().Data().Arrows
17 |
18 | type fields struct {
19 | Mean json.Number
20 | Last Mgdl
21 | Mills Mills
22 | Index json.Number
23 | FromMills Mills
24 | ToMills Mills
25 | Sgvs []SGV
26 | }
27 | type args struct {
28 | arrows config.Arrows
29 | }
30 | tests := []struct {
31 | name string
32 | fields fields
33 | args args
34 | want string
35 | }{
36 | {"TripleUp", fields{Sgvs: []SGV{{Direction: "TripleUp"}}}, args{defaultArrows}, "⇈"},
37 | {"DoubleUp", fields{Sgvs: []SGV{{Direction: "DoubleUp"}}}, args{defaultArrows}, "⇈"},
38 | {"SingleUp", fields{Sgvs: []SGV{{Direction: "SingleUp"}}}, args{defaultArrows}, "↑"},
39 | {"FortyFiveUp", fields{Sgvs: []SGV{{Direction: "FortyFiveUp"}}}, args{defaultArrows}, "↗"},
40 | {"Flat", fields{Sgvs: []SGV{{Direction: "Flat"}}}, args{defaultArrows}, "→"},
41 | {"FortyFiveDown", fields{Sgvs: []SGV{{Direction: "FortyFiveDown"}}}, args{defaultArrows}, "↘"},
42 | {"SingleDown", fields{Sgvs: []SGV{{Direction: "SingleDown"}}}, args{defaultArrows}, "↓"},
43 | {"DoubleDown", fields{Sgvs: []SGV{{Direction: "DoubleDown"}}}, args{defaultArrows}, "⇊"},
44 | {"TripleDown", fields{Sgvs: []SGV{{Direction: "TripleDown"}}}, args{defaultArrows}, "⇊"},
45 | {"unknown", fields{}, args{defaultArrows}, "-"},
46 | }
47 | for _, tt := range tests {
48 | t.Run(tt.name, func(t *testing.T) {
49 | t.Parallel()
50 | r := Reading{
51 | Mean: tt.fields.Mean,
52 | Last: tt.fields.Last,
53 | Mills: tt.fields.Mills,
54 | Index: tt.fields.Index,
55 | FromMills: tt.fields.FromMills,
56 | ToMills: tt.fields.ToMills,
57 | Sgvs: tt.fields.Sgvs,
58 | }
59 | assert.Equal(t, tt.want, r.Arrow(tt.args.arrows))
60 | })
61 | }
62 | }
63 |
64 | func TestReading_String(t *testing.T) {
65 | t.Parallel()
66 | type fields struct {
67 | Mean json.Number
68 | Last Mgdl
69 | Mills Mills
70 | Index json.Number
71 | FromMills Mills
72 | ToMills Mills
73 | Sgvs []SGV
74 | }
75 | type args struct {
76 | conf *config.Config
77 | }
78 | tests := []struct {
79 | name string
80 | fields fields
81 | args args
82 | want string
83 | }{
84 | {
85 | "simple",
86 | fields{
87 | Last: 100,
88 | Mills: Mills{time.Now()},
89 | Sgvs: []SGV{{Direction: "Flat"}},
90 | },
91 | args{config.New()},
92 | "100 → [0m]",
93 | },
94 | }
95 | for _, tt := range tests {
96 | t.Run(tt.name, func(t *testing.T) {
97 | t.Parallel()
98 | r := Reading{
99 | Mean: tt.fields.Mean,
100 | Last: tt.fields.Last,
101 | Mills: tt.fields.Mills,
102 | Index: tt.fields.Index,
103 | FromMills: tt.fields.FromMills,
104 | ToMills: tt.fields.ToMills,
105 | Sgvs: tt.fields.Sgvs,
106 | }
107 | assert.Equal(t, tt.want, r.String(tt.args.conf.Data()))
108 | })
109 | }
110 | }
111 |
112 | func TestReading_DisplayBg(t *testing.T) {
113 | t.Parallel()
114 | type fields struct {
115 | Mean json.Number
116 | Last Mgdl
117 | Mills Mills
118 | Index json.Number
119 | FromMills Mills
120 | ToMills Mills
121 | Sgvs []SGV
122 | }
123 | type args struct {
124 | units config.Unit
125 | }
126 | tests := []struct {
127 | name string
128 | args args
129 | fields fields
130 | want string
131 | }{
132 | {"95", args{config.UnitMgdl}, fields{Last: 95}, "95"},
133 | {"LOW", args{config.UnitMgdl}, fields{Last: 39}, "LOW"},
134 | {"HIGH", args{config.UnitMgdl}, fields{Last: 401}, "HIGH"},
135 | {"mmol", args{config.UnitMmol}, fields{Last: 100}, "5.6"},
136 | }
137 | for _, tt := range tests {
138 | t.Run(tt.name, func(t *testing.T) {
139 | t.Parallel()
140 | r := &Reading{
141 | Mean: tt.fields.Mean,
142 | Last: tt.fields.Last,
143 | Mills: tt.fields.Mills,
144 | Index: tt.fields.Index,
145 | FromMills: tt.fields.FromMills,
146 | ToMills: tt.fields.ToMills,
147 | Sgvs: tt.fields.Sgvs,
148 | }
149 | assert.Equal(t, tt.want, r.DisplayBg(tt.args.units))
150 | })
151 | }
152 | }
153 |
154 | var normalReading = `{
155 | "mean": 100,
156 | "last": 100,
157 | "mills": %d,
158 | "sgvs": [
159 | {
160 | "_id": "a",
161 | "mgdl": 100,
162 | "mills": %d,
163 | "device": "xDrip-DexcomG5",
164 | "direction": "Flat",
165 | "filtered": 0,
166 | "unfiltered": 0,
167 | "noise": 1,
168 | "rssi": 100,
169 | "type": "sgv",
170 | "scaled": 100
171 | }
172 | ]
173 | }`
174 |
175 | var lowReading = `{
176 | "sgvs": [
177 | {
178 | "_id": "a",
179 | "mgdl": 39,
180 | "mills": %d,
181 | "device": "xDrip-DexcomG5",
182 | "direction": "Flat",
183 | "filtered": 0,
184 | "unfiltered": 0,
185 | "noise": 1,
186 | "rssi": 100,
187 | "type": "sgv",
188 | "scaled": 39
189 | }
190 | ]
191 | }`
192 |
193 | func TestReading_UnmarshalJSON(t *testing.T) {
194 | t.Parallel()
195 | now := time.Now()
196 |
197 | type fields struct {
198 | Mean json.Number
199 | Last Mgdl
200 | Mills Mills
201 | Index json.Number
202 | FromMills Mills
203 | ToMills Mills
204 | Sgvs []SGV
205 | }
206 | type args struct {
207 | bytes []byte
208 | }
209 | tests := []struct {
210 | name string
211 | fields fields
212 | args args
213 | wantErr require.ErrorAssertionFunc
214 | }{
215 | {
216 | "simple",
217 | fields{},
218 | args{[]byte(fmt.Sprintf(normalReading, now.UnixMilli(), now.UnixMilli()))},
219 | require.NoError,
220 | },
221 | {
222 | "low",
223 | fields{},
224 | args{[]byte(fmt.Sprintf(lowReading, now.UnixMilli()))},
225 | require.NoError,
226 | },
227 | {
228 | "error",
229 | fields{},
230 | args{[]byte("{")},
231 | require.Error,
232 | },
233 | }
234 | for _, tt := range tests {
235 | t.Run(tt.name, func(t *testing.T) {
236 | t.Parallel()
237 | r := &Reading{
238 | Mean: tt.fields.Mean,
239 | Last: tt.fields.Last,
240 | Mills: tt.fields.Mills,
241 | Index: tt.fields.Index,
242 | FromMills: tt.fields.FromMills,
243 | ToMills: tt.fields.ToMills,
244 | Sgvs: tt.fields.Sgvs,
245 | }
246 | tt.wantErr(t, r.UnmarshalJSON(tt.args.bytes))
247 | })
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on: push
4 |
5 | concurrency:
6 | group: ${{ github.workflow }}-${{ github.ref }}
7 | cancel-in-progress: true
8 |
9 | jobs:
10 | lint:
11 | name: Lint
12 | runs-on: ubuntu-24.04
13 | permissions:
14 | contents: read
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
18 | - name: Set up Go
19 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
20 | with:
21 | go-version-file: go.mod
22 | cache: false
23 | - name: Install systray dependencies
24 | uses: awalsh128/cache-apt-pkgs-action@4c82c3ccdc1344ee11e9775dbdbdf43aa8a5614e # v1.5.1
25 | with:
26 | packages: gcc libgtk-3-dev libayatana-appindicator3-dev
27 | version: amd64
28 | - name: Lint
29 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
30 |
31 | test:
32 | name: Test
33 | runs-on: ubuntu-24.04
34 | permissions:
35 | contents: read
36 | steps:
37 | - name: Checkout code
38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
39 | - name: Set up Go
40 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
41 | with:
42 | go-version-file: go.mod
43 | - name: Install systray dependencies
44 | uses: awalsh128/cache-apt-pkgs-action@4c82c3ccdc1344ee11e9775dbdbdf43aa8a5614e # v1.5.1
45 | with:
46 | packages: gcc libgtk-3-dev libayatana-appindicator3-dev
47 | version: amd64
48 | - name: Test
49 | env:
50 | TZ: America/Chicago
51 | run: go test ./...
52 |
53 | build-linux:
54 | name: Build (linux-${{ matrix.platform }})
55 | runs-on: ${{ matrix.runs-on }}
56 | permissions:
57 | contents: read
58 | strategy:
59 | matrix:
60 | include:
61 | - platform: amd64
62 | runs-on: ubuntu-24.04
63 | - platform: arm64
64 | runs-on: ubuntu-24.04-arm
65 | steps:
66 | - name: Checkout code
67 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
68 | - name: Set up Go
69 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
70 | with:
71 | go-version-file: go.mod
72 | - name: Install systray dependencies
73 | uses: awalsh128/cache-apt-pkgs-action@4c82c3ccdc1344ee11e9775dbdbdf43aa8a5614e # v1.5.1
74 | with:
75 | packages: gcc libgtk-3-dev libayatana-appindicator3-dev
76 | version: ${{ matrix.platform }}
77 | - name: Build
78 | env:
79 | VERSION: ${{ github.ref_name }}
80 | run: go build -ldflags="-w -s -X main.version=$VERSION" -trimpath -o dist/nightscout-menu-bar
81 | - name: Compress
82 | run: tar -czvf nightscout-menu-bar_linux_${{ matrix.platform }}.tar.gz -C dist .
83 | - name: Upload Artifact
84 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
85 | with:
86 | name: nightscout-menu-bar_linux_${{ matrix.platform }}
87 | path: nightscout-menu-bar_linux_${{ matrix.platform }}.tar.gz
88 |
89 | build-windows:
90 | name: Build (windows)
91 | runs-on: windows-2025
92 | permissions:
93 | contents: read
94 | steps:
95 | - name: Checkout code
96 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
97 | - name: Set up Go
98 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
99 | with:
100 | go-version-file: go.mod
101 | - name: Build
102 | shell: bash
103 | env:
104 | VERSION: ${{ github.ref_name }}
105 | run: ./hack/build-windows.sh
106 | - name: Compress
107 | working-directory: dist
108 | shell: powershell
109 | run: Compress-Archive -Path * -Destination ../nightscout-menu-bar_windows_amd64.zip
110 | - name: Upload Artifact
111 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
112 | with:
113 | name: nightscout-menu-bar_windows_amd64
114 | path: nightscout-menu-bar_windows_amd64.zip
115 |
116 | build-darwin:
117 | name: Build (darwin)
118 | runs-on: macos-14
119 | permissions:
120 | contents: read
121 | steps:
122 | - name: Checkout code
123 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
124 | - name: Set up Go
125 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
126 | with:
127 | go-version-file: go.mod
128 | - name: Build
129 | env:
130 | VERSION: ${{ github.ref_name }}
131 | run: ./hack/build-darwin.sh
132 | - name: Upload Artifact
133 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
134 | with:
135 | name: nightscout-menu-bar_darwin
136 | path: dist/nightscout-menu-bar_darwin.tar.gz
137 | - name: Upload Artifact
138 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
139 | with:
140 | name: nightscout-menu-bar_cask
141 | path: dist/nightscout-menu-bar.rb
142 |
143 | release:
144 | name: Release
145 | if: startsWith(github.ref, 'refs/tags/')
146 | needs: [lint, test, build-windows, build-darwin, build-linux]
147 | runs-on: ubuntu-24.04
148 | permissions:
149 | contents: write
150 | steps:
151 | - name: Checkout
152 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
153 | with:
154 | fetch-depth: 0
155 | - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
156 | with:
157 | pattern: nightscout-menu-bar_*
158 | path: dist
159 | merge-multiple: true
160 | - name: Generate Changelog
161 | id: changelog
162 | uses: gabe565/changelog-generator@733bb2c02fab0bb0e2dd9a059e0dc9f668d05c95 # v1.1.5
163 | - name: Release
164 | uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
165 | with:
166 | body: ${{ steps.changelog.outputs.changelog }}
167 | files: |
168 | dist/nightscout-menu-bar_*
169 |
170 | update-homebrew-tap:
171 | name: Update Homebrew Tap
172 | runs-on: ubuntu-24.04
173 | if: startsWith(github.ref, 'refs/tags/')
174 | needs: [release]
175 | permissions:
176 | contents: read
177 | env:
178 | commit_name: gabe565-goreleaser[bot]
179 | commit_email: 159411820+gabe565-goreleaser[bot]@users.noreply.github.com
180 | steps:
181 | - name: Generate Token
182 | id: app-token
183 | uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
184 | with:
185 | app-id: ${{ secrets.BOT_APP_ID }}
186 | private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
187 | repositories: homebrew-tap
188 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
189 | with:
190 | repository: gabe565/homebrew-tap
191 | token: ${{ steps.app-token.outputs.token }}
192 | - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
193 | with:
194 | name: nightscout-menu-bar_cask
195 | path: Casks
196 | - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1
197 | with:
198 | commit_message: Brew formula update for nightscout-menu-bar version ${{ github.ref_name }}
199 | commit_user_name: ${{ env.commit_name }}
200 | commit_user_email: ${{ env.commit_email }}
201 | commit_author: ${{ env.commit_name }} <${{ env.commit_email }}>
202 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
3 | fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
4 | fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
5 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c h1:740ifPX0340EHhAPnr5PJ5FgpDFMX3EEmd8CMLcSi5I=
6 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c/go.mod h1:zPhcEoKWZOPz7CH+g3nzkKY/AOs+QEo9SokKKkJyq2U=
7 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
8 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
9 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
10 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
11 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
12 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
13 | github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw=
14 | github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA=
20 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw=
21 | github.com/dmarkham/enumer v1.5.11 h1:quorLCaEfzjJ23Pf7PB9lyyaHseh91YfTM/sAD/4Mbo=
22 | github.com/dmarkham/enumer v1.5.11/go.mod h1:yixql+kDDQRYqcuBM2n9Vlt7NoT9ixgXhaXry8vmRg8=
23 | github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2 h1:CgF8+TNFvlnxEbplSgS70ZI4IUFEzVkY+ICNqTVE/AM=
24 | github.com/emersion/go-autostart v0.0.0-20250403115856-34830d6457d2/go.mod h1:buzQsO8HHkZX2Q45fdfGH1xejPjuDQaXH8btcYMFzPM=
25 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
26 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
27 | github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
28 | github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
29 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
30 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
31 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
32 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
33 | github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
34 | github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
35 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
36 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
37 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
38 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
41 | github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 h1:Odeq5rB6OZSkib5gqTG+EM1iF0bUVjYYd33XB1ULv00=
42 | github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346/go.mod h1:4ggHM2qnyyZjenBb7RpwVzIj+JMsu9kHCVxMjB30hGs=
43 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
44 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
45 | github.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY=
46 | github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
47 | github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
48 | github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
49 | github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
50 | github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
51 | github.com/knadh/koanf/providers/rawbytes v1.0.0 h1:MrKDh/HksJlKJmaZjgs4r8aVBb/zsJyc/8qaSnzcdNI=
52 | github.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo=
53 | github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4=
54 | github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w=
55 | github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A=
56 | github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q=
57 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
58 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
59 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
60 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
61 | github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
62 | github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
63 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
64 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
65 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
66 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
67 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
68 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
69 | github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI=
70 | github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4=
71 | github.com/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U=
72 | github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM=
73 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
74 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
75 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
76 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
77 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
78 | github.com/prashantgupta24/mac-sleep-notifier v1.0.1 h1:xd1lPtnn1gxGNjD2tCoVDoOtiQcQ8B9KNFhcWgGqreQ=
79 | github.com/prashantgupta24/mac-sleep-notifier v1.0.1/go.mod h1:bcfTio1xW+rjjZzdF0kbMEs9mcCEmrOBOSK+Jeml7zM=
80 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc=
81 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8=
82 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
83 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
84 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
85 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
86 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
87 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
88 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
89 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
90 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
91 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
92 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
93 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
94 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
95 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
96 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
97 | github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
98 | github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
99 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
100 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
101 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
102 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
103 | golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
104 | golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
105 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
106 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
107 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
108 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
109 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
110 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
111 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
112 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
113 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
114 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
115 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
118 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
119 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
120 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
121 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
122 |
--------------------------------------------------------------------------------