Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
937 changes: 937 additions & 0 deletions assets/data/i18n/en.json

Large diffs are not rendered by default.

937 changes: 937 additions & 0 deletions assets/data/i18n/ru.json

Large diffs are not rendered by default.

43 changes: 41 additions & 2 deletions base/api/endpoints_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"github.com/safing/portmaster/base/config"
"github.com/safing/portmaster/base/i18n"
)

func registerConfigEndpoints() error {
Expand All @@ -11,14 +12,52 @@ func registerConfigEndpoints() error {
MimeType: MimeTypeJSON,
StructFunc: listConfig,
Name: "Export Configuration Options",
Description: "Returns a list of all registered configuration options and their metadata. This does not include the current active or default settings.",
Description: "Returns a list of all registered configuration options and their metadata. This does not include the current active or default settings. Use ?lang=ru for localized names.",
}); err != nil {
return err
}

return nil
}

// LocalizedOption is an Option with localized Name and Description.
type LocalizedOption struct {
*config.Option
Name string `json:"Name"`
Description string `json:"Description"`
}

func listConfig(ar *Request) (i interface{}, err error) {
return config.ExportOptions(), nil
// Get language from query parameter
lang := ar.URL.Query().Get("lang")
if lang == "" {
lang = i18n.GetLanguage()
}

// Get original options
opts := config.ExportOptions()

// If English or no translations, return as-is
if lang == "en" || lang == "" {
return opts, nil
}

// Set language for translation
i18n.SetLanguage(lang)
Comment on lines +31 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't change the process-wide language from a request handler.

i18n.SetLanguage(lang) mutates global state, so concurrent /config/options?lang=ru and /config/options?lang=en requests can bleed into each other and change unrelated responses. Please resolve translations through a request-scoped API instead of flipping the global language here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@base/api/endpoints_config.go` around lines 31 - 46, The handler must not call
i18n.SetLanguage (which mutates global state); instead obtain translations using
a request-scoped API and leave process-wide language untouched. Replace the
i18n.SetLanguage(lang) call with a request-local translation step such as
calling a new or existing function that accepts the target language and the
options (e.g., i18n.TranslateOptions(lang, opts) or
i18n.WithLanguage(lang).TranslateOptions(opts)), or pass lang into the
exporter/translator so config.ExportOptions() output is translated into a copy
for this request; keep using i18n.GetLanguage() only to default lang when
missing and do not mutate global i18n state.


// Create localized copies
localizedOpts := make([]*LocalizedOption, len(opts))
for idx, opt := range opts {
// Get translated name and description with fallback to original
name := i18n.GetConfigName(opt.Key, opt.Name)
desc := i18n.GetConfigDescription(opt.Key, opt.Description)

localizedOpts[idx] = &LocalizedOption{
Option: opt,
Name: name,
Description: desc,
}
}

return localizedOpts, nil
}
7 changes: 7 additions & 0 deletions base/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"time"

"github.com/safing/portmaster/base/i18n"
"github.com/safing/portmaster/service/mgr"
)

Expand All @@ -23,6 +24,12 @@ func init() {
}

func prep() error {
// Initialize i18n system
if err := i18n.Init(); err != nil {
// Log warning but don't fail - translations are optional
_ = err
}

// Register endpoints.
if err := registerConfig(); err != nil {
return err
Expand Down
156 changes: 156 additions & 0 deletions base/i18n/i18n.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Package i18n provides internationalization support for the Portmaster.
package i18n

import (
"embed"
"encoding/json"
"fmt"
"sync"

"github.com/safing/portmaster/base/log"
)

//go:embed translations/*.json
var translationsFS embed.FS

// Translation represents a single translation entry.
type Translation struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category,omitempty"`
}

// Translations holds all translations for a language.
type Translations struct {
Language string `json:"language"`
Config map[string]Translation `json:"config"`
UI map[string]string `json:"ui"`
}

var (
currentLang = "en"
translations = make(map[string]*Translations)
translationsMu sync.RWMutex
initialized bool
)

// SupportedLanguages returns list of supported languages.
var SupportedLanguages = []string{"en", "ru"}

// Init initializes the i18n system.
func Init() error {
translationsMu.Lock()
defer translationsMu.Unlock()

if initialized {
return nil
}

for _, lang := range SupportedLanguages {
data, err := translationsFS.ReadFile(fmt.Sprintf("translations/%s.json", lang))
if err != nil {
log.Warningf("i18n: failed to load translations for %s: %s", lang, err)
continue
}

var t Translations
if err := json.Unmarshal(data, &t); err != nil {
log.Warningf("i18n: failed to parse translations for %s: %s", lang, err)
continue
}

t.Language = lang
translations[lang] = &t
log.Infof("i18n: loaded %d config translations for %s", len(t.Config), lang)
}

initialized = true
return nil
}

// SetLanguage sets the current language.
func SetLanguage(lang string) error {
translationsMu.Lock()
defer translationsMu.Unlock()

if _, ok := translations[lang]; !ok {
return fmt.Errorf("i18n: language %s not supported", lang)
}

currentLang = lang
log.Infof("i18n: language set to %s", lang)
return nil
}

// GetLanguage returns the current language.
func GetLanguage() string {
translationsMu.RLock()
defer translationsMu.RUnlock()
return currentLang
}

// T returns translated string for UI key.
func T(key string) string {
translationsMu.RLock()
defer translationsMu.RUnlock()

if t, ok := translations[currentLang]; ok {
if val, ok := t.UI[key]; ok {
return val
}
}

// Fallback to English
if t, ok := translations["en"]; ok {
if val, ok := t.UI[key]; ok {
return val
}
}

return key
}

// GetConfigTranslation returns translation for a config key.
func GetConfigTranslation(key string) *Translation {
translationsMu.RLock()
defer translationsMu.RUnlock()

if t, ok := translations[currentLang]; ok {
if val, ok := t.Config[key]; ok {
return &val
}
}

// Fallback to English
if t, ok := translations["en"]; ok {
if val, ok := t.Config[key]; ok {
return &val
}
}

return nil
}

// GetConfigName returns translated name for a config key.
func GetConfigName(key, fallback string) string {
if t := GetConfigTranslation(key); t != nil && t.Name != "" {
return t.Name
}
return fallback
}

// GetConfigDescription returns translated description for a config key.
func GetConfigDescription(key, fallback string) string {
if t := GetConfigTranslation(key); t != nil && t.Description != "" {
return t.Description
}
return fallback
}

// GetConfigCategory returns translated category for a config key.
func GetConfigCategory(key, fallback string) string {
if t := GetConfigTranslation(key); t != nil && t.Category != "" {
return t.Category
}
return fallback
}
Loading