diff --git a/internal/config/config.go b/internal/config/config.go index 8471f61..5ad5671 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,9 @@ import ( ) type Config struct { + File string `toml:"-"` + callbacks []func() `toml:"-"` + Title string `toml:"title" comment:"Tray title."` URL string `toml:"url" comment:"Nightscout URL. (required)"` Token string `toml:"token" comment:"Nightscout token. Using an access token is recommended instead of the API secret."` @@ -31,10 +34,9 @@ type LocalFile struct { Enabled bool `toml:"enabled"` Format string `toml:"format" comment:"Local file format. (one of: csv)"` Path string `toml:"path" comment:"Local file path. $TMPDIR will be replaced with the current temp directory."` - Cleanup bool `toml:"cleanup" comment:"If enabled, the local file will be cleaned up when Nightscout Menu Bar is closed."` } -var configDir = "nightscout-menu-bar" +const configDir = "nightscout-menu-bar" func GetDir() (string, error) { switch runtime.GOOS { diff --git a/internal/config/default.go b/internal/config/default.go index 8e0f6de..78c021c 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -5,12 +5,10 @@ import ( "time" ) -var Default = NewDefault() - const LocalFileFormatCsv = "csv" -func NewDefault() Config { - return Config{ +func NewDefault() *Config { + return &Config{ Title: "Nightscout", Units: UnitsMgdl, Interval: Duration{30 * time.Second}, @@ -25,9 +23,8 @@ func NewDefault() Config { Unknown: "-", }, LocalFile: LocalFile{ - Format: LocalFileFormatCsv, - Path: filepath.Join("$TMPDIR", "nightscout.csv"), - Cleanup: true, + Format: LocalFileFormatCsv, + Path: filepath.Join("$TMPDIR", "nightscout.csv"), }, } } diff --git a/internal/config/load.go b/internal/config/load.go index 40efcf0..21c16f3 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -4,6 +4,7 @@ package config import ( "bytes" + "context" "errors" "log/slog" "os" @@ -18,33 +19,31 @@ import ( flag "github.com/spf13/pflag" ) -var cfgFile string - -func init() { - flag.StringVarP(&cfgFile, "config", "c", "", "Config file") +func (conf *Config) RegisterFlags(fs *flag.FlagSet) { + fs.StringVarP(&conf.File, "config", "c", "", "Config file") } -func Load() error { +func (conf *Config) Load() error { flag.Parse() k := koanf.New(".") - // Load default config - if err := k.Load(structs.Provider(Default, "toml"), nil); err != nil { + // Load conf config + if err := k.Load(structs.Provider(conf, "toml"), nil); err != nil { return err } // Find config file - if cfgFile == "" { + if conf.File == "" { cfgDir, err := GetDir() if err != nil { return err } - cfgFile = filepath.Join(cfgDir, "config.toml") + conf.File = filepath.Join(cfgDir, "config.toml") } // Load config file if exists - cfgContents, err := os.ReadFile(cfgFile) + cfgContents, err := os.ReadFile(conf.File) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } @@ -55,32 +54,32 @@ func Load() error { return err } - if err := k.UnmarshalWithConf("", &Default, koanf.UnmarshalConf{Tag: "toml"}); err != nil { + if err := k.UnmarshalWithConf("", &conf, koanf.UnmarshalConf{Tag: "toml"}); err != nil { return err } - if err := Write(); err != nil { + if err := conf.Write(); err != nil { return err } - slog.Info("Loaded config", "file", cfgFile) - return err + slog.Info("Loaded config", "file", conf.File) + return nil } -func Write() error { +func (conf *Config) Write() error { // Find config file - if cfgFile == "" { + if conf.File == "" { cfgDir, err := GetDir() if err != nil { return err } - cfgFile = filepath.Join(cfgDir, "config.toml") + conf.File = filepath.Join(cfgDir, "config.toml") } var cfgNotExists bool // Load config file if exists - cfgContents, err := os.ReadFile(cfgFile) + cfgContents, err := os.ReadFile(conf.File) if err != nil { if errors.Is(err, os.ErrNotExist) { cfgNotExists = true @@ -89,47 +88,56 @@ func Write() error { } } - newCfg, err := toml.Marshal(Default) + newCfg, err := toml.Marshal(conf) if err != nil { return err } if !bytes.Equal(cfgContents, newCfg) { if cfgNotExists { - slog.Info("Creating config", "file", cfgFile) + slog.Info("Creating config", "file", conf.File) - if err := os.MkdirAll(filepath.Dir(cfgFile), 0o777); err != nil { + if err := os.MkdirAll(filepath.Dir(conf.File), 0o777); err != nil { return err } } else { - slog.Info("Updating config", "file", cfgFile) + slog.Info("Updating config", "file", conf.File) } - if err := os.WriteFile(cfgFile, newCfg, 0o666); err != nil { + if err := os.WriteFile(conf.File, newCfg, 0o666); err != nil { return err } } - return err + return nil } -func Watch() error { - slog.Info("Watching config", "file", cfgFile) - f := file.Provider(cfgFile) - return f.Watch(func(event interface{}, err error) { +func (conf *Config) Watch(ctx context.Context) error { + slog.Info("Watching config", "file", conf.File) + f := file.Provider(conf.File) + return f.Watch(func(event any, err error) { if err != nil { slog.Error("Config watcher failed", "error", err.Error()) + if ctx.Err() != nil { + conf.callbacks = nil + return + } time.Sleep(time.Second) defer func() { - _ = Watch() + _ = conf.Watch(ctx) }() } - if err := Load(); err != nil { + if err := conf.Load(); err != nil { slog.Error("Failed to load config", "error", err.Error()) } - for _, reloader := range reloaders { - reloader() + + for _, fn := range conf.callbacks { + fn() } }) } + +func (conf *Config) AddCallback(fn func()) { + conf.callbacks = append(conf.callbacks, fn) +} diff --git a/internal/config/reload.go b/internal/config/reload.go deleted file mode 100644 index a85d632..0000000 --- a/internal/config/reload.go +++ /dev/null @@ -1,7 +0,0 @@ -package config - -var reloaders []func() - -func AddReloader(fn func()) { - reloaders = append(reloaders, fn) -} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go new file mode 100644 index 0000000..beefedc --- /dev/null +++ b/internal/fetch/fetch.go @@ -0,0 +1,134 @@ +package fetch + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "time" + + "github.com/gabe565/nightscout-menu-bar/internal/config" + "github.com/gabe565/nightscout-menu-bar/internal/nightscout" +) + +var ( + ErrHttp = errors.New("unexpected HTTP error") + ErrNotModified = errors.New("not modified") +) + +func NewFetch(conf *config.Config) *Fetch { + return &Fetch{ + config: conf, + client: &http.Client{ + Timeout: time.Minute, + }, + } +} + +type Fetch struct { + config *config.Config + client *http.Client + url *url.URL + tokenChecksum string + etag string +} + +func (f *Fetch) Do() (*nightscout.Properties, error) { + if f.url == nil { + if err := f.UpdateUrl(); err != nil { + return nil, err + } + } + + // Fetch JSON + req, err := http.NewRequest("GET", f.url.String(), nil) + if err != nil { + return nil, err + } + if f.etag != "" { + req.Header.Set("If-None-Match", f.etag) + } + + if f.tokenChecksum != "" { + req.Header.Set("Api-Secret", f.tokenChecksum) + } + + resp, err := f.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusNotModified: + return nil, ErrNotModified + case http.StatusOK: + // Decode JSON + var properties nightscout.Properties + if err := json.NewDecoder(resp.Body).Decode(&properties); err != nil { + return nil, err + } + + f.etag = resp.Header.Get("etag") + return &properties, nil + default: + f.etag = "" + return nil, fmt.Errorf("%w: %d", ErrHttp, resp.StatusCode) + } +} + +func (f *Fetch) UpdateUrl() error { + u, err := BuildUrl(f.config) + if err != nil { + return err + } + + u.Path = path.Join(u.Path, "api", "v2", "properties", "bgnow,buckets,delta,direction") + f.url = u + + if token := f.config.Token; token != "" { + rawChecksum := sha1.Sum([]byte(token)) + f.tokenChecksum = hex.EncodeToString(rawChecksum[:]) + } else { + f.tokenChecksum = "" + } + + return nil +} + +func (f *Fetch) Reset() { + f.url = nil + f.tokenChecksum = "" + f.etag = "" +} + +func BuildUrl(conf *config.Config) (*url.URL, error) { + if conf.URL == "" { + return nil, errors.New("please configure your Nightscout URL") + } + + return url.Parse(conf.URL) +} + +func BuildUrlWithToken(conf *config.Config) (*url.URL, error) { + u, err := BuildUrl(conf) + if err != nil { + return u, err + } + + if token := conf.Token; token != "" { + query := u.Query() + query.Set("token", conf.Token) + u.RawQuery = query.Encode() + } + + return u, nil +} diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go new file mode 100644 index 0000000..1c7dbbe --- /dev/null +++ b/internal/fetch/fetch_test.go @@ -0,0 +1,76 @@ +package fetch + +import ( + _ "embed" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gabe565/nightscout-menu-bar/internal/config" + "github.com/gabe565/nightscout-menu-bar/internal/nightscout" + "github.com/hhsnopek/etag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed fetch_test_properties.json +var propertiesJson []byte + +var ( + properties = &nightscout.Properties{Bgnow: nightscout.Reading{Mean: 123, Last: 123, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Index: 0, FromMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, ToMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, Sgvs: []nightscout.SGV{{ID: "633a49639fc610138697ba4d", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 123, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "123", Type: "sgv", Unfiltered: 0}}}, Buckets: []nightscout.Reading{{Mean: 123, Last: 123, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Index: 0, FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)}, ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 33, 28, 417000000, time.Local)}, Sgvs: []nightscout.SGV{{ID: "633a49639fc610138697ba4d", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 123, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "123", Type: "sgv", Unfiltered: 0}}}, {Mean: 122, Last: 122, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Index: 1, FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)}, ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)}, Sgvs: []nightscout.SGV{{ID: "633a48389fc610138697b95b", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 122, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "122", Type: "sgv", Unfiltered: 0}}}, {Mean: 119, Last: 119, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local)}, Index: 2, FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)}, ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)}, Sgvs: []nightscout.SGV{{ID: "633a470d9fc610138697b86a", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 119, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "119", Type: "sgv", Unfiltered: 0}}}, {Mean: 116, Last: 116, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local)}, Index: 3, FromMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 13, 28, 417000000, time.Local)}, ToMills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)}, Sgvs: []nightscout.SGV{{ID: "633a45e09fc610138697b779", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 116, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "116", Type: "sgv", Unfiltered: 0}}}}, Delta: nightscout.Delta{Absolute: 1, DisplayVal: "+1", ElapsedMins: 4.987633333333333, Interpolated: false, Mean5MinsAgo: 122, Mgdl: 1, Previous: nightscout.Reading{Mean: 122, Last: 122, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Index: 0, FromMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, ToMills: nightscout.Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, Sgvs: []nightscout.SGV{{ID: "633a48389fc610138697b95b", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 122, Mills: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "122", Type: "sgv", Unfiltered: 0}}}, Scaled: 1, Times: nightscout.Times{Previous: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Recent: nightscout.Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}}}, Direction: nightscout.Direction{Entity: "→", Label: "→", Value: "Flat"}} + propertiesEtag = `W/"20-8b9f9edb2e2b1a9f5a8ffbf92a1a1c42f170a654"` + differentEtag = `W/"7cb-pLFn++MnPyzFsPH7e6MXNVFr2KU"` +) + +func TestFetch_Do(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + etag := etag.Generate(propertiesJson, true) + + if reqEtag := r.Header.Get("If-None-Match"); reqEtag == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set("Etag", etag) + _, _ = w.Write(propertiesJson) + })) + defer server.Close() + + type fields struct { + config *config.Config + client *http.Client + url *url.URL + tokenChecksum string + etag string + } + tests := []struct { + name string + fields fields + want *nightscout.Properties + wantEtag string + wantErr require.ErrorAssertionFunc + }{ + {"no url", fields{config: &config.Config{}}, nil, "", require.Error}, + {"success", fields{config: &config.Config{URL: server.URL}}, properties, propertiesEtag, require.NoError}, + {"same etag", fields{config: &config.Config{URL: server.URL}, etag: propertiesEtag}, nil, propertiesEtag, require.Error}, + {"different etag", fields{config: &config.Config{URL: server.URL}, etag: differentEtag}, properties, propertiesEtag, require.NoError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := NewFetch(tt.fields.config) + if tt.fields.client != nil { + f.client = tt.fields.client + } + f.url = tt.fields.url + f.tokenChecksum = tt.fields.tokenChecksum + f.etag = tt.fields.etag + + got, err := f.Do() + tt.wantErr(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantEtag, f.etag) + }) + } +} diff --git a/internal/nightscout/fetch_test_properties.json b/internal/fetch/fetch_test_properties.json similarity index 100% rename from internal/nightscout/fetch_test_properties.json rename to internal/fetch/fetch_test_properties.json diff --git a/internal/localfile/localfile.go b/internal/localfile/localfile.go index 36efb55..a6b4ce0 100644 --- a/internal/localfile/localfile.go +++ b/internal/localfile/localfile.go @@ -12,12 +12,27 @@ import ( "github.com/gabe565/nightscout-menu-bar/internal/nightscout" ) -func Format(format string, last *nightscout.Properties) string { - switch format { +func New(conf *config.Config) *LocalFile { + l := &LocalFile{ + config: conf, + } + l.reloadConfig() + + conf.AddCallback(l.reloadConfig) + return l +} + +type LocalFile struct { + config *config.Config + path string +} + +func (l *LocalFile) Format(last *nightscout.Properties) string { + switch l.config.LocalFile.Format { case config.LocalFileFormatCsv: - return last.Bgnow.DisplayBg() + "," + - last.Bgnow.Arrow() + "," + - last.Delta.Display() + "," + + return last.Bgnow.DisplayBg(l.config.Units) + "," + + last.Bgnow.Arrow(l.config.Arrows) + "," + + last.Delta.Display(l.config.Units) + "," + strconv.Itoa(int(last.Bgnow.Mills.Time.Unix())) + "\n" default: @@ -25,45 +40,35 @@ func Format(format string, last *nightscout.Properties) string { } } -var path string - -func ReloadConfig() { - var newPath string - if config.Default.LocalFile.Enabled { - newPath = config.Default.LocalFile.Path - if strings.HasPrefix(newPath, "$TMPDIR") { - newPath = strings.TrimPrefix(newPath, "$TMPDIR") - newPath = filepath.Join(os.TempDir(), newPath) +func (l *LocalFile) reloadConfig() { + var path string + if l.config.LocalFile.Enabled { + path = l.config.LocalFile.Path + if strings.HasPrefix(path, "$TMPDIR") { + path = strings.TrimPrefix(path, "$TMPDIR") + path = filepath.Join(os.TempDir(), path) } } - if newPath != path { - if err := Cleanup(); err != nil { + if l.path != "" && path != l.path { + if err := l.Cleanup(); err != nil { slog.Error("Failed to cleanup local file", "error", err.Error()) } } - path = newPath -} - -func init() { - config.AddReloader(ReloadConfig) + l.path = path } -func Write(last *nightscout.Properties) error { - if path == "" { - ReloadConfig() - if path == "" { - return nil - } +func (l *LocalFile) Write(last *nightscout.Properties) error { + if l.path != "" { + segment := l.Format(last) + err := os.WriteFile(l.path, []byte(segment), 0o600) + return err } - - segment := Format(config.Default.LocalFile.Format, last) - err := os.WriteFile(path, []byte(segment), 0o600) - return err + return nil } -func Cleanup() error { - if config.Default.LocalFile.Cleanup && path != "" { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { +func (l *LocalFile) Cleanup() error { + if l.path != "" { + if err := os.Remove(l.path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } diff --git a/internal/nightscout/delta.go b/internal/nightscout/delta.go index 5d7170c..7028fcb 100644 --- a/internal/nightscout/delta.go +++ b/internal/nightscout/delta.go @@ -25,8 +25,8 @@ type Delta struct { Times Times `json:"times"` } -func (d Delta) Display() string { - if config.Default.Units == config.UnitsMmol { +func (d Delta) Display(units string) string { + if units == config.UnitsMmol { mmol := util.ToMmol(d.Scaled) mmol = math.Round(mmol*10) / 10 return fmt.Sprintf("%+.1f", mmol) diff --git a/internal/nightscout/delta_test.go b/internal/nightscout/delta_test.go index f663e00..10efbc6 100644 --- a/internal/nightscout/delta_test.go +++ b/internal/nightscout/delta_test.go @@ -8,10 +8,6 @@ import ( ) func TestDelta_Display(t *testing.T) { - defer func() { - config.Default.Units = config.UnitsMgdl - }() - type fields struct { Absolute int DisplayVal string @@ -47,12 +43,6 @@ func TestDelta_Display(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - switch tt.args.units { - case config.UnitsMgdl: - config.Default.Units = config.UnitsMgdl - case config.UnitsMmol: - config.Default.Units = config.UnitsMmol - } d := Delta{ Absolute: tt.fields.Absolute, DisplayVal: tt.fields.DisplayVal, @@ -64,7 +54,7 @@ func TestDelta_Display(t *testing.T) { Scaled: tt.fields.Scaled, Times: tt.fields.Times, } - assert.Equal(t, tt.want, d.Display()) + assert.Equal(t, tt.want, d.Display(tt.args.units)) }) } } diff --git a/internal/nightscout/fetch.go b/internal/nightscout/fetch.go deleted file mode 100644 index 290658f..0000000 --- a/internal/nightscout/fetch.go +++ /dev/null @@ -1,131 +0,0 @@ -package nightscout - -import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "path" - "time" - - "github.com/gabe565/nightscout-menu-bar/internal/config" -) - -var lastEtag string - -var ( - ErrHttp = errors.New("unexpected HTTP error") - ErrNotModified = errors.New("not modified") -) - -var client = &http.Client{ - Timeout: time.Minute, -} - -var ( - u *url.URL - tokenChecksum string -) - -func Fetch() (*Properties, error) { - if u == nil { - if err := UpdateUrl(); err != nil { - return nil, err - } - } - - // Fetch JSON - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, err - } - if lastEtag != "" { - req.Header.Set("If-None-Match", lastEtag) - } - - if tokenChecksum != "" { - req.Header.Set("Api-Secret", tokenChecksum) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - switch resp.StatusCode { - case http.StatusNotModified: - return nil, ErrNotModified - case http.StatusOK: - // Decode JSON - var properties Properties - if err := json.NewDecoder(resp.Body).Decode(&properties); err != nil { - return nil, err - } - - lastEtag = resp.Header.Get("etag") - return &properties, nil - default: - lastEtag = "" - return nil, fmt.Errorf("%w: %d", ErrHttp, resp.StatusCode) - } -} - -func BuildUrl() (*url.URL, error) { - conf := config.Default.URL - if conf == "" { - return nil, errors.New("please configure your Nightscout URL") - } - - newUrl, err := url.Parse(conf) - if err != nil { - return nil, err - } - - return newUrl, err -} - -func BuildUrlWithToken() (*url.URL, error) { - u, err := BuildUrl() - if err != nil { - return u, err - } - - if token := config.Default.Token; token != "" { - query := u.Query() - query.Set("token", config.Default.Token) - u.RawQuery = query.Encode() - } - - return u, nil -} - -func UpdateUrl() error { - newUrl, err := BuildUrl() - if err != nil { - return err - } - - newUrl.Path = path.Join(newUrl.Path, "api", "v2", "properties", "bgnow,buckets,delta,direction") - u = newUrl - - if token := config.Default.Token; token != "" { - rawChecksum := sha1.Sum([]byte(token)) - tokenChecksum = hex.EncodeToString(rawChecksum[:]) - } else { - tokenChecksum = "" - } - - return nil -} - -func ClearUrl() { - u = nil -} diff --git a/internal/nightscout/fetch_test.go b/internal/nightscout/fetch_test.go deleted file mode 100644 index 8f2ecf7..0000000 --- a/internal/nightscout/fetch_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package nightscout - -import ( - _ "embed" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gabe565/nightscout-menu-bar/internal/config" - "github.com/hhsnopek/etag" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -//go:embed fetch_test_properties.json -var propertiesJson []byte - -var ( - properties = &Properties{Bgnow: Reading{Mean: 123, Last: 123, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Index: 0, FromMills: Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, ToMills: Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, Sgvs: []SGV{{ID: "633a49639fc610138697ba4d", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 123, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "123", Type: "sgv", Unfiltered: 0}}}, Buckets: []Reading{{Mean: 123, Last: 123, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Index: 0, FromMills: Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)}, ToMills: Mills{Time: time.Date(2022, time.October, 2, 21, 33, 28, 417000000, time.Local)}, Sgvs: []SGV{{ID: "633a49639fc610138697ba4d", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 123, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "123", Type: "sgv", Unfiltered: 0}}}, {Mean: 122, Last: 122, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Index: 1, FromMills: Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)}, ToMills: Mills{Time: time.Date(2022, time.October, 2, 21, 28, 28, 417000000, time.Local)}, Sgvs: []SGV{{ID: "633a48389fc610138697b95b", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 122, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "122", Type: "sgv", Unfiltered: 0}}}, {Mean: 119, Last: 119, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local)}, Index: 2, FromMills: Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)}, ToMills: Mills{Time: time.Date(2022, time.October, 2, 21, 23, 28, 417000000, time.Local)}, Sgvs: []SGV{{ID: "633a470d9fc610138697b86a", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 119, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 20, 59, 528000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "119", Type: "sgv", Unfiltered: 0}}}, {Mean: 116, Last: 116, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local)}, Index: 3, FromMills: Mills{Time: time.Date(2022, time.October, 2, 21, 13, 28, 417000000, time.Local)}, ToMills: Mills{Time: time.Date(2022, time.October, 2, 21, 18, 28, 417000000, time.Local)}, Sgvs: []SGV{{ID: "633a45e09fc610138697b779", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 116, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 15, 59, 256000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "116", Type: "sgv", Unfiltered: 0}}}}, Delta: Delta{Absolute: 1, DisplayVal: "+1", ElapsedMins: 4.987633333333333, Interpolated: false, Mean5MinsAgo: 122, Mgdl: 1, Previous: Reading{Mean: 122, Last: 122, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Index: 0, FromMills: Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, ToMills: Mills{Time: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)}, Sgvs: []SGV{{ID: "633a48389fc610138697b95b", Device: "xDrip-DexcomG5", Direction: "Flat", Filtered: 0, Mgdl: 122, Mills: Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Noise: 1, Rssi: 100, Scaled: "122", Type: "sgv", Unfiltered: 0}}}, Scaled: 1, Times: Times{Previous: Mills{Time: time.Date(2022, time.October, 2, 21, 25, 59, 159000000, time.Local)}, Recent: Mills{Time: time.Date(2022, time.October, 2, 21, 30, 58, 417000000, time.Local)}}}, Direction: Direction{Entity: "→", Label: "→", Value: "Flat"}} - propertiesEtag = `W/"20-8b9f9edb2e2b1a9f5a8ffbf92a1a1c42f170a654"` - differentEtag = `W/"7cb-pLFn++MnPyzFsPH7e6MXNVFr2KU"` -) - -func TestFetch(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - etag := etag.Generate(propertiesJson, true) - - if reqEtag := r.Header.Get("If-None-Match"); reqEtag == etag { - w.WriteHeader(http.StatusNotModified) - return - } - - w.Header().Set("Etag", etag) - _, _ = w.Write(propertiesJson) - })) - defer server.Close() - - tests := []struct { - name string - url string - etag string - want *Properties - wantEtag string - wantErr require.ErrorAssertionFunc - }{ - {"no url", "", "", nil, "", require.Error}, - {"success", server.URL, "", properties, propertiesEtag, require.NoError}, - {"same etag", server.URL, propertiesEtag, nil, propertiesEtag, require.Error}, - {"different etag", server.URL, differentEtag, properties, propertiesEtag, require.NoError}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config.Default.URL = tt.url - lastEtag = tt.etag - - got, err := Fetch() - tt.wantErr(t, err) - assert.Equal(t, tt.want, got) - assert.Equal(t, tt.wantEtag, lastEtag) - }) - } -} diff --git a/internal/nightscout/properties.go b/internal/nightscout/properties.go index e35d930..0eda460 100644 --- a/internal/nightscout/properties.go +++ b/internal/nightscout/properties.go @@ -3,6 +3,7 @@ package nightscout import ( "fmt" + "github.com/gabe565/nightscout-menu-bar/internal/config" "github.com/gabe565/nightscout-menu-bar/internal/util" ) @@ -13,12 +14,12 @@ type Properties struct { Direction Direction `json:"direction"` } -func (p Properties) String() string { +func (p Properties) String(units string, arrows config.Arrows) string { return fmt.Sprintf( "%s %s %s [%s]", - p.Bgnow.DisplayBg(), - p.Bgnow.Arrow(), - p.Delta.Display(), + p.Bgnow.DisplayBg(units), + p.Bgnow.Arrow(arrows), + p.Delta.Display(units), util.MinAgo(p.Bgnow.Mills.Time), ) } diff --git a/internal/nightscout/properties_test.go b/internal/nightscout/properties_test.go index 617a6df..30b6c20 100644 --- a/internal/nightscout/properties_test.go +++ b/internal/nightscout/properties_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/gabe565/nightscout-menu-bar/internal/config" "github.com/stretchr/testify/assert" ) @@ -14,9 +15,14 @@ func TestProperties_String(t *testing.T) { Delta Delta Direction Direction } + type args struct { + units string + arrows config.Arrows + } tests := []struct { name string fields fields + args args want string }{ { @@ -29,6 +35,7 @@ func TestProperties_String(t *testing.T) { }, Delta: Delta{DisplayVal: "+1"}, }, + args{config.UnitsMgdl, config.NewDefault().Arrows}, "100 → +1 [0m]", }, } @@ -40,7 +47,7 @@ func TestProperties_String(t *testing.T) { Delta: tt.fields.Delta, Direction: tt.fields.Direction, } - assert.Equal(t, tt.want, p.String()) + assert.Equal(t, tt.want, p.String(tt.args.units, tt.args.arrows)) }) } } diff --git a/internal/nightscout/reading.go b/internal/nightscout/reading.go index af64f61..2b5a51b 100644 --- a/internal/nightscout/reading.go +++ b/internal/nightscout/reading.go @@ -25,37 +25,37 @@ type Reading struct { Sgvs []SGV `json:"sgvs"` } -func (r *Reading) Arrow() string { +func (r *Reading) Arrow(conf config.Arrows) string { var direction string if len(r.Sgvs) > 0 { direction = r.Sgvs[0].Direction } switch direction { case "DoubleUp", "TripleUp": - direction = config.Default.Arrows.DoubleUp + direction = conf.DoubleUp case "SingleUp": - direction = config.Default.Arrows.SingleUp + direction = conf.SingleUp case "FortyFiveUp": - direction = config.Default.Arrows.FortyFiveUp + direction = conf.FortyFiveUp case "Flat": - direction = config.Default.Arrows.Flat + direction = conf.Flat case "FortyFiveDown": - direction = config.Default.Arrows.FortyFiveDown + direction = conf.FortyFiveDown case "SingleDown": - direction = config.Default.Arrows.SingleDown + direction = conf.SingleDown case "DoubleDown", "TripleDown": - direction = config.Default.Arrows.DoubleDown + direction = conf.DoubleDown default: - direction = config.Default.Arrows.Unknown + direction = conf.Unknown } return direction } -func (r *Reading) String() string { +func (r *Reading) String(units string, arrows config.Arrows) string { return fmt.Sprintf( "%s %s [%s]", - r.DisplayBg(), - r.Arrow(), + r.DisplayBg(units), + r.Arrow(arrows), util.MinAgo(r.Mills.Time), ) } @@ -76,7 +76,7 @@ func (r *Reading) UnmarshalJSON(bytes []byte) error { return nil } -func (r *Reading) DisplayBg() string { +func (r *Reading) DisplayBg(units string) string { switch r.Last { case LowReading: return "LOW" @@ -84,7 +84,7 @@ func (r *Reading) DisplayBg() string { return "HIGH" } - if config.Default.Units == config.UnitsMmol { + if units == config.UnitsMmol { mmol := util.ToMmol(r.Last) mmol = math.Round(mmol*10) / 10 return strconv.FormatFloat(mmol, 'f', 1, 64) diff --git a/internal/nightscout/reading_test.go b/internal/nightscout/reading_test.go index 915c25b..73894ec 100644 --- a/internal/nightscout/reading_test.go +++ b/internal/nightscout/reading_test.go @@ -11,6 +11,8 @@ import ( ) func TestReading_Arrow(t *testing.T) { + defaultArrows := config.NewDefault().Arrows + type fields struct { Mean int Last int @@ -20,21 +22,25 @@ func TestReading_Arrow(t *testing.T) { ToMills Mills Sgvs []SGV } + type args struct { + arrows config.Arrows + } tests := []struct { name string fields fields + args args want string }{ - {"TripleUp", fields{Sgvs: []SGV{{Direction: "TripleUp"}}}, "⇈"}, - {"DoubleUp", fields{Sgvs: []SGV{{Direction: "DoubleUp"}}}, "⇈"}, - {"SingleUp", fields{Sgvs: []SGV{{Direction: "SingleUp"}}}, "↑"}, - {"FortyFiveUp", fields{Sgvs: []SGV{{Direction: "FortyFiveUp"}}}, "↗"}, - {"Flat", fields{Sgvs: []SGV{{Direction: "Flat"}}}, "→"}, - {"FortyFiveDown", fields{Sgvs: []SGV{{Direction: "FortyFiveDown"}}}, "↘"}, - {"SingleDown", fields{Sgvs: []SGV{{Direction: "SingleDown"}}}, "↓"}, - {"DoubleDown", fields{Sgvs: []SGV{{Direction: "DoubleDown"}}}, "⇊"}, - {"TripleDown", fields{Sgvs: []SGV{{Direction: "TripleDown"}}}, "⇊"}, - {"unknown", fields{}, "-"}, + {"TripleUp", fields{Sgvs: []SGV{{Direction: "TripleUp"}}}, args{defaultArrows}, "⇈"}, + {"DoubleUp", fields{Sgvs: []SGV{{Direction: "DoubleUp"}}}, args{defaultArrows}, "⇈"}, + {"SingleUp", fields{Sgvs: []SGV{{Direction: "SingleUp"}}}, args{defaultArrows}, "↑"}, + {"FortyFiveUp", fields{Sgvs: []SGV{{Direction: "FortyFiveUp"}}}, args{defaultArrows}, "↗"}, + {"Flat", fields{Sgvs: []SGV{{Direction: "Flat"}}}, args{defaultArrows}, "→"}, + {"FortyFiveDown", fields{Sgvs: []SGV{{Direction: "FortyFiveDown"}}}, args{defaultArrows}, "↘"}, + {"SingleDown", fields{Sgvs: []SGV{{Direction: "SingleDown"}}}, args{defaultArrows}, "↓"}, + {"DoubleDown", fields{Sgvs: []SGV{{Direction: "DoubleDown"}}}, args{defaultArrows}, "⇊"}, + {"TripleDown", fields{Sgvs: []SGV{{Direction: "TripleDown"}}}, args{defaultArrows}, "⇊"}, + {"unknown", fields{}, args{defaultArrows}, "-"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -47,7 +53,7 @@ func TestReading_Arrow(t *testing.T) { ToMills: tt.fields.ToMills, Sgvs: tt.fields.Sgvs, } - assert.Equal(t, tt.want, r.Arrow()) + assert.Equal(t, tt.want, r.Arrow(tt.args.arrows)) }) } } @@ -62,9 +68,14 @@ func TestReading_String(t *testing.T) { ToMills Mills Sgvs []SGV } + type args struct { + units string + arrows config.Arrows + } tests := []struct { name string fields fields + args args want string }{ { @@ -74,6 +85,7 @@ func TestReading_String(t *testing.T) { Mills: Mills{time.Now()}, Sgvs: []SGV{{Direction: "Flat"}}, }, + args{config.UnitsMgdl, config.NewDefault().Arrows}, "100 → [0m]", }, } @@ -88,16 +100,12 @@ func TestReading_String(t *testing.T) { ToMills: tt.fields.ToMills, Sgvs: tt.fields.Sgvs, } - assert.Equal(t, tt.want, r.String()) + assert.Equal(t, tt.want, r.String(tt.args.units, tt.args.arrows)) }) } } func TestReading_DisplayBg(t *testing.T) { - defer func() { - config.Default.Units = config.UnitsMgdl - }() - type fields struct { Mean int Last int @@ -122,12 +130,6 @@ func TestReading_DisplayBg(t *testing.T) { {"mmol", args{config.UnitsMmol}, fields{Last: 100}, "5.6"}, } for _, tt := range tests { - switch tt.args.units { - case config.UnitsMgdl: - config.Default.Units = config.UnitsMgdl - case config.UnitsMmol: - config.Default.Units = config.UnitsMmol - } t.Run(tt.name, func(t *testing.T) { r := &Reading{ Mean: tt.fields.Mean, @@ -138,7 +140,7 @@ func TestReading_DisplayBg(t *testing.T) { ToMills: tt.fields.ToMills, Sgvs: tt.fields.Sgvs, } - assert.Equal(t, tt.want, r.DisplayBg()) + assert.Equal(t, tt.want, r.DisplayBg(tt.args.units)) }) } } diff --git a/internal/ticker/config.go b/internal/ticker/config.go deleted file mode 100644 index 3e44538..0000000 --- a/internal/ticker/config.go +++ /dev/null @@ -1,22 +0,0 @@ -package ticker - -import ( - "github.com/gabe565/nightscout-menu-bar/internal/config" - "github.com/gabe565/nightscout-menu-bar/internal/nightscout" -) - -func init() { - config.AddReloader(reloadConfig) -} - -func reloadConfig() { - if renderTimer != nil { - renderTimer.Reset(0) - } - nightscout.ClearUrl() - Fetch() - - if fetchTimer != nil { - fetchTimer.Reset(config.Default.Interval.Duration) - } -} diff --git a/internal/ticker/fetch.go b/internal/ticker/fetch.go index 90c37e8..38d06f4 100644 --- a/internal/ticker/fetch.go +++ b/internal/ticker/fetch.go @@ -5,33 +5,39 @@ import ( "log/slog" "time" - "github.com/gabe565/nightscout-menu-bar/internal/config" - "github.com/gabe565/nightscout-menu-bar/internal/localfile" + "github.com/gabe565/nightscout-menu-bar/internal/fetch" "github.com/gabe565/nightscout-menu-bar/internal/nightscout" - "github.com/gabe565/nightscout-menu-bar/internal/tray" ) -var fetchTimer = time.NewTimer(0) - -func BeginFetch() { +func (t *Ticker) beginFetch(render chan<- *nightscout.Properties) { go func() { - for range fetchTimer.C { - Fetch() - fetchTimer.Reset(config.Default.Interval.Duration) + t.Fetch(render) + t.fetchTicker = time.NewTicker(t.config.Interval.Duration) + + for { + select { + case <-t.ctx.Done(): + return + case <-t.fetchTicker.C: + t.Fetch(render) + t.fetchTicker.Reset(t.config.Interval.Duration) + } } }() } -func Fetch() { - properties, err := nightscout.Fetch() - if err != nil && !errors.Is(err, nightscout.ErrNotModified) { - tray.Error <- err +func (t *Ticker) Fetch(render chan<- *nightscout.Properties) { + properties, err := t.fetch.Do() + if err != nil && !errors.Is(err, fetch.ErrNotModified) { + t.bus <- err return } if properties != nil { - RenderCh <- properties - if config.Default.LocalFile.Enabled { - if err := localfile.Write(properties); err != nil { + if render != nil { + render <- properties + } + if t.config.LocalFile.Enabled { + if err := t.localFile.Write(properties); err != nil { slog.Error("Failed to write local file", "error", err.Error()) } } diff --git a/internal/ticker/render.go b/internal/ticker/render.go index d9cad47..e472227 100644 --- a/internal/ticker/render.go +++ b/internal/ticker/render.go @@ -4,30 +4,30 @@ import ( "time" "github.com/gabe565/nightscout-menu-bar/internal/nightscout" - "github.com/gabe565/nightscout-menu-bar/internal/tray" "github.com/gabe565/nightscout-menu-bar/internal/util" ) -var ( - renderTimer = time.NewTimer(5 * time.Minute) - RenderCh = make(chan *nightscout.Properties) -) - -func BeginRender() { +func (t *Ticker) beginRender() chan<- *nightscout.Properties { + renderCh := make(chan *nightscout.Properties) go func() { + defer close(renderCh) + t.renderTicker = time.NewTicker(5 * time.Minute) var properties *nightscout.Properties for { select { - case p := <-RenderCh: + case <-t.ctx.Done(): + return + case p := <-renderCh: properties = p - case <-renderTimer.C: + case <-t.renderTicker.C: } if properties != nil { - tray.Update <- properties - renderTimer.Reset(util.GetNextMinChange(properties.Bgnow.Mills.Time)) + t.bus <- properties + t.renderTicker.Reset(util.GetNextMinChange(properties.Bgnow.Mills.Time)) } else { - renderTimer.Reset(5 * time.Minute) + t.renderTicker.Reset(5 * time.Minute) } } }() + return renderCh } diff --git a/internal/ticker/ticker.go b/internal/ticker/ticker.go new file mode 100644 index 0000000..54bead4 --- /dev/null +++ b/internal/ticker/ticker.go @@ -0,0 +1,63 @@ +package ticker + +import ( + "context" + "log/slog" + "time" + + "github.com/gabe565/nightscout-menu-bar/internal/config" + "github.com/gabe565/nightscout-menu-bar/internal/fetch" + "github.com/gabe565/nightscout-menu-bar/internal/localfile" +) + +func New(conf *config.Config, updateCh chan<- any) *Ticker { + t := &Ticker{ + config: conf, + fetch: fetch.NewFetch(conf), + localFile: localfile.New(conf), + bus: updateCh, + } + + conf.AddCallback(t.reloadConfig) + return t +} + +type Ticker struct { + ctx context.Context + cancel context.CancelFunc + + config *config.Config + fetch *fetch.Fetch + localFile *localfile.LocalFile + + fetchTicker *time.Ticker + renderTicker *time.Ticker + bus chan<- any +} + +func (t *Ticker) Start() { + t.ctx, t.cancel = context.WithCancel(context.Background()) + renderCh := t.beginRender() + t.beginFetch(renderCh) +} + +func (t *Ticker) reloadConfig() { + if t.renderTicker != nil { + t.renderTicker.Reset(time.Millisecond) + } + t.fetch.Reset() + if t.fetchTicker != nil { + t.fetchTicker.Reset(time.Millisecond) + } +} + +func (t *Ticker) Close() { + t.fetchTicker.Stop() + t.renderTicker.Stop() + if t.cancel != nil { + t.cancel() + } + if err := t.localFile.Cleanup(); err != nil { + slog.Error("Failed to cleanup local file", "error", err.Error()) + } +} diff --git a/internal/tray/items/preferences.go b/internal/tray/items/preferences.go index 3ac2bb1..c4d5322 100644 --- a/internal/tray/items/preferences.go +++ b/internal/tray/items/preferences.go @@ -4,16 +4,17 @@ import ( "fyne.io/systray" "github.com/gabe565/nightscout-menu-bar/internal/assets" "github.com/gabe565/nightscout-menu-bar/internal/autostart" + "github.com/gabe565/nightscout-menu-bar/internal/config" "github.com/gabe565/nightscout-menu-bar/internal/tray/items/preferences" ) -func NewPreferences() Preferences { +func NewPreferences(conf *config.Config) Preferences { item := systray.AddMenuItem("Preferences", "") item.SetTemplateIcon(assets.Gear, assets.Gear) - url := preferences.NewUrl(item) - token := preferences.NewToken(item) - units := preferences.NewUnits(item) + url := preferences.NewUrl(conf, item) + token := preferences.NewToken(conf, item) + units := preferences.NewUnits(conf, item) autostartEnabled, _ := autostart.IsEnabled() startOnLogin := item.AddSubMenuItemCheckbox( @@ -22,7 +23,7 @@ func NewPreferences() Preferences { autostartEnabled, ) - localFile := preferences.NewLocalFile(item) + localFile := preferences.NewLocalFile(conf, item) return Preferences{ Item: item, diff --git a/internal/tray/items/preferences/local_file.go b/internal/tray/items/preferences/local_file.go index 55418ed..2258745 100644 --- a/internal/tray/items/preferences/local_file.go +++ b/internal/tray/items/preferences/local_file.go @@ -5,17 +5,18 @@ import ( "github.com/gabe565/nightscout-menu-bar/internal/config" ) -func NewLocalFile(parent *systray.MenuItem) LocalFile { - var item LocalFile +func NewLocalFile(conf *config.Config, parent *systray.MenuItem) LocalFile { + item := LocalFile{config: conf} item.MenuItem = parent.AddSubMenuItemCheckbox( "Write to local file", "", - config.Default.LocalFile.Enabled, + conf.LocalFile.Enabled, ) return item } type LocalFile struct { + config *config.Config *systray.MenuItem } @@ -26,8 +27,8 @@ func (l LocalFile) Toggle() error { l.Check() } - config.Default.LocalFile.Enabled = l.Checked() - if err := config.Write(); err != nil { + l.config.LocalFile.Enabled = l.Checked() + if err := l.config.Write(); err != nil { return err } return nil diff --git a/internal/tray/items/preferences/token.go b/internal/tray/items/preferences/token.go index 3976761..845e27a 100644 --- a/internal/tray/items/preferences/token.go +++ b/internal/tray/items/preferences/token.go @@ -9,20 +9,21 @@ import ( "github.com/ncruces/zenity" ) -func NewToken(parent *systray.MenuItem) Token { - var token Token +func NewToken(config *config.Config, parent *systray.MenuItem) Token { + token := Token{config: config} token.MenuItem = parent.AddSubMenuItem(token.GetTitle(), "") return token } type Token struct { + config *config.Config *systray.MenuItem } func (n Token) GetTitle() string { title := "API Token" - if config.Default.Token != "" { - title += ": " + config.Default.Token + if n.config.Token != "" { + title += ": " + n.config.Token } return title } @@ -32,7 +33,7 @@ func (n Token) UpdateTitle() { } func (n Token) Prompt() error { - token, err := ui.PromptToken() + token, err := ui.PromptToken(n.config.Token) if err != nil { if errors.Is(err, zenity.ErrCanceled) { return nil @@ -40,8 +41,8 @@ func (n Token) Prompt() error { return err } - config.Default.Token = token - if err := config.Write(); err != nil { + n.config.Token = token + if err := n.config.Write(); err != nil { return err } return nil diff --git a/internal/tray/items/preferences/units.go b/internal/tray/items/preferences/units.go index 52f8a24..303cb0c 100644 --- a/internal/tray/items/preferences/units.go +++ b/internal/tray/items/preferences/units.go @@ -9,18 +9,19 @@ import ( "github.com/ncruces/zenity" ) -func NewUnits(parent *systray.MenuItem) Units { - var item Units +func NewUnits(config *config.Config, parent *systray.MenuItem) Units { + item := Units{config: config} item.MenuItem = parent.AddSubMenuItem(item.GetTitle(), "") return item } type Units struct { + config *config.Config *systray.MenuItem } func (n Units) GetTitle() string { - return "Units: " + config.Default.Units + return "Units: " + n.config.Units } func (n Units) UpdateTitle() { @@ -28,7 +29,7 @@ func (n Units) UpdateTitle() { } func (n Units) Prompt() error { - unit, err := ui.PromptUnits() + unit, err := ui.PromptUnits(n.config.Units) if err != nil { if errors.Is(err, zenity.ErrCanceled) { return nil @@ -36,8 +37,8 @@ func (n Units) Prompt() error { return err } - config.Default.Units = unit - if err := config.Write(); err != nil { + n.config.Units = unit + if err := n.config.Write(); err != nil { return err } return nil diff --git a/internal/tray/items/preferences/url.go b/internal/tray/items/preferences/url.go index 536a027..ebb318b 100644 --- a/internal/tray/items/preferences/url.go +++ b/internal/tray/items/preferences/url.go @@ -9,20 +9,21 @@ import ( "github.com/ncruces/zenity" ) -func NewUrl(parent *systray.MenuItem) Url { - var item Url +func NewUrl(conf *config.Config, parent *systray.MenuItem) Url { + item := Url{config: conf} item.MenuItem = parent.AddSubMenuItem(item.GetTitle(), "") return item } type Url struct { + config *config.Config *systray.MenuItem } func (n Url) GetTitle() string { title := "Nightscout URL" - if config.Default.URL != "" { - title += ": " + config.Default.URL + if n.config.URL != "" { + title += ": " + n.config.URL } return title } @@ -32,7 +33,7 @@ func (n Url) UpdateTitle() { } func (n Url) Prompt() error { - url, err := ui.PromptURL() + url, err := ui.PromptURL(n.config.URL) if err != nil { if errors.Is(err, zenity.ErrCanceled) { return nil @@ -40,8 +41,8 @@ func (n Url) Prompt() error { return err } - config.Default.URL = url - if err := config.Write(); err != nil { + n.config.URL = url + if err := n.config.Write(); err != nil { return err } return nil diff --git a/internal/tray/messages.go b/internal/tray/messages.go new file mode 100644 index 0000000..21d5c78 --- /dev/null +++ b/internal/tray/messages.go @@ -0,0 +1,3 @@ +package tray + +type ReloadConfigMsg struct{} diff --git a/internal/tray/systray.go b/internal/tray/systray.go index 48d197c..6329674 100644 --- a/internal/tray/systray.go +++ b/internal/tray/systray.go @@ -1,128 +1,163 @@ package tray import ( + "context" "log/slog" "fyne.io/systray" "github.com/gabe565/nightscout-menu-bar/internal/assets" "github.com/gabe565/nightscout-menu-bar/internal/autostart" "github.com/gabe565/nightscout-menu-bar/internal/config" - "github.com/gabe565/nightscout-menu-bar/internal/localfile" + "github.com/gabe565/nightscout-menu-bar/internal/fetch" "github.com/gabe565/nightscout-menu-bar/internal/nightscout" + "github.com/gabe565/nightscout-menu-bar/internal/ticker" "github.com/gabe565/nightscout-menu-bar/internal/tray/items" "github.com/skratchdot/open-golang/open" ) -func Run() { - systray.Run(onReady, onExit) -} +func New() *Tray { + t := &Tray{ + config: config.NewDefault(), + bus: make(chan any, 1), + } + + if err := t.config.Load(); err != nil { + t.onError(err) + } + + t.ticker = ticker.New(t.config, t.bus) -func init() { - config.AddReloader(func() { - reloadConfig <- struct{}{} + t.config.AddCallback(func() { + t.bus <- ReloadConfigMsg{} }) + return t } -var ( - Update = make(chan *nightscout.Properties) - reloadConfig = make(chan struct{}) - Error = make(chan error, 1) -) +type Tray struct { + config *config.Config + ticker *ticker.Ticker + bus chan any +} + +func (t *Tray) Run(ctx context.Context) { + t.ticker.Start() + if err := t.config.Watch(ctx); err != nil { + t.onError(err) + } + go func() { + <-ctx.Done() + t.Quit() + }() + systray.Run(t.onReady, t.onExit) +} + +func (t *Tray) Quit() { + systray.Quit() +} -func onReady() { +func (t *Tray) onReady() { systray.SetTemplateIcon(assets.Nightscout, assets.Nightscout) - systray.SetTitle(config.Default.Title) - systray.SetTooltip(config.Default.Title) + systray.SetTitle(t.config.Title) + systray.SetTooltip(t.config.Title) lastReadingItem := items.NewLastReading() errorItem := items.NewError() systray.AddSeparator() - openNightscoutItem := items.NewOpenNightscout(config.Default.Title) + openNightscoutItem := items.NewOpenNightscout(t.config.Title) historyItem, historyVals := items.NewHistory() systray.AddSeparator() - prefs := items.NewPreferences() + prefs := items.NewPreferences(t.config) quitItem := items.NewQuit() for { select { case <-openNightscoutItem.ClickedCh: - go func() { - u, err := nightscout.BuildUrlWithToken() - if err != nil { - Error <- err - return - } - if err := open.Run(u.String()); err != nil { - Error <- err - } - }() + u, err := fetch.BuildUrlWithToken(t.config) + if err != nil { + t.onError(err) + return + } + if err := open.Run(u.String()); err != nil { + t.onError(err) + } case <-prefs.Url.ClickedCh: go func() { if err := prefs.Url.Prompt(); err != nil { - Error <- err + t.onError(err) } }() case <-prefs.Token.ClickedCh: go func() { if err := prefs.Token.Prompt(); err != nil { - Error <- err + t.onError(err) } }() case <-prefs.Units.ClickedCh: go func() { if err := prefs.Units.Prompt(); err != nil { - Error <- err + t.onError(err) } }() - case <-reloadConfig: - prefs.Url.UpdateTitle() - prefs.Token.UpdateTitle() - prefs.Units.UpdateTitle() case <-prefs.StartOnLogin.ClickedCh: if prefs.StartOnLogin.Checked() { if err := autostart.Disable(); err != nil { - Error <- err + t.onError(err) + continue } prefs.StartOnLogin.Uncheck() } else { if err := autostart.Enable(); err != nil { - Error <- err + t.onError(err) + continue } prefs.StartOnLogin.Check() } case <-prefs.LocalFile.ClickedCh: if err := prefs.LocalFile.Toggle(); err != nil { - Error <- err + t.onError(err) } case <-quitItem.ClickedCh: - systray.Quit() - case properties := <-Update: - errorItem.Hide() - - value := properties.String() - systray.SetTitle(value) - systray.SetTooltip(value) - lastReadingItem.SetTitle(value) - - for i, reading := range properties.Buckets { - if i < len(historyVals) { - historyVals[i].SetTitle(reading.String()) - } else { - entry := historyItem.AddSubMenuItem(reading.String(), "") - entry.Disable() - historyVals = append(historyVals, entry) + t.Quit() + case msg := <-t.bus: + switch msg := msg.(type) { + case *nightscout.Properties: + errorItem.Hide() + + value := msg.String(t.config.Units, t.config.Arrows) + systray.SetTitle(value) + systray.SetTooltip(value) + lastReadingItem.SetTitle(value) + + for i, reading := range msg.Buckets { + if i < len(historyVals) { + historyVals[i].SetTitle(reading.String(t.config.Units, t.config.Arrows)) + } else { + entry := historyItem.AddSubMenuItem(reading.String(t.config.Units, t.config.Arrows), "") + entry.Disable() + historyVals = append(historyVals, entry) + } } + case error: + errorItem.SetTitle(msg.Error()) + errorItem.Show() + case ReloadConfigMsg: + prefs.Url.UpdateTitle() + prefs.Token.UpdateTitle() + prefs.Units.UpdateTitle() } - case err := <-Error: - errorItem.SetTitle(err.Error()) - errorItem.Show() } } } -func onExit() { - slog.Info("Exiting") - if err := localfile.Cleanup(); err != nil { - slog.Error("Failed to cleanup local file", "error", err.Error()) +func (t *Tray) onError(err error) { + select { + case t.bus <- err: + default: } } + +func (t *Tray) onExit() { + slog.Info("Exiting") + t.ticker.Close() + close(t.bus) +} diff --git a/internal/ui/token.go b/internal/ui/token.go index d758337..d824833 100644 --- a/internal/ui/token.go +++ b/internal/ui/token.go @@ -1,14 +1,13 @@ package ui import ( - "github.com/gabe565/nightscout-menu-bar/internal/config" "github.com/ncruces/zenity" ) -func PromptToken() (string, error) { +func PromptToken(text string) (string, error) { return zenity.Entry( "Enter new Nightscout API token:", zenity.Title("Token"), - zenity.EntryText(config.Default.Token), + zenity.EntryText(text), ) } diff --git a/internal/ui/units.go b/internal/ui/units.go index c5c242a..d97855e 100644 --- a/internal/ui/units.go +++ b/internal/ui/units.go @@ -5,12 +5,12 @@ import ( "github.com/ncruces/zenity" ) -func PromptUnits() (string, error) { +func PromptUnits(item string) (string, error) { return zenity.List( "Select units:", []string{config.UnitsMgdl, config.UnitsMmol}, zenity.Title("Nightscout Units"), zenity.DisallowEmpty(), - zenity.DefaultItems(config.Default.Units), + zenity.DefaultItems(item), ) } diff --git a/internal/ui/url.go b/internal/ui/url.go index 6a51f0b..79b5f0f 100644 --- a/internal/ui/url.go +++ b/internal/ui/url.go @@ -1,14 +1,13 @@ package ui import ( - "github.com/gabe565/nightscout-menu-bar/internal/config" "github.com/ncruces/zenity" ) -func PromptURL() (string, error) { +func PromptURL(text string) (string, error) { return zenity.Entry( "Enter new Nightscout URL:", zenity.Title("Nightscout URL"), - zenity.EntryText(config.Default.URL), + zenity.EntryText(text), ) } diff --git a/main.go b/main.go index 9376880..b39621d 100644 --- a/main.go +++ b/main.go @@ -6,35 +6,14 @@ import ( "os/signal" "syscall" - "fyne.io/systray" - "github.com/gabe565/nightscout-menu-bar/internal/config" - "github.com/gabe565/nightscout-menu-bar/internal/localfile" - "github.com/gabe565/nightscout-menu-bar/internal/ticker" "github.com/gabe565/nightscout-menu-bar/internal/tray" ) func main() { - if err := config.Load(); err != nil { - go func() { - tray.Error <- err - }() - } - if err := config.Watch(); err != nil { - go func() { - tray.Error <- err - }() - } - - localfile.ReloadConfig() - - ticker.BeginRender() - ticker.BeginFetch() + t := tray.New() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) defer cancel() - go func() { - <-ctx.Done() - systray.Quit() - }() - tray.Run() + + t.Run(ctx) }