├── .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 | ![Demo](./screenshot.webp?raw=true) 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 | [![Build](https://github.com/gabe565/nightscout-menu-bar/actions/workflows/build.yml/badge.svg)](https://github.com/gabe565/nightscout-menu-bar/actions/workflows/build.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/gabe565/nightscout-menu-bar)](https://goreportcard.com/report/github.com/gabe565/nightscout-menu-bar) 5 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=gabe565_nightscout-menu-bar&metric=alert_status)](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 | Nightscout Menu Bar Screenshot 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 | 2 | 3 | 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 | --------------------------------------------------------------------------------