From 6b5645a563cc6dbff552f46b4de8825640fa67a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Wed, 1 Apr 2026 18:40:34 +0300 Subject: [PATCH 1/3] refactor/unify settings to support persistnce --- settings.go | 142 +++++++++++++++++++++++++++++++++++++++++++++++ settings_test.go | 118 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 settings.go create mode 100644 settings_test.go diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..01323c6 --- /dev/null +++ b/settings.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sync" + + "zee/log" +) + +type Settings struct { + Language string `json:"language"` + Device string `json:"device"` + Provider string `json:"provider"` + Model string `json:"model"` + AutoPaste bool `json:"auto_paste"` + AutoStart bool `json:"auto_start"` +} + +const settingsFile = "config.json" + +var ( + settingsMu sync.Mutex + current Settings + cfgDir string +) + +var settingsDefaults = Settings{ + Language: "en", + AutoPaste: true, +} + +func settingsDir() string { + if cfgDir != "" { + return cfgDir + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "zee") + case "windows": + if v := os.Getenv("LOCALAPPDATA"); v != "" { + return filepath.Join(v, "zee") + } + return filepath.Join(home, "AppData", "Local", "zee") + default: + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg == "" { + xdg = filepath.Join(home, ".config") + } + return filepath.Join(xdg, "zee") + } +} + +func settingsPath() string { + return filepath.Join(settingsDir(), settingsFile) +} + +func loadSettings() error { + cfgDir = settingsDir() + current = settingsDefaults + + data, err := os.ReadFile(settingsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + log.Warnf("settings: corrupt config.json, using defaults: %v", err) + return nil + } + + current = s + if current.Language == "" { + current.Language = settingsDefaults.Language + } + return nil +} + +func getSettings() Settings { + settingsMu.Lock() + s := current + settingsMu.Unlock() + return s +} + +func updateSettings(fn func(*Settings)) { + settingsMu.Lock() + fn(¤t) + s := current + settingsMu.Unlock() + + saveSettings(s) +} + +func saveSettings(s Settings) { + dir := cfgDir + if err := os.MkdirAll(dir, 0755); err != nil { + log.Warnf("settings: create dir: %v", err) + return + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + log.Warnf("settings: marshal: %v", err) + return + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(dir, ".config-*.json") + if err != nil { + log.Warnf("settings: create temp: %v", err) + return + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + log.Warnf("settings: write temp: %v", err) + return + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + log.Warnf("settings: close temp: %v", err) + return + } + + if err := os.Rename(tmpPath, settingsPath()); err != nil { + os.Remove(tmpPath) + log.Warnf("settings: rename: %v", err) + } +} diff --git a/settings_test.go b/settings_test.go new file mode 100644 index 0000000..8536a45 --- /dev/null +++ b/settings_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestSettingsDefaults(t *testing.T) { + cfgDir = t.TempDir() + current = Settings{} + + if err := loadSettings(); err != nil { + t.Fatalf("loadSettings: %v", err) + } + s := getSettings() + if s.Language != "en" { + t.Errorf("Language = %q, want %q", s.Language, "en") + } + if !s.AutoPaste { + t.Error("AutoPaste = false, want true") + } + if s.Provider != "" || s.Model != "" || s.Device != "" { + t.Errorf("expected zero-value strings, got Provider=%q Model=%q Device=%q", s.Provider, s.Model, s.Device) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + cfgDir = t.TempDir() + current = Settings{} + + if err := loadSettings(); err != nil { + t.Fatalf("loadSettings: %v", err) + } + + updateSettings(func(s *Settings) { + s.Language = "fr" + s.Device = "Blue Yeti" + s.Provider = "groq" + s.Model = "whisper-large-v3-turbo" + s.AutoPaste = false + s.AutoStart = true + }) + + // Re-load from disk + current = Settings{} + if err := loadSettings(); err != nil { + t.Fatalf("loadSettings after update: %v", err) + } + + s := getSettings() + if s.Language != "fr" { + t.Errorf("Language = %q, want %q", s.Language, "fr") + } + if s.Device != "Blue Yeti" { + t.Errorf("Device = %q, want %q", s.Device, "Blue Yeti") + } + if s.Provider != "groq" { + t.Errorf("Provider = %q, want %q", s.Provider, "groq") + } + if s.Model != "whisper-large-v3-turbo" { + t.Errorf("Model = %q, want %q", s.Model, "whisper-large-v3-turbo") + } + if s.AutoPaste { + t.Error("AutoPaste = true, want false") + } + if !s.AutoStart { + t.Error("AutoStart = false, want true") + } +} + +func TestSettingsCopySafety(t *testing.T) { + cfgDir = t.TempDir() + current = settingsDefaults + + s := getSettings() + s.Language = "xx" + + s2 := getSettings() + if s2.Language == "xx" { + t.Error("mutating returned Settings affected internal state") + } +} + +func TestSettingsCorruptFile(t *testing.T) { + cfgDir = t.TempDir() + current = Settings{} + + os.WriteFile(filepath.Join(cfgDir, settingsFile), []byte("not json{{{"), 0644) + + if err := loadSettings(); err != nil { + t.Fatalf("loadSettings should not error on corrupt file: %v", err) + } + s := getSettings() + if s.Language != "en" { + t.Errorf("Language = %q, want default %q after corrupt file", s.Language, "en") + } +} + +func TestSettingsConcurrent(t *testing.T) { + cfgDir = t.TempDir() + current = settingsDefaults + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { + defer wg.Done() + updateSettings(func(s *Settings) { s.Language = "es" }) + }() + go func() { + defer wg.Done() + _ = getSettings() + }() + } + wg.Wait() +} From 37b3e8c4867d724f408df3f559c3680f22017263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Wed, 1 Apr 2026 18:48:07 +0300 Subject: [PATCH 2/3] fix: typo --- main.go | 124 +++++++++++++++++++++--------------- transcriber/transcriber.go | 42 +++++++------ tray/tray.go | 24 ++++++- tray/tray_darwin.go | 33 +++++----- update/apply.go | 125 ------------------------------------- update/check.go | 55 ++++++---------- update/update.go | 14 ++--- update/update_test.go | 10 +-- 8 files changed, 164 insertions(+), 263 deletions(-) delete mode 100644 update/apply.go diff --git a/main.go b/main.go index f292c71..a030729 100644 --- a/main.go +++ b/main.go @@ -144,20 +144,9 @@ func run() { fmt.Println("Already up to date.") os.Exit(0) } - fmt.Printf("Update available: %s -> %s\n", version, rel.Version) - fmt.Print("Continue? [y/N] ") - var answer string - fmt.Scanln(&answer) - if answer != "y" && answer != "Y" { - fmt.Println("Aborted.") - os.Exit(0) - } - fmt.Printf("Downloading %s...\n", rel.Version) - if err := update.Apply(rel); err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - fmt.Printf("Updated to %s\n", rel.Version) + fmt.Printf("\nUpdate available: %s β†’ %s\n\n", version, rel.Version) + fmt.Println("Homebrew: brew upgrade sumerc/tap/zee") + fmt.Printf("Download: %s\n", rel.URL) os.Exit(0) } @@ -223,7 +212,24 @@ func run() { } os.Exit(doctor.Run(wavFile)) } - autoPaste = *autoPasteFlag + // Load persistent settings, merge with CLI flags + if err := loadSettings(); err != nil { + log.Warnf("settings: %v", err) + } + cfg := getSettings() + flagSet := map[string]bool{} + flag.Visit(func(f *flag.Flag) { flagSet[f.Name] = true }) + if !flagSet["lang"] && cfg.Language != "" { + *langFlag = cfg.Language + } + if !flagSet["device"] && cfg.Device != "" { + *deviceFlag = cfg.Device + } + if !flagSet["autopaste"] { + autoPaste = cfg.AutoPaste + } else { + autoPaste = *autoPasteFlag + } streamEnabled = *streamFlag // Validate format @@ -238,10 +244,26 @@ func run() { log.Warn("format ignored in streaming mode") } - var initErr error - activeTranscriber, initErr = transcriber.New() - if initErr != nil { - fatal("No API key set.\n\nSet GROQ_API_KEY, OPENAI_API_KEY, or DEEPGRAM_API_KEY.") + // Restore saved provider/model or fall back to auto-detection + if cfg.Provider != "" { + for _, p := range transcriber.Providers() { + if p.Name == cfg.Provider { + if key := os.Getenv(p.EnvKey); key != "" { + activeTranscriber = p.NewFn(key) + if cfg.Model != "" { + activeTranscriber.SetModel(cfg.Model) + } + } + break + } + } + } + if activeTranscriber == nil { + var initErr error + activeTranscriber, initErr = transcriber.New() + if initErr != nil { + fatal("No API key set.\n\nSet GROQ_API_KEY, OPENAI_API_KEY, or DEEPGRAM_API_KEY.") + } } streamEnabled = modelSupportsStream(activeTranscriber) if *langFlag != "" { @@ -348,6 +370,7 @@ func run() { } tray.SetDevices(names, preferredDevice, func(name string) { preferredDevice = name + updateSettings(func(s *Settings) { s.Device = name }) if name == "" { applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil) } else { @@ -357,38 +380,20 @@ func run() { } tray.SetAutoPaste(autoPaste) - groqKey := os.Getenv("GROQ_API_KEY") - openaiKey := os.Getenv("OPENAI_API_KEY") - dgKey := os.Getenv("DEEPGRAM_API_KEY") - mistralKey := os.Getenv("MISTRAL_API_KEY") - elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY") - - type providerDef struct { - name, label, key string - models []transcriber.ModelInfo - newFn func() transcriber.Transcriber - } - providers := []providerDef{ - {"groq", "Groq", groqKey, transcriber.GroqModels, func() transcriber.Transcriber { return transcriber.NewGroq(groqKey) }}, - {"openai", "OpenAI", openaiKey, transcriber.OpenAIModels, func() transcriber.Transcriber { return transcriber.NewOpenAI(openaiKey) }}, - {"deepgram", "Deepgram", dgKey, transcriber.DeepgramModels, func() transcriber.Transcriber { return transcriber.NewDeepgram(dgKey) }}, - {"mistral", "Mistral", mistralKey, transcriber.MistralModels, func() transcriber.Transcriber { return transcriber.NewMistral(mistralKey) }}, - {"elevenlabs", "ElevenLabs", elevenLabsKey, transcriber.ElevenLabsModels, func() transcriber.Transcriber { return transcriber.NewElevenLabs(elevenLabsKey) }}, - } - var trayModels []tray.Model modelIndex := map[string]transcriber.ModelInfo{} - for _, p := range providers { - for _, m := range p.models { + for _, p := range transcriber.Providers() { + key := os.Getenv(p.EnvKey) + for _, m := range p.Models { trayModels = append(trayModels, tray.Model{ - Provider: p.name, - ProviderLabel: p.label, + Provider: p.Name, + ProviderLabel: p.Label, ModelID: m.ID, Label: m.Label, - HasKey: p.key != "", - Active: activeTranscriber.Name() == p.name && activeTranscriber.GetModel() == m.ID, + HasKey: key != "", + Active: activeTranscriber.Name() == p.Name && activeTranscriber.GetModel() == m.ID, }) - modelIndex[p.name+":"+m.ID] = m + modelIndex[p.Name+":"+m.ID] = m } } @@ -401,9 +406,11 @@ func run() { currentLang := activeTranscriber.GetLanguage() var newTr transcriber.Transcriber - for _, p := range providers { - if p.name == provider { - newTr = p.newFn() + for _, p := range transcriber.Providers() { + if p.Name == provider { + if key := os.Getenv(p.EnvKey); key != "" { + newTr = p.NewFn(key) + } break } } @@ -419,6 +426,7 @@ func run() { activeFormat = *formatFlag } + updateSettings(func(s *Settings) { s.Provider = provider; s.Model = model }) tray.SetLanguages(newTr.SupportedLanguages()) }) @@ -426,6 +434,7 @@ func run() { configMu.Lock() activeTranscriber.SetLanguage(code) configMu.Unlock() + updateSettings(func(s *Settings) { s.Language = code }) }) tray.SetLogin(login.Enabled()) @@ -434,6 +443,7 @@ func run() { configMu.Lock() autoPaste = on configMu.Unlock() + updateSettings(func(s *Settings) { s.AutoPaste = on }) }) tray.OnLogin(func(on bool) error { var err error @@ -445,6 +455,8 @@ func run() { if err != nil { log.Errorf("login toggle: %v", err) tray.SetError(err.Error()) + } else { + updateSettings(func(s *Settings) { s.AutoStart = on }) } return err }) @@ -484,7 +496,21 @@ func run() { } }() - update.StartBackgroundCheck(version, log.Dir(), func(rel update.Release) { + tray.SetVersion(version) + tray.OnCheckUpdate(func() { + go func() { + rel, err := update.CheckNow(version, settingsDir()) + if err != nil { + tray.SetError("Update check failed") + return + } + if rel != nil { + tray.SetUpdateAvailable(rel.Version) + } + }() + }) + + update.StartBackgroundCheck(version, settingsDir(), func(rel update.Release) { log.Info("update_available: " + rel.Version) tray.SetUpdateAvailable(rel.Version) }) diff --git a/transcriber/transcriber.go b/transcriber/transcriber.go index a46a00b..44c0a0e 100644 --- a/transcriber/transcriber.go +++ b/transcriber/transcriber.go @@ -165,6 +165,24 @@ func modelLanguages(models []ModelInfo, current string) []Language { return nil } +type ProviderInfo struct { + Name string + Label string + EnvKey string + Models []ModelInfo + NewFn func(string) Transcriber +} + +func Providers() []ProviderInfo { + return []ProviderInfo{ + {"deepgram", "Deepgram", "DEEPGRAM_API_KEY", DeepgramModels, func(k string) Transcriber { return NewDeepgram(k) }}, + {"openai", "OpenAI", "OPENAI_API_KEY", OpenAIModels, func(k string) Transcriber { return NewOpenAI(k) }}, + {"groq", "Groq", "GROQ_API_KEY", GroqModels, func(k string) Transcriber { return NewGroq(k) }}, + {"mistral", "Mistral", "MISTRAL_API_KEY", MistralModels, func(k string) Transcriber { return NewMistral(k) }}, + {"elevenlabs", "ElevenLabs", "ELEVENLABS_API_KEY", ElevenLabsModels, func(k string) Transcriber { return NewElevenLabs(k) }}, + } +} + func New() (Transcriber, error) { if fakeText, ok := os.LookupEnv("ZEE_FAKE_TEXT"); ok { var fakeErr error @@ -174,26 +192,10 @@ func New() (Transcriber, error) { return NewFake(fakeText, fakeErr), nil } - dgKey := os.Getenv("DEEPGRAM_API_KEY") - openaiKey := os.Getenv("OPENAI_API_KEY") - groqKey := os.Getenv("GROQ_API_KEY") - mistralKey := os.Getenv("MISTRAL_API_KEY") - elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY") - - if dgKey != "" { - return NewDeepgram(dgKey), nil - } - if openaiKey != "" { - return NewOpenAI(openaiKey), nil - } - if groqKey != "" { - return NewGroq(groqKey), nil - } - if mistralKey != "" { - return NewMistral(mistralKey), nil - } - if elevenLabsKey != "" { - return NewElevenLabs(elevenLabsKey), nil + for _, p := range Providers() { + if key := os.Getenv(p.EnvKey); key != "" { + return p.NewFn(key), nil + } } return nil, fmt.Errorf("set DEEPGRAM_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, or ELEVENLABS_API_KEY environment variable") diff --git a/tray/tray.go b/tray/tray.go index e353f8b..fc37bcb 100644 --- a/tray/tray.go +++ b/tray/tray.go @@ -46,6 +46,10 @@ var ( langCode string // current language code ("" = auto-detect) langCb func(string) + + appVersion string + latestVersion string + checkUpdateCb func() ) var languages []transcriber.Language // set via SetLanguages @@ -111,8 +115,13 @@ func SetLastRecording(dur time.Duration, totalMs float64) { updateCopyLastTitle(fmt.Sprintf("Copy Last Recorded Text (%.1fs | %dms)", dur.Seconds(), int(totalMs))) } +func SetVersion(v string) { appVersion = v } +func OnCheckUpdate(fn func()) { checkUpdateCb = fn } + func SetUpdateAvailable(version string) { - addUpdateMenuItem(version) + latestVersion = version + updateStatus() + setCheckUpdateTitle("Update available: " + version) } func SetLanguage(code string, onSwitch func(string)) { @@ -140,14 +149,23 @@ func statusText() string { } } modelMu.Unlock() + + ver := "𝘻𝘦𝘦" + if appVersion != "" && appVersion != "dev" { + ver = "𝘻𝘦𝘦 " + appVersion + } + if latestVersion != "" { + ver += " (update: " + latestVersion + ")" + } + lang := "Auto" if langCode != "" { lang = langCode } if provider == "" { - return "𝘻𝘦𝘦" + return ver } - return "𝘻𝘦𝘦 β€” " + provider + " Β· " + model + " Β· " + lang + return ver + " β€” " + provider + " Β· " + model + " Β· " + lang } func updateStatus() { diff --git a/tray/tray_darwin.go b/tray/tray_darwin.go index fb5c68a..4d8fbc0 100644 --- a/tray/tray_darwin.go +++ b/tray/tray_darwin.go @@ -27,7 +27,7 @@ var ( item *systray.MenuItem code string } - mUpdate *systray.MenuItem + mCheckUpdate *systray.MenuItem modelItems []*systray.MenuItem ) @@ -163,7 +163,7 @@ func onReady() { systray.AddSeparator() - mRecord = systray.AddMenuItem("β—‹ Start Recording (Fn)", "Start or stop recording") + mRecord = systray.AddMenuItem("β—‹ Start Recording (Shift+Control+Space)", "Start or stop recording") mRecord.Click(func() { if recording { if stopFn != nil { @@ -293,6 +293,19 @@ func onReady() { addLangEntry(lang.Code, lang.Label) } + sep2 := mSettings.AddSubMenuItem("─────────", "") + sep2.Disable() + + mCheckUpdate = mSettings.AddSubMenuItem("Check for Updates…", "Check for updates") + mCheckUpdate.Click(func() { + if latestVersion != "" { + exec.Command("open", "https://github.com/sumerc/zee/releases/tag/"+latestVersion).Start() + } else if checkUpdateCb != nil { + mCheckUpdate.SetTitle("Checking…") + checkUpdateCb() + } + }) + systray.AddSeparator() mQuit := systray.AddMenuItem("Quit", "Quit zee") mQuit.Click(func() { Quit() }) @@ -308,20 +321,10 @@ func updateCopyLastTitle(title string) { } } -func addUpdateMenuItem(version string) { - if mUpdate != nil { - mUpdate.SetTitle("⚠ Update available: " + version) - mUpdate.Show() - return - } - if mSettings == nil { - return +func setCheckUpdateTitle(title string) { + if mCheckUpdate != nil { + mCheckUpdate.SetTitle(title) } - mUpdate = mSettings.AddSubMenuItem("Update available: "+version, "Open release page") - mUpdate.Click(func() { - url := "https://github.com/sumerc/zee/releases/tag/" + version - exec.Command("open", url).Start() - }) } func addLangEntry(code, label string) { diff --git a/update/apply.go b/update/apply.go deleted file mode 100644 index 2aa23b1..0000000 --- a/update/apply.go +++ /dev/null @@ -1,125 +0,0 @@ -package update - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" -) - -func Apply(rel *Release) error { - execPath, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - execPath, err = filepath.EvalSymlinks(execPath) - if err != nil { - return fmt.Errorf("resolve symlinks: %w", err) - } - dir := filepath.Dir(execPath) - - // Download to temp file in same directory (same filesystem for atomic rename) - tmpFile, err := os.CreateTemp(dir, ".zee-update-*") - if err != nil { - return fmt.Errorf("create temp file: %w", err) - } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) // cleanup on any error path - - resp, err := http.Get(rel.AssetURL) - if err != nil { - tmpFile.Close() - return fmt.Errorf("download binary: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - tmpFile.Close() - return fmt.Errorf("download binary: %s", resp.Status) - } - - hasher := sha256.New() - src := io.Reader(resp.Body) - if resp.ContentLength > 0 { - src = &progressReader{r: resp.Body, total: resp.ContentLength} - } - if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), src); err != nil { - tmpFile.Close() - return fmt.Errorf("write binary: %w", err) - } - if resp.ContentLength > 0 { - fmt.Println() // newline after progress - } - tmpFile.Close() - actualHash := hex.EncodeToString(hasher.Sum(nil)) - - // Verify checksum - if rel.ChecksumURL != "" { - expectedHash, err := fetchExpectedHash(rel.ChecksumURL, assetName()) - if err != nil { - return fmt.Errorf("fetch checksums: %w", err) - } - if actualHash != expectedHash { - return fmt.Errorf("checksum mismatch: got %s, want %s", actualHash[:12], expectedHash[:12]) - } - } - - if err := os.Chmod(tmpPath, 0755); err != nil { - return fmt.Errorf("chmod: %w", err) - } - - // Atomic swap: current -> .old, new -> current, remove .old - oldPath := execPath + ".old" - if err := os.Rename(execPath, oldPath); err != nil { - return fmt.Errorf("backup current binary: %w", err) - } - if err := os.Rename(tmpPath, execPath); err != nil { - // Rollback - _ = os.Rename(oldPath, execPath) - return fmt.Errorf("install new binary: %w", err) - } - _ = os.Remove(oldPath) - return nil -} - -type progressReader struct { - r io.Reader - total int64 - read int64 -} - -func (p *progressReader) Read(b []byte) (int, error) { - n, err := p.r.Read(b) - p.read += int64(n) - pct := float64(p.read) / float64(p.total) * 100 - fmt.Fprintf(os.Stderr, "\r %.0f%% (%d / %d KB)", pct, p.read/1024, p.total/1024) - return n, err -} - -func fetchExpectedHash(checksumURL, filename string) (string, error) { - resp, err := http.Get(checksumURL) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("checksums: %s", resp.Status) - } - - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - // Format: " " or " " - line := scanner.Text() - parts := strings.Fields(line) - if len(parts) == 2 && parts[1] == filename { - return parts[0], nil - } - } - return "", fmt.Errorf("no checksum for %s", filename) -} diff --git a/update/check.go b/update/check.go index e88b049..d3b5d16 100644 --- a/update/check.go +++ b/update/check.go @@ -6,35 +6,22 @@ import ( "net/http" "os" "path/filepath" - "runtime" "time" ) const ( - cacheFile = "update_check.json" - cacheTTL = 24 * time.Hour + cacheFile = "update_check.json" + cacheTTL = 24 * time.Hour checkInterval = 5 * time.Minute ) type ghRelease struct { - TagName string `json:"tag_name"` - Assets []ghAsset `json:"assets"` -} - -type ghAsset struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` + TagName string `json:"tag_name"` } type cachedCheck struct { - Version string `json:"version"` - AssetURL string `json:"asset_url"` - ChecksumURL string `json:"checksum_url"` - CheckedAt int64 `json:"checked_at"` -} - -func assetName() string { - return fmt.Sprintf("%s_%s_%s", BinaryName, runtime.GOOS, runtime.GOARCH) + Version string `json:"version"` + CheckedAt int64 `json:"checked_at"` } func CheckLatest(currentVersion string) (*Release, error) { @@ -64,21 +51,7 @@ func CheckLatest(currentVersion string) (*Release, error) { return nil, err } - want := assetName() - var assetURL, checksumURL string - for _, a := range rel.Assets { - switch a.Name { - case want: - assetURL = a.BrowserDownloadURL - case "checksums.txt": - checksumURL = a.BrowserDownloadURL - } - } - if assetURL == "" { - return nil, fmt.Errorf("no asset %q in release %s", want, rel.TagName) - } - - r := &Release{Version: rel.TagName, AssetURL: assetURL, ChecksumURL: checksumURL} + r := &Release{Version: rel.TagName, URL: ReleaseURL(rel.TagName)} if !r.NewerThan(currentVersion) { return nil, nil } @@ -102,17 +75,15 @@ func readCache(cacheDir string) (*Release, bool) { return nil, false } if c.Version == "" { - return nil, true // cached "no update" + return nil, true } - return &Release{Version: c.Version, AssetURL: c.AssetURL, ChecksumURL: c.ChecksumURL}, true + return &Release{Version: c.Version, URL: ReleaseURL(c.Version)}, true } func writeCache(cacheDir string, rel *Release) { c := cachedCheck{CheckedAt: time.Now().Unix()} if rel != nil { c.Version = rel.Version - c.AssetURL = rel.AssetURL - c.ChecksumURL = rel.ChecksumURL } data, err := json.Marshal(c) if err != nil { @@ -137,6 +108,16 @@ func CheckLatestCached(currentVersion, cacheDir string) (*Release, error) { return rel, nil } +// CheckNow forces a fresh check, bypassing cache. Writes result to cache. +func CheckNow(currentVersion, cacheDir string) (*Release, error) { + rel, err := CheckLatest(currentVersion) + if err != nil { + return nil, err + } + writeCache(cacheDir, rel) + return rel, nil +} + func StartBackgroundCheck(currentVersion, cacheDir string, notify func(Release)) { if currentVersion == "dev" { return diff --git a/update/update.go b/update/update.go index b4d9c9e..0913a0c 100644 --- a/update/update.go +++ b/update/update.go @@ -6,15 +6,15 @@ import ( "strings" ) -const ( - Repo = "sumerc/zee" - BinaryName = "zee" -) +const Repo = "sumerc/zee" type Release struct { - Version string - AssetURL string - ChecksumURL string + Version string + URL string // GitHub release page URL +} + +func ReleaseURL(version string) string { + return fmt.Sprintf("https://github.com/%s/releases/tag/%s", Repo, version) } type semver struct { diff --git a/update/update_test.go b/update/update_test.go index e45aecb..448457e 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -60,11 +60,9 @@ func TestReleaseNewerThan(t *testing.T) { func TestCacheWriteRead(t *testing.T) { dir := t.TempDir() - // Write a release to cache - rel := &Release{Version: "v0.2.0", AssetURL: "https://example.com/zee", ChecksumURL: "https://example.com/checksums.txt"} + rel := &Release{Version: "v0.2.0", URL: ReleaseURL("v0.2.0")} writeCache(dir, rel) - // Read it back got, ok := readCache(dir) if !ok { t.Fatal("readCache returned not ok") @@ -72,11 +70,10 @@ func TestCacheWriteRead(t *testing.T) { if got == nil { t.Fatal("readCache returned nil release") } - if got.Version != rel.Version || got.AssetURL != rel.AssetURL || got.ChecksumURL != rel.ChecksumURL { - t.Errorf("readCache = %+v, want %+v", got, rel) + if got.Version != rel.Version { + t.Errorf("readCache version = %q, want %q", got.Version, rel.Version) } - // Write nil (no update available) writeCache(dir, nil) got, ok = readCache(dir) if !ok { @@ -86,7 +83,6 @@ func TestCacheWriteRead(t *testing.T) { t.Errorf("readCache = %+v, want nil", got) } - // Corrupt cache file _ = os.WriteFile(filepath.Join(dir, cacheFile), []byte("not json"), 0644) _, ok = readCache(dir) if ok { From b3300e0c50ff00abbc7594697e32064e664e0400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=BCmer=20Cip?= Date: Wed, 1 Apr 2026 20:10:20 +0300 Subject: [PATCH 3/3] update Alerts --- CLAUDE.md | 1 + alert/alert_darwin.go | 13 ++++++ alert/alert_other.go | 6 ++- main.go | 20 ++++----- tray/tray.go | 28 ++++--------- tray/tray_darwin.go | 18 ++------- update/check.go | 94 ------------------------------------------- update/update_test.go | 39 +----------------- 8 files changed, 40 insertions(+), 179 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 223346e..906566c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ Ctrl+Shift+Space keydown β†’ record audio β†’ encode (mode-based) β†’ API call - `device.go` - microphone picker with arrow-key navigation - `vad.go` - voice activity detection using WebRTC VAD with debounced speech confirmation - `silence.go` - silence monitoring with warnings, repeat beeps, and auto-close (toggle mode) +- `settings.go` - persistent settings (language, device, provider/model, auto-paste, auto-start) with JSON config file - `log.go` - diagnostic logging and panic capture to `diagnostics_log.txt` ## Design Philosophy diff --git a/alert/alert_darwin.go b/alert/alert_darwin.go index 86ea092..a3c72ff 100644 --- a/alert/alert_darwin.go +++ b/alert/alert_darwin.go @@ -12,6 +12,19 @@ func Warn(msg string) { show(msg, "caution") } +func Info(msg string) { + show(msg, "note") +} + +func Confirm(msg, action string) bool { + out, err := exec.Command("osascript", "-e", + `display dialog "`+msg+`" with title "Zee" buttons {"Cancel", "`+action+`"} default button "`+action+`" with icon note`).Output() + if err != nil { + return false + } + return string(out) != "" +} + func show(msg, icon string) { exec.Command("osascript", "-e", `display dialog "`+msg+`" with title "Zee" buttons {"OK"} default button "OK" with icon `+icon).Run() diff --git a/alert/alert_other.go b/alert/alert_other.go index 974a544..be5e3d2 100644 --- a/alert/alert_other.go +++ b/alert/alert_other.go @@ -2,5 +2,7 @@ package alert -func Error(_ string) {} -func Warn(_ string) {} +func Error(_ string) {} +func Warn(_ string) {} +func Info(_ string) {} +func Confirm(_, _ string) bool { return false } diff --git a/main.go b/main.go index a030729..bf41211 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "net/http" _ "net/http/pprof" "os" + "os/exec" "path/filepath" "runtime/debug" "slices" @@ -437,6 +438,7 @@ func run() { updateSettings(func(s *Settings) { s.Language = code }) }) tray.SetLogin(login.Enabled()) + tray.SetVersion(version) trayQuit := tray.Init() tray.OnAutoPaste(func(on bool) { @@ -496,25 +498,23 @@ func run() { } }() - tray.SetVersion(version) tray.OnCheckUpdate(func() { go func() { - rel, err := update.CheckNow(version, settingsDir()) + rel, err := update.CheckLatest(version) if err != nil { - tray.SetError("Update check failed") + alert.Warn("Could not check for updates:\n" + err.Error()) + return + } + if rel == nil { + alert.Info("You're on the latest version (" + version + ")") return } - if rel != nil { - tray.SetUpdateAvailable(rel.Version) + if alert.Confirm("Update available: "+version+" β†’ "+rel.Version+"\n\nHomebrew:\nbrew upgrade sumerc/tap/zee", "Open Release Page") { + exec.Command("open", rel.URL).Start() } }() }) - update.StartBackgroundCheck(version, settingsDir(), func(rel update.Release) { - log.Info("update_available: " + rel.Version) - tray.SetUpdateAvailable(rel.Version) - }) - sigChan := make(chan os.Signal, 1) shutdown.Notify(sigChan) go func() { diff --git a/tray/tray.go b/tray/tray.go index fc37bcb..7a8bf88 100644 --- a/tray/tray.go +++ b/tray/tray.go @@ -48,7 +48,6 @@ var ( langCb func(string) appVersion string - latestVersion string checkUpdateCb func() ) @@ -115,14 +114,8 @@ func SetLastRecording(dur time.Duration, totalMs float64) { updateCopyLastTitle(fmt.Sprintf("Copy Last Recorded Text (%.1fs | %dms)", dur.Seconds(), int(totalMs))) } -func SetVersion(v string) { appVersion = v } -func OnCheckUpdate(fn func()) { checkUpdateCb = fn } - -func SetUpdateAvailable(version string) { - latestVersion = version - updateStatus() - setCheckUpdateTitle("Update available: " + version) -} +func SetVersion(v string) { appVersion = v } +func OnCheckUpdate(fn func()) { checkUpdateCb = fn } func SetLanguage(code string, onSwitch func(string)) { langCode = code @@ -149,23 +142,18 @@ func statusText() string { } } modelMu.Unlock() - - ver := "𝘻𝘦𝘦" - if appVersion != "" && appVersion != "dev" { - ver = "𝘻𝘦𝘦 " + appVersion - } - if latestVersion != "" { - ver += " (update: " + latestVersion + ")" - } - lang := "Auto" if langCode != "" { lang = langCode } + ver := "" + if appVersion != "" && appVersion != "dev" { + ver = " Β· " + appVersion + } if provider == "" { - return ver + return "𝘻𝘦𝘦" } - return ver + " β€” " + provider + " Β· " + model + " Β· " + lang + return "𝘻𝘦𝘦 β€” " + provider + " Β· " + model + " Β· " + lang + ver } func updateStatus() { diff --git a/tray/tray_darwin.go b/tray/tray_darwin.go index 4d8fbc0..617231d 100644 --- a/tray/tray_darwin.go +++ b/tray/tray_darwin.go @@ -3,8 +3,6 @@ package tray import ( - "os/exec" - "github.com/energye/systray" "golang.design/x/hotkey/mainthread" ) @@ -293,20 +291,15 @@ func onReady() { addLangEntry(lang.Code, lang.Label) } - sep2 := mSettings.AddSubMenuItem("─────────", "") - sep2.Disable() + systray.AddSeparator() - mCheckUpdate = mSettings.AddSubMenuItem("Check for Updates…", "Check for updates") + mCheckUpdate = systray.AddMenuItem("Check for Updates…", "Check for updates") mCheckUpdate.Click(func() { - if latestVersion != "" { - exec.Command("open", "https://github.com/sumerc/zee/releases/tag/"+latestVersion).Start() - } else if checkUpdateCb != nil { - mCheckUpdate.SetTitle("Checking…") + if checkUpdateCb != nil { checkUpdateCb() } }) - systray.AddSeparator() mQuit := systray.AddMenuItem("Quit", "Quit zee") mQuit.Click(func() { Quit() }) systray.CreateMenu() @@ -321,11 +314,6 @@ func updateCopyLastTitle(title string) { } } -func setCheckUpdateTitle(title string) { - if mCheckUpdate != nil { - mCheckUpdate.SetTitle(title) - } -} func addLangEntry(code, label string) { idx := len(langEntries) diff --git a/update/check.go b/update/check.go index d3b5d16..f8784c0 100644 --- a/update/check.go +++ b/update/check.go @@ -4,26 +4,12 @@ import ( "encoding/json" "fmt" "net/http" - "os" - "path/filepath" - "time" -) - -const ( - cacheFile = "update_check.json" - cacheTTL = 24 * time.Hour - checkInterval = 5 * time.Minute ) type ghRelease struct { TagName string `json:"tag_name"` } -type cachedCheck struct { - Version string `json:"version"` - CheckedAt int64 `json:"checked_at"` -} - func CheckLatest(currentVersion string) (*Release, error) { if currentVersion == "dev" { return nil, nil @@ -57,83 +43,3 @@ func CheckLatest(currentVersion string) (*Release, error) { } return r, nil } - -func cachePath(cacheDir string) string { - return filepath.Join(cacheDir, cacheFile) -} - -func readCache(cacheDir string) (*Release, bool) { - data, err := os.ReadFile(cachePath(cacheDir)) - if err != nil { - return nil, false - } - var c cachedCheck - if json.Unmarshal(data, &c) != nil { - return nil, false - } - if time.Since(time.Unix(c.CheckedAt, 0)) > cacheTTL { - return nil, false - } - if c.Version == "" { - return nil, true - } - return &Release{Version: c.Version, URL: ReleaseURL(c.Version)}, true -} - -func writeCache(cacheDir string, rel *Release) { - c := cachedCheck{CheckedAt: time.Now().Unix()} - if rel != nil { - c.Version = rel.Version - } - data, err := json.Marshal(c) - if err != nil { - return - } - _ = os.MkdirAll(cacheDir, 0755) - _ = os.WriteFile(cachePath(cacheDir), data, 0644) -} - -func CheckLatestCached(currentVersion, cacheDir string) (*Release, error) { - if currentVersion == "dev" { - return nil, nil - } - if rel, ok := readCache(cacheDir); ok { - return rel, nil - } - rel, err := CheckLatest(currentVersion) - if err != nil { - return nil, err - } - writeCache(cacheDir, rel) - return rel, nil -} - -// CheckNow forces a fresh check, bypassing cache. Writes result to cache. -func CheckNow(currentVersion, cacheDir string) (*Release, error) { - rel, err := CheckLatest(currentVersion) - if err != nil { - return nil, err - } - writeCache(cacheDir, rel) - return rel, nil -} - -func StartBackgroundCheck(currentVersion, cacheDir string, notify func(Release)) { - if currentVersion == "dev" { - return - } - go func() { - check := func() { - rel, err := CheckLatestCached(currentVersion, cacheDir) - if err == nil && rel != nil { - notify(*rel) - } - } - check() - ticker := time.NewTicker(checkInterval) - defer ticker.Stop() - for range ticker.C { - check() - } - }() -} diff --git a/update/update_test.go b/update/update_test.go index 448457e..52914c4 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -1,10 +1,6 @@ package update -import ( - "os" - "path/filepath" - "testing" -) +import "testing" func TestParseSemver(t *testing.T) { tests := []struct { @@ -56,36 +52,3 @@ func TestReleaseNewerThan(t *testing.T) { } } } - -func TestCacheWriteRead(t *testing.T) { - dir := t.TempDir() - - rel := &Release{Version: "v0.2.0", URL: ReleaseURL("v0.2.0")} - writeCache(dir, rel) - - got, ok := readCache(dir) - if !ok { - t.Fatal("readCache returned not ok") - } - if got == nil { - t.Fatal("readCache returned nil release") - } - if got.Version != rel.Version { - t.Errorf("readCache version = %q, want %q", got.Version, rel.Version) - } - - writeCache(dir, nil) - got, ok = readCache(dir) - if !ok { - t.Fatal("readCache returned not ok for nil cache") - } - if got != nil { - t.Errorf("readCache = %+v, want nil", got) - } - - _ = os.WriteFile(filepath.Join(dir, cacheFile), []byte("not json"), 0644) - _, ok = readCache(dir) - if ok { - t.Error("readCache should return not ok for corrupt cache") - } -}