From fb4bb2a0c3e8f30e91ff9a756ddee525d09141a7 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 15 Jul 2026 03:31:00 +0800 Subject: [PATCH] fix: localize native tray menu --- README.ru.md | 4 +- internal/core/appsettings/manager.go | 30 +++++-- internal/core/appsettings/manager_test.go | 29 +++++++ internal/shell/tray/controller.go | 95 ++++++++++++++++++++++- internal/shell/tray/controller_test.go | 47 ++++++++++- internal/shell/tray/systray_backend.go | 12 +++ main.go | 7 ++ 7 files changed, 213 insertions(+), 11 deletions(-) diff --git a/README.ru.md b/README.ru.md index e9862d8..71dec6f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -7,7 +7,7 @@ ### Рабочий контекст остаётся рядом — и хранится локально. Файлы, заметки, ссылки, материалы из браузера, активность и история работы -в одном расширяемом рабочем пространстве. +в одной расширяемой локальной среде. [English](README.md) · **Русский** @@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage ## Фоновая работа и трей -Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. +Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. ### Проверка скачанного файла diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index 22b5fbd..7d80116 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -78,9 +78,10 @@ type WindowState struct { // Manager provides thread-safe access to app settings. type Manager struct { - mu sync.RWMutex - config *Config - configPath string + mu sync.RWMutex + config *Config + configPath string + onLanguageChanged func(string) } // DefaultConfigPath returns the default path for app settings. @@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error { } m.mu.Lock() - defer m.mu.Unlock() if m.config == nil { m.config = defaultConfig() } m.config.Language = language m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) - return m.saveLocked() + err := m.saveLocked() + handler := m.onLanguageChanged + m.mu.Unlock() + if err != nil { + return err + } + if handler != nil { + handler(language) + } + return nil +} + +// SetLanguageChangedHandler registers the shell callback invoked after a +// language preference has been durably updated. +func (m *Manager) SetLanguageChangedHandler(handler func(string)) { + if m == nil { + return + } + m.mu.Lock() + m.onLanguageChanged = handler + m.mu.Unlock() } // UpdateSync replaces sync settings without changing unrelated app settings. diff --git a/internal/core/appsettings/manager_test.go b/internal/core/appsettings/manager_test.go index 3c980e9..51ae4c6 100644 --- a/internal/core/appsettings/manager_test.go +++ b/internal/core/appsettings/manager_test.go @@ -3,6 +3,7 @@ package appsettings import ( "os" "path/filepath" + "reflect" "testing" ) @@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) { } } +func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + manager := NewManager(path) + if err := manager.Load(); err != nil { + t.Fatal(err) + } + + var received []string + manager.SetLanguageChangedHandler(func(language string) { + received = append(received, language) + }) + if err := manager.UpdateLanguage(LanguageRussian); err != nil { + t.Fatalf("UpdateLanguage: %v", err) + } + if got := manager.Get().Language; got != LanguageRussian { + t.Fatalf("persisted language = %q, want %q", got, LanguageRussian) + } + if !reflect.DeepEqual(received, []string{LanguageRussian}) { + t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian) + } + if err := manager.UpdateLanguage("de"); err == nil { + t.Fatal("UpdateLanguage(de) succeeded, want validation failure") + } + if !reflect.DeepEqual(received, []string{LanguageRussian}) { + t.Fatalf("language notification fired after rejected update: %#v", received) + } +} + func TestLoad_CorruptConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") diff --git a/internal/shell/tray/controller.go b/internal/shell/tray/controller.go index ee88d91..e4a38c0 100644 --- a/internal/shell/tray/controller.go +++ b/internal/shell/tray/controller.go @@ -1,11 +1,16 @@ // Package tray owns the native tray menu wiring for the desktop shell. package tray -import "sync" +import ( + "strings" + "sync" +) // MenuItem exposes click events from a native tray menu item. type MenuItem interface { Clicked() <-chan struct{} + SetTitle(title string) + SetTooltip(tooltip string) } // Backend is the platform tray implementation. @@ -23,11 +28,63 @@ type Actions struct { Quit func() } +// Labels contains the user-visible text for one tray menu locale. +type Labels struct { + ShowTitle string + ShowTooltip string + QuitTitle string + QuitTooltip string +} + +func englishLabels() Labels { + return Labels{ + ShowTitle: "Show Verstak", + ShowTooltip: "Show the Verstak window", + QuitTitle: "Quit", + QuitTooltip: "Quit Verstak", + } +} + +func russianLabels() Labels { + return Labels{ + ShowTitle: "Показать Верстак", + ShowTooltip: "Показать окно Верстака", + QuitTitle: "Выйти", + QuitTooltip: "Завершить Верстак", + } +} + +// LabelsForPreference resolves the saved language preference for native tray UI. +// System locale values are intentionally passed in by the shell so this package +// stays independent of process environment and is deterministic in tests. +func LabelsForPreference(preference string, systemLocales ...string) Labels { + if strings.EqualFold(strings.TrimSpace(preference), "ru") { + return russianLabels() + } + if strings.EqualFold(strings.TrimSpace(preference), "system") { + for _, locale := range systemLocales { + locale = strings.ToLower(strings.TrimSpace(locale)) + if locale == "" { + continue + } + if strings.HasPrefix(locale, "ru") { + return russianLabels() + } + return englishLabels() + } + } + return englishLabels() +} + // Controller initializes one native tray and routes its menu actions. type Controller struct { backend Backend icon []byte start sync.Once + mu sync.RWMutex + labels Labels + show MenuItem + quit MenuItem } // New creates a tray controller for one application process. @@ -35,9 +92,22 @@ func New(backend Backend, icon []byte) *Controller { return &Controller{ backend: backend, icon: append([]byte(nil), icon...), + labels: englishLabels(), } } +// SetLabels updates the current and future native tray menu labels. +func (c *Controller) SetLabels(labels Labels) { + if c == nil { + return + } + c.mu.Lock() + c.labels = labels + show, quit := c.show, c.quit + c.mu.Unlock() + applyLabels(show, quit, labels) +} + // Start registers the native tray without taking over the Wails event loop. func (c *Controller) Start(actions Actions) { if c == nil || c.backend == nil { @@ -47,8 +117,16 @@ func (c *Controller) Start(actions Actions) { c.backend.Register(func() { c.backend.SetIcon(c.icon) c.backend.SetTooltip("Verstak") - show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window") - quit := c.backend.AddMenuItem("Quit", "Quit Verstak") + c.mu.RLock() + labels := c.labels + c.mu.RUnlock() + show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip) + quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip) + c.mu.Lock() + c.show, c.quit = show, quit + labels = c.labels + c.mu.Unlock() + applyLabels(show, quit, labels) if actions.Show != nil && show != nil { go routeClicks(show.Clicked(), actions.Show) } @@ -59,6 +137,17 @@ func (c *Controller) Start(actions Actions) { }) } +func applyLabels(show, quit MenuItem, labels Labels) { + if show != nil { + show.SetTitle(labels.ShowTitle) + show.SetTooltip(labels.ShowTooltip) + } + if quit != nil { + quit.SetTitle(labels.QuitTitle) + quit.SetTooltip(labels.QuitTooltip) + } +} + // Stop releases the native tray after Wails has begun application shutdown. func (c *Controller) Stop() { if c == nil || c.backend == nil { diff --git a/internal/shell/tray/controller_test.go b/internal/shell/tray/controller_test.go index ab1de58..2cedb46 100644 --- a/internal/shell/tray/controller_test.go +++ b/internal/shell/tray/controller_test.go @@ -7,12 +7,22 @@ import ( type fakeMenuItem struct { clicked chan struct{} + title string + tooltip string } func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked } +func (i *fakeMenuItem) SetTitle(title string) { + i.title = title +} + +func (i *fakeMenuItem) SetTooltip(tooltip string) { + i.tooltip = tooltip +} + type fakeBackend struct { icon []byte tooltip string @@ -35,7 +45,7 @@ func (b *fakeBackend) SetTooltip(tooltip string) { } func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { - item := &fakeMenuItem{clicked: make(chan struct{}, 1)} + item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title} b.items[title] = item return item } @@ -89,3 +99,38 @@ func TestControllerStopsNativeTrayBackend(t *testing.T) { t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) } } + +func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) { + backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} + controller := New(backend, []byte{1}) + controller.SetLabels(LabelsForPreference("ru")) + controller.Start(Actions{}) + + show := backend.items["Показать Верстак"] + quit := backend.items["Выйти"] + if show == nil || quit == nil { + t.Fatalf("Russian tray menu = %#v", backend.items) + } + if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" { + t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip) + } + + controller.SetLabels(LabelsForPreference("en")) + if show.title != "Show Verstak" || quit.title != "Quit" { + t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title) + } + if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" { + t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip) + } +} + +func TestLabelsForSystemRussianLocale(t *testing.T) { + labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8") + if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" { + t.Fatalf("system Russian labels = %#v", labels) + } + labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8") + if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" { + t.Fatalf("higher-priority system locale must win, got %#v", labels) + } +} diff --git a/internal/shell/tray/systray_backend.go b/internal/shell/tray/systray_backend.go index 34b26db..834a8a8 100644 --- a/internal/shell/tray/systray_backend.go +++ b/internal/shell/tray/systray_backend.go @@ -39,3 +39,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} { } return item.item.ClickedCh } + +func (item systrayMenuItem) SetTitle(title string) { + if item.item != nil { + item.item.SetTitle(title) + } +} + +func (item systrayMenuItem) SetTooltip(tooltip string) { + if item.item != nil { + item.item.SetTooltip(tooltip) + } +} diff --git a/main.go b/main.go index 4cf7161..4a77f54 100644 --- a/main.go +++ b/main.go @@ -230,6 +230,13 @@ func main() { app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now)) trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) + trayLabels := func(language string) tray.Labels { + return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG")) + } + trayController.SetLabels(trayLabels(cfg.Language)) + appSettingsMgr.SetLanguageChangedHandler(func(language string) { + trayController.SetLabels(trayLabels(language)) + }) if browserReceiver != nil { browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) if err != nil {