Localize daemon notifications via stable message keys

This commit is contained in:
Viktor Liu
2026-08-05 17:53:02 +02:00
parent f2318a8fef
commit b3ead5ee7e
37 changed files with 1592 additions and 261 deletions
+6
View File
@@ -40,6 +40,12 @@ i18n/locales/<code>/common.json a target — message only
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
The `event.*` keys are a special group: the background service names them when it
publishes a notification, and the app looks them up here. Their names are part of
a Go↔JSON contract (`client/proto/usermsg.go`), so they are even less renameable
than the rest — and a missing one shows the user English. Tests fail the build if
any locale drops one.
| ✅ Do | ❌ Don't |
|---|---|
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
+14 -3
View File
@@ -126,18 +126,29 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
// the default language, then to the key itself so a miss is visible in the UI.
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
if v, ok := b.Lookup(lang, key, args...); ok {
return v
}
return key
}
// Lookup resolves key like Translate but reports whether it was found in the
// requested or the default bundle. Callers holding a better fallback than the
// raw key — a daemon-supplied English string for a key this build predates —
// use this to tell a miss from a hit.
func (b *Bundle) Lookup(lang LanguageCode, key string, args ...string) (string, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
if v, ok := b.bundles[lang][key]; ok {
return applyPlaceholders(v, args)
return applyPlaceholders(v, args), true
}
if lang != DefaultLanguage {
if v, ok := b.bundles[DefaultLanguage][key]; ok {
return applyPlaceholders(v, args)
return applyPlaceholders(v, args), true
}
}
return key
return "", false
}
// applyPlaceholders substitutes {name} in s using args as flat name/value
+126
View File
@@ -0,0 +1,126 @@
//go:build !android && !ios && !freebsd && !js
package i18n
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// shippedBundle loads the real locale tree rather than the fstest fixture the
// other tests use: these checks exist to catch a key the daemon publishes but no
// bundle translates, which only the shipped files can prove.
func shippedBundle(t *testing.T) *Bundle {
t.Helper()
b, err := NewBundle(os.DirFS("locales"))
require.NoError(t, err, "the shipped locale tree must load")
return b
}
// The daemon publishes a message key and each UI resolves it locally, so a key
// with no en entry degrades to the daemon's English fallback and silently stops
// being translatable. Fail the build instead.
func TestUserMessageKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for key, text := range proto.UserMessageTexts {
got, ok := b.Lookup(DefaultLanguage, string(key))
if !assert.True(t, ok, "message key %q has no en translation", key) {
continue
}
assert.Equal(t, text, got,
"en translation of %q must match the daemon's English fallback", key)
}
}
func TestUserMessageTitleKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for _, key := range proto.UserMessageTitleKeys {
_, ok := b.Lookup(DefaultLanguage, string(key))
assert.True(t, ok, "title key %q has no en translation", key)
}
}
// Every shipped locale must translate the daemon's keys, not just en. A missing
// one still renders (Lookup falls back to en) but the notification would show up
// in English for that user, which is the bug this whole mechanism exists to fix.
func TestUserMessageKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := make([]proto.UserMessageKey, 0, len(proto.UserMessageTexts))
for key := range proto.UserMessageTexts {
keys = append(keys, key)
}
keys = append(keys, proto.UserMessageTitleKeys...)
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[string(key)]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
}
// The tray composes a title from these when an event carries no title key, so a
// gap here would render "event.severity.warning: DNS" to the user.
func TestEventTitleKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := []string{
"event.title",
"event.severity.info", "event.severity.warning",
"event.severity.error", "event.severity.critical",
"event.category.network", "event.category.dns",
"event.category.authentication", "event.category.connectivity",
"event.category.system",
}
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[key]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
// The composed title is useless without both slots.
title, ok := b.Lookup(DefaultLanguage, "event.title", "severity", "Warning", "category", "DNS")
require.True(t, ok)
assert.Equal(t, "Warning: DNS", title, "event.title must substitute both placeholders")
}
func TestBundleLookupReportsMisses(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
got, ok := b.Lookup("en", "tray.menu.connect")
assert.True(t, ok)
assert.Equal(t, "Connect", got)
// An absent key must report a miss rather than echo the key, so callers can
// substitute their own fallback.
got, ok = b.Lookup("en", "tray.missing")
assert.False(t, ok, "unknown key must report a miss")
assert.Empty(t, got, "a miss must not return the key")
// Empty keys reach Lookup from events that carry no title key at all.
_, ok = b.Lookup("en", "")
assert.False(t, ok, "empty key must report a miss")
}
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Warnung"
},
"event.severity.error": {
"message": "Fehler"
},
"event.severity.critical": {
"message": "Kritisch"
},
"event.category.network": {
"message": "Netzwerk"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentifizierung"
},
"event.category.connectivity": {
"message": "Konnektivität"
},
"event.category.system": {
"message": "System"
},
"event.panic": {
"message": "Der NetBird-Dienst ist abgestürzt. Bitte starten Sie den Dienst neu und senden Sie einen Fehlerbericht mit den Client-Protokollen."
},
"event.dns.recovered": {
"message": "DNS-Server sind wieder erreichbar."
},
"event.dns.unreachable": {
"message": "Ein oder mehrere DNS-Server sind nicht erreichbar. Das kann die Verbindung zu einigen Diensten beeinträchtigen."
},
"event.exitNode.connected": {
"message": "Exit Node verbunden."
},
"event.exitNode.disconnected": {
"message": "Exit Node getrennt."
},
"event.exitNode.connectionLost": {
"message": "Verbindung zum Exit Node verloren. Ihr Internetzugang kann beeinträchtigt sein."
},
"event.exitNode.haChange": {
"message": "Exit Node aufgrund einer Änderung der Hochverfügbarkeit getrennt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit Node aus unbekannten Gründen getrennt."
},
"event.update.installing": {
"message": "Update wird jetzt installiert."
},
"event.update.completed": {
"message": "Ihr NetBird-Client wurde automatisch auf Version {version} aktualisiert."
},
"event.update.failed": {
"message": "Automatisches Update fehlgeschlagen: {reason}"
},
"common.cancel": {
"message": "Abbrechen"
},
+84
View File
@@ -239,6 +239,90 @@
"message": "Your NetBird configuration was updated by your IT policy.",
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
},
"event.title": {
"message": "{severity}: {category}",
"description": "Notification title for a daemon event, composed from severity and category, e.g. \"Warning: DNS\". Keep both placeholders; use your locale's colon spacing."
},
"event.severity.info": {
"message": "Info",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.warning": {
"message": "Warning",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.error": {
"message": "Error",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.critical": {
"message": "Critical",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.category.network": {
"message": "Network",
"description": "Event category label used in the {category} slot of event.title. Refers to the overlay network."
},
"event.category.dns": {
"message": "DNS",
"description": "Event category label used in the {category} slot of event.title. Acronym, do not translate."
},
"event.category.authentication": {
"message": "Authentication",
"description": "Event category label used in the {category} slot of event.title. Refers to signing in to the management server."
},
"event.category.connectivity": {
"message": "Connectivity",
"description": "Event category label used in the {category} slot of event.title. Refers to reaching peers."
},
"event.category.system": {
"message": "System",
"description": "Event category label used in the {category} slot of event.title. Refers to the local machine and the NetBird service."
},
"event.panic": {
"message": "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
"description": "Notification body after the NetBird background service crashed. \"Service\" is the daemon, not a remote service."
},
"event.dns.recovered": {
"message": "DNS servers are reachable again.",
"description": "Notification body when previously unreachable upstream DNS servers respond again."
},
"event.dns.unreachable": {
"message": "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
"description": "Notification body when one or more upstream DNS servers stop responding."
},
"event.exitNode.connected": {
"message": "Exit node connected.",
"description": "Notification body when a full-tunnel exit node becomes active."
},
"event.exitNode.disconnected": {
"message": "Exit node disconnected.",
"description": "Notification body when the user or the client shuts the exit node down deliberately."
},
"event.exitNode.connectionLost": {
"message": "Exit node connection lost. Your internet access might be affected.",
"description": "Notification body when the exit node peer became unreachable. \"Internet access\" means the user's own browsing."
},
"event.exitNode.haChange": {
"message": "Exit node disconnected due to high availability change.",
"description": "Notification body when a high-availability group switched away from this exit node. High availability is the standard IT term."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit node disconnected for unknown reasons.",
"description": "Notification body when the exit node dropped for a reason the client could not classify."
},
"event.update.installing": {
"message": "Installing update now.",
"description": "Notification body shown as an automatic client update starts installing."
},
"event.update.completed": {
"message": "Your NetBird client was auto-updated to version {version}.",
"description": "Notification body after an automatic client update succeeded. {version} is a version number, keep verbatim."
},
"event.update.failed": {
"message": "Auto-update failed: {reason}",
"description": "Notification body when an automatic client update failed. {reason} is an untranslated technical error string; keep your locale's colon spacing."
},
"common.cancel": {
"message": "Cancel",
"description": "Generic Cancel button label, reused across dialogs. Keep short."
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Su configuración de NetBird fue actualizada por su política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Información"
},
"event.severity.warning": {
"message": "Advertencia"
},
"event.severity.error": {
"message": "Error"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Red"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticación"
},
"event.category.connectivity": {
"message": "Conectividad"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "El servicio de NetBird falló de forma inesperada. Reinicie el servicio y envíe un informe de error con los registros del cliente."
},
"event.dns.recovered": {
"message": "Los servidores DNS vuelven a estar accesibles."
},
"event.dns.unreachable": {
"message": "No se puede acceder a uno o más servidores DNS. Esto puede afectar la conexión a algunos servicios."
},
"event.exitNode.connected": {
"message": "Nodo de salida conectado."
},
"event.exitNode.disconnected": {
"message": "Nodo de salida desconectado."
},
"event.exitNode.connectionLost": {
"message": "Se perdió la conexión con el nodo de salida. Su acceso a Internet puede verse afectado."
},
"event.exitNode.haChange": {
"message": "Nodo de salida desconectado por un cambio de alta disponibilidad."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo de salida desconectado por motivos desconocidos."
},
"event.update.installing": {
"message": "Instalando la actualización ahora."
},
"event.update.completed": {
"message": "Su cliente de NetBird se actualizó automáticamente a la versión {version}."
},
"event.update.failed": {
"message": "La actualización automática falló: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
},
"event.title": {
"message": "{severity} : {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avertissement"
},
"event.severity.error": {
"message": "Erreur"
},
"event.severity.critical": {
"message": "Critique"
},
"event.category.network": {
"message": "Réseau"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentification"
},
"event.category.connectivity": {
"message": "Connectivité"
},
"event.category.system": {
"message": "Système"
},
"event.panic": {
"message": "Le service NetBird s'est arrêté brutalement. Veuillez redémarrer le service et envoyer un rapport de bug avec les journaux du client."
},
"event.dns.recovered": {
"message": "Les serveurs DNS sont de nouveau joignables."
},
"event.dns.unreachable": {
"message": "Impossible de joindre un ou plusieurs serveurs DNS. Cela peut affecter la connexion à certains services."
},
"event.exitNode.connected": {
"message": "Nœud de sortie connecté."
},
"event.exitNode.disconnected": {
"message": "Nœud de sortie déconnecté."
},
"event.exitNode.connectionLost": {
"message": "Connexion au nœud de sortie perdue. Votre accès à Internet peut être affecté."
},
"event.exitNode.haChange": {
"message": "Nœud de sortie déconnecté suite à un changement de haute disponibilité."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nœud de sortie déconnecté pour une raison inconnue."
},
"event.update.installing": {
"message": "Installation de la mise à jour en cours."
},
"event.update.completed": {
"message": "Votre client NetBird a été mis à jour automatiquement vers la version {version}."
},
"event.update.failed": {
"message": "Échec de la mise à jour automatique : {reason}"
},
"common.cancel": {
"message": "Annuler"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Információ"
},
"event.severity.warning": {
"message": "Figyelmeztetés"
},
"event.severity.error": {
"message": "Hiba"
},
"event.severity.critical": {
"message": "Kritikus"
},
"event.category.network": {
"message": "Hálózat"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Hitelesítés"
},
"event.category.connectivity": {
"message": "Kapcsolat"
},
"event.category.system": {
"message": "Rendszer"
},
"event.panic": {
"message": "A NetBird szolgáltatás összeomlott. Kérjük, indítsa újra a szolgáltatást, és küldjön hibajelentést a kliens naplóival."
},
"event.dns.recovered": {
"message": "A DNS-kiszolgálók ismét elérhetők."
},
"event.dns.unreachable": {
"message": "Egy vagy több DNS-kiszolgáló nem érhető el. Ez befolyásolhatja egyes szolgáltatások elérését."
},
"event.exitNode.connected": {
"message": "Exit Node csatlakoztatva."
},
"event.exitNode.disconnected": {
"message": "Exit Node leválasztva."
},
"event.exitNode.connectionLost": {
"message": "Megszakadt a kapcsolat az Exit Node-dal. Ez érintheti az internetelérést."
},
"event.exitNode.haChange": {
"message": "Az Exit Node leválasztva a magas rendelkezésre állás változása miatt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Az Exit Node ismeretlen okból leválasztva."
},
"event.update.installing": {
"message": "A frissítés telepítése folyamatban."
},
"event.update.completed": {
"message": "A NetBird kliens automatikusan a {version} verzióra frissült."
},
"event.update.failed": {
"message": "Az automatikus frissítés sikertelen: {reason}"
},
"common.cancel": {
"message": "Mégse"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avviso"
},
"event.severity.error": {
"message": "Errore"
},
"event.severity.critical": {
"message": "Critico"
},
"event.category.network": {
"message": "Rete"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticazione"
},
"event.category.connectivity": {
"message": "Connettività"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "Il servizio NetBird si è arrestato in modo anomalo. Riavvii il servizio e invii una segnalazione di bug con i log del client."
},
"event.dns.recovered": {
"message": "I server DNS sono di nuovo raggiungibili."
},
"event.dns.unreachable": {
"message": "Impossibile raggiungere uno o più server DNS. Questo potrebbe influire sulla connessione ad alcuni servizi."
},
"event.exitNode.connected": {
"message": "Nodo di uscita connesso."
},
"event.exitNode.disconnected": {
"message": "Nodo di uscita disconnesso."
},
"event.exitNode.connectionLost": {
"message": "Connessione al nodo di uscita perduta. L'accesso a Internet potrebbe essere compromesso."
},
"event.exitNode.haChange": {
"message": "Nodo di uscita disconnesso a causa di una modifica dell'alta disponibilità."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo di uscita disconnesso per motivi sconosciuti."
},
"event.update.installing": {
"message": "Installazione dell'aggiornamento in corso."
},
"event.update.completed": {
"message": "Il client NetBird è stato aggiornato automaticamente alla versione {version}."
},
"event.update.failed": {
"message": "Aggiornamento automatico non riuscito: {reason}"
},
"common.cancel": {
"message": "Annulla"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "NetBird の構成が IT ポリシーによって更新されました。"
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "情報"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "エラー"
},
"event.severity.critical": {
"message": "重大"
},
"event.category.network": {
"message": "ネットワーク"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "認証"
},
"event.category.connectivity": {
"message": "接続"
},
"event.category.system": {
"message": "システム"
},
"event.panic": {
"message": "NetBird サービスがクラッシュしました。サービスを再起動し、クライアントログを添えてバグを報告してください。"
},
"event.dns.recovered": {
"message": "DNS サーバーに再び到達できるようになりました。"
},
"event.dns.unreachable": {
"message": "1 つ以上の DNS サーバーに到達できません。一部のサービスへの接続に影響する可能性があります。"
},
"event.exitNode.connected": {
"message": "出口ノードに接続しました。"
},
"event.exitNode.disconnected": {
"message": "出口ノードの接続を解除しました。"
},
"event.exitNode.connectionLost": {
"message": "出口ノードとの接続が失われました。インターネット接続に影響する可能性があります。"
},
"event.exitNode.haChange": {
"message": "高可用性の変更により出口ノードの接続が解除されました。"
},
"event.exitNode.disconnectedUnknown": {
"message": "不明な理由により出口ノードの接続が解除されました。"
},
"event.update.installing": {
"message": "更新をインストールしています。"
},
"event.update.completed": {
"message": "NetBird クライアントがバージョン {version} に自動更新されました。"
},
"event.update.failed": {
"message": "自動更新に失敗しました: {reason}"
},
"common.cancel": {
"message": "キャンセル"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Informação"
},
"event.severity.warning": {
"message": "Aviso"
},
"event.severity.error": {
"message": "Erro"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Rede"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticação"
},
"event.category.connectivity": {
"message": "Conectividade"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "O serviço NetBird falhou de forma inesperada. Reinicie o serviço e envie um relatório de erro com os registros do cliente."
},
"event.dns.recovered": {
"message": "Os servidores DNS estão novamente acessíveis."
},
"event.dns.unreachable": {
"message": "Não é possível acessar um ou mais servidores DNS. Isto pode afetar a conexão a alguns serviços."
},
"event.exitNode.connected": {
"message": "Nó de saída conectado."
},
"event.exitNode.disconnected": {
"message": "Nó de saída desconectado."
},
"event.exitNode.connectionLost": {
"message": "Conexão com o nó de saída perdida. O seu acesso à Internet pode ser afetado."
},
"event.exitNode.haChange": {
"message": "Nó de saída desconectado devido a uma alteração de alta disponibilidade."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nó de saída desconectado por motivos desconhecidos."
},
"event.update.installing": {
"message": "Instalando a atualização agora."
},
"event.update.completed": {
"message": "O seu cliente NetBird foi atualizado automaticamente para a versão {version}."
},
"event.update.failed": {
"message": "Falha na atualização automática: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Информация"
},
"event.severity.warning": {
"message": "Предупреждение"
},
"event.severity.error": {
"message": "Ошибка"
},
"event.severity.critical": {
"message": "Критично"
},
"event.category.network": {
"message": "Сеть"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Аутентификация"
},
"event.category.connectivity": {
"message": "Связь"
},
"event.category.system": {
"message": "Система"
},
"event.panic": {
"message": "Служба NetBird аварийно завершилась. Перезапустите службу и отправьте отчёт об ошибке с журналами клиента."
},
"event.dns.recovered": {
"message": "DNS-серверы снова доступны."
},
"event.dns.unreachable": {
"message": "Не удалось связаться с одним или несколькими DNS-серверами. Это может повлиять на подключение к некоторым сервисам."
},
"event.exitNode.connected": {
"message": "Выходной узел подключён."
},
"event.exitNode.disconnected": {
"message": "Выходной узел отключён."
},
"event.exitNode.connectionLost": {
"message": "Соединение с выходным узлом потеряно. Доступ в интернет может быть нарушен."
},
"event.exitNode.haChange": {
"message": "Выходной узел отключён из-за изменения конфигурации высокой доступности."
},
"event.exitNode.disconnectedUnknown": {
"message": "Выходной узел отключён по неизвестной причине."
},
"event.update.installing": {
"message": "Устанавливается обновление."
},
"event.update.completed": {
"message": "Клиент NetBird автоматически обновлён до версии {version}."
},
"event.update.failed": {
"message": "Не удалось выполнить автоматическое обновление: {reason}"
},
"common.cancel": {
"message": "Отмена"
},
+63
View File
@@ -179,6 +179,69 @@
"notify.mdm.policyApplied.body": {
"message": "您的 NetBird 配置已根据 IT 策略更新。"
},
"event.title": {
"message": "{severity}:{category}"
},
"event.severity.info": {
"message": "信息"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "错误"
},
"event.severity.critical": {
"message": "严重"
},
"event.category.network": {
"message": "网络"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "身份验证"
},
"event.category.connectivity": {
"message": "连接"
},
"event.category.system": {
"message": "系统"
},
"event.panic": {
"message": "NetBird 服务发生崩溃。请重启该服务,并附上客户端日志提交错误报告。"
},
"event.dns.recovered": {
"message": "DNS 服务器已恢复可访问。"
},
"event.dns.unreachable": {
"message": "无法访问一个或多个 DNS 服务器。这可能影响您连接部分服务。"
},
"event.exitNode.connected": {
"message": "出口节点已连接。"
},
"event.exitNode.disconnected": {
"message": "出口节点已断开。"
},
"event.exitNode.connectionLost": {
"message": "与出口节点的连接已丢失。您的互联网访问可能受到影响。"
},
"event.exitNode.haChange": {
"message": "由于高可用性变更,出口节点已断开。"
},
"event.exitNode.disconnectedUnknown": {
"message": "出口节点因未知原因已断开。"
},
"event.update.installing": {
"message": "正在安装更新。"
},
"event.update.completed": {
"message": "NetBird 客户端已自动更新到版本 {version}。"
},
"event.update.failed": {
"message": "自动更新失败:{reason}"
},
"common.cancel": {
"message": "取消"
},
+28
View File
@@ -63,6 +63,20 @@ func (l *Localizer) T(key string, args ...string) string {
return l.bundle.Translate(lang, key, args...)
}
// Lookup resolves a key supplied at runtime by the daemon, substituting args as
// {placeholder}/value pairs. It reports false when the key is in no bundle, so
// the caller can fall back to the daemon's own English text instead of showing a
// bare key. An empty key never resolves.
func (l *Localizer) Lookup(key string, args map[string]string) (string, bool) {
if l == nil || l.bundle == nil || key == "" {
return "", false
}
l.mu.RLock()
lang := l.lang
l.mu.RUnlock()
return l.bundle.Lookup(lang, key, flattenArgs(args)...)
}
// Watch invokes cb on each language change, after the cached language is
// updated so cb may call l.T with the new locale. Replaces any prior subscription.
func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) {
@@ -128,3 +142,17 @@ func (l *Localizer) StatusLabel(status string) string {
}
return status
}
// flattenArgs turns a placeholder map into the flat name/value slice the bundle
// takes. Iteration order is irrelevant: each pair substitutes an independent
// {name}.
func flattenArgs(args map[string]string) []string {
if len(args) == 0 {
return nil
}
out := make([]string, 0, len(args)*2)
for name, value := range args {
out = append(out, name, value)
}
return out
}
+18 -7
View File
@@ -59,13 +59,21 @@ type Emitter interface {
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
type SystemEvent struct {
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
// MessageKey names the localizable body for this event; empty on control
// events and on events from a daemon that predates the field. Resolve it
// against the UI bundle and fall back to UserMessage on a miss.
MessageKey string `json:"messageKey"`
MessageArgs map[string]string `json:"messageArgs"`
// TitleKey names the localizable notification title, empty when the event
// has none and the consumer should compose one from severity and category.
TitleKey string `json:"titleKey"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
}
// PeerStatus is the frontend-facing shape of a daemon PeerState.
@@ -563,6 +571,9 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")),
Message: e.GetMessage(),
UserMessage: e.GetUserMessage(),
MessageKey: e.GetMessageKey(),
MessageArgs: e.GetMessageArgs(),
TitleKey: e.GetTitleKey(),
Metadata: map[string]string{},
}
if ts := e.GetTimestamp(); ts != nil {
-1
View File
@@ -26,7 +26,6 @@ const (
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDMDMPolicy = "netbird-mdm-policy"
statusError = "Error"
+34 -57
View File
@@ -21,32 +21,14 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if !ok {
return
}
// config_changed carries no UserMessage, so handle it before the message gate below.
// config_changed carries no user-facing message, so handle it before the gate below.
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey])
go t.refreshRestrictions()
go t.loadConfig()
// MDM gets a localised toast here; the daemon's English "policy_applied"
// event is suppressed in shouldSkipSystemEvent. Other sources stay silent.
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
t.profileMu.Lock()
enabled := t.notificationsEnabled
t.profileMu.Unlock()
if enabled {
t.notify(
t.loc.T("notify.mdm.policyApplied.title"),
t.loc.T("notify.mdm.policyApplied.body"),
notifyIDMDMPolicy,
)
}
}
return
}
// Session-warning and deadline-rejected events build their body locally from
// metadata; every other event needs a UserMessage.
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != ""
if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" {
if se.MessageKey == "" && se.UserMessage == "" {
return
}
if shouldSkipSystemEvent(se) {
@@ -61,56 +43,56 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
return
}
// Session-warning events route via stable metadata flags rather than
// category/severity so a daemon-side reword still lands here. Final warning
// auto-opens the SessionExpiration dialog with no notification (the dialog is
// the last-chance reminder; doubling up would be noise).
if isDeadlineRejected {
t.notify(
t.loc.T("notify.sessionDeadlineRejected.title"),
t.loc.T("notify.sessionDeadlineRejected.body"),
notifyIDSessionExpired,
)
return
}
body := t.localizedEventMessage(se)
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
// The final session warning auto-opens the SessionExpiration dialog instead of
// toasting: the dialog is the last-chance reminder and doubling up would be
// noise. This routes on metadata rather than the message key because it is a
// behavioural distinction, not a wording one.
if se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
t.openSessionExpiration()
return
}
t.notifySessionWarning(
t.loc.T("notify.sessionWarning.title"),
t.buildSessionWarningBody(se.Metadata),
)
t.notifySessionWarning(t.eventTitle(se), body)
return
}
body := se.UserMessage
if id := se.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
t.notify(t.eventTitle(se), body, notifyIDEvent+se.ID)
}
// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication".
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)
if prefix == "" {
prefix = "Info"
// localizedEventMessage resolves the daemon's message key against the active
// locale. A key this build does not ship — a daemon newer than the UI — falls
// back to the daemon's own English rendering rather than showing a bare key.
func (t *Tray) localizedEventMessage(se services.SystemEvent) string {
if body, ok := t.loc.Lookup(se.MessageKey, se.MessageArgs); ok {
return body
}
category := titleCase(e.Category)
if category == "" {
category = "System"
if se.MessageKey != "" {
log.Debugf("no translation for event message key %q, using the daemon's text", se.MessageKey)
}
return prefix + ": " + category
return se.UserMessage
}
func titleCase(s string) string {
if s == "" {
return ""
// eventTitle resolves the event's own title key, falling back to a title
// composed from severity and category, e.g. "Critical: DNS" in English. An enum
// value this build does not know falls back to the Info and System labels.
func (t *Tray) eventTitle(se services.SystemEvent) string {
if title, ok := t.loc.Lookup(se.TitleKey, nil); ok {
return title
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
severity, ok := t.loc.Lookup("event.severity."+strings.ToLower(se.Severity), nil)
if !ok {
severity = t.loc.T("event.severity.info")
}
category, ok := t.loc.Lookup("event.category."+strings.ToLower(se.Category), nil)
if !ok {
category = t.loc.T("event.category.system")
}
return t.loc.T("event.title", "severity", severity, "category", category)
}
// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as
@@ -119,11 +101,6 @@ func titleCase(s string) string {
// - install-progress signals (consumed by the install-progress window)
// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted)
func shouldSkipSystemEvent(se services.SystemEvent) bool {
// "policy_applied" carries a hardcoded English message; the localised toast
// fires on the paired config_changed (source=mdm) event instead.
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
return true
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return true
}
+150
View File
@@ -0,0 +1,150 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/services"
)
// trayWithLocalizer builds the minimum Tray the message/title resolvers touch:
// they read t.loc and nothing else, so no app, window or daemon connection is
// needed. The shipped locale tree is used so the assertions below exercise the
// real bundles rather than a fixture.
func trayWithLocalizer(t *testing.T) *Tray {
t.Helper()
bundle, err := i18n.NewBundle(os.DirFS("i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
return &Tray{loc: NewLocalizer(bundle, nil)}
}
func TestLocalizedEventMessageResolvesKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgExitNodeConnected),
// A daemon always ships its English rendering too; the key must win.
UserMessage: "should not be used",
})
assert.Equal(t, "Exit node connected.", got)
}
func TestLocalizedEventMessageSubstitutesArgs(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgUpdateCompleted),
MessageArgs: map[string]string{proto.ArgVersion: "0.60.1"},
})
assert.Equal(t, "Your NetBird client was auto-updated to version 0.60.1.", got)
}
// A daemon newer than the UI can publish a key this build has never heard of.
// Showing the raw key would be a visible regression, so the daemon's own English
// text has to win instead.
func TestLocalizedEventMessageFallsBackToDaemonText(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: "event.somethingThisBuildNeverHeardOf",
UserMessage: "A message from a newer daemon.",
})
assert.Equal(t, "A message from a newer daemon.", got)
}
// An old daemon sends no key at all, only userMessage.
func TestLocalizedEventMessageWithoutKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{UserMessage: "Legacy English text."})
assert.Equal(t, "Legacy English text.", got)
}
func TestEventTitlePrefersTitleKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.eventTitle(services.SystemEvent{
Severity: "critical",
Category: "authentication",
TitleKey: string(proto.TitleSessionWarning),
})
assert.Equal(t, "Session expires soon", got, "a title key must beat the composed title")
}
func TestEventTitleComposesFromSeverityAndCategory(t *testing.T) {
tray := trayWithLocalizer(t)
tests := []struct {
name string
severity string
category string
want string
}{
{"warning dns", "warning", "dns", "Warning: DNS"},
{"critical system", "critical", "system", "Critical: System"},
{"info network", "info", "network", "Info: Network"},
{"error authentication", "error", "authentication", "Error: Authentication"},
// Enum values this build does not know, and the empty severity/category
// an event carries before the daemon fills them in.
{"unknown severity", "apocalyptic", "dns", "Info: DNS"},
{"unknown category", "warning", "quantum", "Warning: System"},
{"empty", "", "", "Info: System"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tray.eventTitle(services.SystemEvent{Severity: tc.severity, Category: tc.category})
assert.Equal(t, tc.want, got)
})
}
}
func TestShouldSkipSystemEvent(t *testing.T) {
tests := []struct {
name string
ev services.SystemEvent
want bool
}{
{
name: "update announcement handled by the tray updater",
ev: services.SystemEvent{Metadata: map[string]string{"new_version_available": "0.60.1"}},
want: true,
},
{
name: "install progress belongs to the progress window",
ev: services.SystemEvent{Metadata: map[string]string{"progress_window": "show"}},
want: true,
},
{
name: "the v6 half of a dual-stack default route is already toasted as v4",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "::/0"}},
want: true,
},
{
name: "the v4 default route is the one that toasts",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "0.0.0.0/0"}},
want: false,
},
{
// policy_applied used to be suppressed here while the tray toasted
// off the paired config_changed event; it now carries its own keys.
name: "mdm policy applied surfaces normally",
ev: services.SystemEvent{
MessageKey: string(proto.UserMsgMDMPolicyApplied),
Metadata: map[string]string{proto.MetadataTypeKey: proto.MetadataTypePolicyApplied},
},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, shouldSkipSystemEvent(tc.ev))
})
}
}
-21
View File
@@ -10,8 +10,6 @@ import (
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
@@ -196,25 +194,6 @@ func (t *Tray) registerSessionWarningCategory() {
})
}
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
// Falls back to a generic string when metadata is missing or unparsable.
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
if meta == nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
raw := meta[authsession.MetaExpiresAt]
if raw == "" {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
deadline, err := authsession.ParseExpiresAt(raw)
if err != nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
}
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
func (t *Tray) notifySessionWarning(title, body string) {