Skip to content
Open
1,000 changes: 620 additions & 380 deletions internal/dashboard/server.go

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions internal/i18n/detect_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !windows

package i18n

import "os"

// DetectLocale returns the best locale tag for the current OS session.
// On Linux/macOS it reads LANG, LC_ALL, or LC_MESSAGES, falling back to "en".
func DetectLocale() string {
for _, key := range []string{"LANG", "LC_ALL", "LC_MESSAGES"} {
if v := os.Getenv(key); v != "" {
// Strip encoding suffix: "ca_ES.UTF-8" -> "ca_ES" -> "ca"
return normaliseLang(v)
}
}
return "en"
}
40 changes: 40 additions & 0 deletions internal/i18n/detect_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//go:build windows

package i18n

import (
"os"
"syscall"
"unsafe"
)

var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetUserDefaultLocale = kernel32.NewProc("GetUserDefaultLocaleName")
)

// DetectLocale returns the best locale tag for the current OS session.
// On Windows it calls GetUserDefaultLocaleName, falling back to the LANG
// environment variable, and finally to "en".
func DetectLocale() string {
// Try Windows API first.
buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH
r, _, _ := procGetUserDefaultLocale.Call(
uintptr(unsafe.Pointer(&buf[0])),
uintptr(len(buf)),
)
if r > 0 {
name := syscall.UTF16ToString(buf)
if name != "" {
return normaliseLang(name)
}
}

// Fallback to environment.
for _, key := range []string{"LANG", "LC_ALL", "LC_MESSAGES"} {
if v := os.Getenv(key); v != "" {
return normaliseLang(v)
}
}
return "en"
}
126 changes: 126 additions & 0 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Package i18n provides lightweight internationalisation for Owlrun.
// Locale strings are stored as flat JSON files embedded at compile time.
// The package exposes a simple T(key) function and supports runtime locale
// switching with automatic fallback to English.
package i18n

import (
"embed"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
)

//go:embed locales/*.json
var localeFS embed.FS

var (
mu sync.RWMutex
current = "en"
messages = map[string]map[string]string{} // lang -> key -> value
)

func init() {
loadAll()
}

// loadAll reads every JSON file in locales/ into memory.
func loadAll() {
entries, err := localeFS.ReadDir("locales")
if err != nil {
log.Printf("i18n: failed to read embedded locales: %v", err)
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
lang := strings.TrimSuffix(e.Name(), ".json")
data, err := localeFS.ReadFile("locales/" + e.Name())
if err != nil {
log.Printf("i18n: skip %s: %v", e.Name(), err)
continue
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("i18n: invalid JSON in %s: %v", e.Name(), err)
continue
}
messages[lang] = m
}
}

// SetLocale changes the active locale. Unknown locales fall back to English.
func SetLocale(lang string) {
mu.Lock()
defer mu.Unlock()
lang = normaliseLang(lang)
if _, ok := messages[lang]; !ok {
lang = "en"
}
current = lang
}

// Locale returns the active locale tag.
func Locale() string {
mu.RLock()
defer mu.RUnlock()
return current
}

// T returns the localised string for key. If the key is missing in the
// current locale it falls back to English; if still missing it returns the
// key itself. Optional args are passed to fmt.Sprintf when present.
func T(key string, args ...any) string {
mu.RLock()
lang := current
mu.RUnlock()

if v, ok := messages[lang][key]; ok {
if len(args) > 0 {
return fmt.Sprintf(v, args...)
}
return v
}
// Fallback to English.
if v, ok := messages["en"][key]; ok {
if len(args) > 0 {
return fmt.Sprintf(v, args...)
}
return v
}
return key
}

// Locales returns the list of available locale tags.
func Locales() []string {
mu.RLock()
defer mu.RUnlock()
out := make([]string, 0, len(messages))
for k := range messages {
out = append(out, k)
}
return out
}

// Messages returns the full translation map for a locale (for serving
// to the dashboard JS). Falls back to English for unknown locales.
func Messages(lang string) map[string]string {
mu.RLock()
defer mu.RUnlock()
lang = normaliseLang(lang)
if m, ok := messages[lang]; ok {
return m
}
return messages["en"]
}

// normaliseLang converts "ca-ES" -> "ca", "pt_BR" -> "pt", etc.
func normaliseLang(lang string) string {
if i := strings.IndexAny(lang, "-_"); i > 0 {
lang = lang[:i]
}
return strings.ToLower(lang)
}
178 changes: 178 additions & 0 deletions internal/i18n/locales/ca.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
{
"_lang": "Catal\u00e0",

"tray.tooltip": "Owlrun \u2014 guanya amb la GPU ociosa",
"tray.earnings_today": "Guanys d'avui",
"tray.earnings_total": "Guanys totals",
"tray.wallet_warning": "\u26a0 Configura el teu moneder a ~/.owlrun/owlrun.conf",
"tray.toggle_tip": "Pausa o reprendre Owlrun",
"tray.open_dashboard": "Obrir Tauler",
"tray.open_dashboard_tip": "Obrir localhost:19131",
"tray.accept_jobs": "Acceptar Treballs",
"tray.job_never": "Mai",
"tray.job_never_tip": "No acceptar mai treballs",
"tray.job_idle_fmt": "Despr\u00e9s de %dm inactiu",
"tray.job_idle_tip": "Acceptar treballs despr\u00e9s del temps d'inactivitat",
"tray.job_always": "Sempre",
"tray.job_always_tip": "Acceptar treballs sempre",
"tray.quit": "Sortir",
"tray.quit_tip": "Tancar Owlrun",
"tray.state.earning": "\ud83d\udfe2 Guanyant",
"tray.state.idle": "\ud83d\udfe1 Inactiu \u2014 esperant",
"tray.state.ready": "\ud83d\udfe1 Preparant-se",
"tray.state.wallet": "\ud83d\udfe2 Moneder no configurat",
"tray.state.error": "\ud83d\udd34 Error",
"tray.state.paused": "\u26aa En pausa",
"tray.resume": "Reprendre",
"tray.pause": "Pausa",
"tray.legend.green": "\ud83d\udfe2 Verd \u2014 Connectat i guanyant",
"tray.legend.yellow": "\ud83d\udfe1 Groc \u2014 Preparant-se",
"tray.legend.blue": "\ud83d\udfe2 Blau \u2014 Moneder no configurat",
"tray.legend.red": "\ud83d\udd34 Vermell\u2014 Error",
"tray.legend.grey": "\u26aa Gris \u2014 En pausa",
"tray.disk_critical_fmt": "\u26a0 Nom\u00e9s %.1f GB lliures \u2014 desc\u00e0rregues bloquejades",
"tray.disk_warning_fmt": "\u26a0 Poc disc: %.1f GB lliures (%.0f%%)",
"tray.today_fmt": "Avui: $%.2f",
"tray.total_fmt": "Total: $%.2f",
"tray.err.mock_failed": "L'Ollama simulat no s'ha pogut iniciar: %s",
"tray.err.not_installed": "Ollama no est\u00e0 instal\u00b7lat. Descarrega'l a ollama.com/download, instal\u00b7la'l i reinicia Owlrun.",
"tray.err.start_failed": "Ollama no s'ha pogut iniciar. Assegura't que Ollama est\u00e0 instal\u00b7lat (ollama.com/download) i reinicia Owlrun.",
"tray.err.no_models": "No hi ha models d'IA instal\u00b7lats. Obre el tauler a localhost:19131 i descarrega un model, o executa: ollama pull qwen2.5:0.5b",
"tray.wallet_msg": "Configura la teva adre\u00e7a Lightning a la secci\u00f3 Moneder per comen\u00e7ar a guanyar Bitcoin.",
"tray.wallet_configured_fmt": "Moneder configurat a %s",

"dash.title": "Tauler d'Owlrun",
"dash.notifications": "Notificacions",
"dash.notifications_empty": "Les notificacions del gateway apareixeran aqu\u00ed.",
"dash.broadcasts_empty": "Les notificacions del gateway apareixeran aqu\u00ed.",
"dash.wallet": "Moneder",
"dash.wallet_not_configured": "Moneder no configurat",
"dash.wallet_setup_heading": "Configura el teu moneder per cobrar",
"dash.wallet_step1": "Instal\u00b7la el moneder <a href=\"https://www.minibits.cash/\" target=\"_blank\" style=\"color:#f7931a;text-decoration:underline\">Minibits</a> (de Bitango Technologies)",
"dash.wallet_step2_pre": "Troba la teva adre\u00e7a Lightning a Minibits (sembla ",
"dash.wallet_step2_post": ")",
"dash.wallet_step3": "Enganxa-la aqu\u00ed sota",
"dash.wallet_placeholder": "elteunom@minibits.cash",
"dash.wallet_save_btn": "Desar i comen\u00e7ar a guanyar",
"dash.wallet_saving": "Desant...",
"dash.wallet_any_wallet": "Funciona amb qualsevol moneder Lightning \u2014 Minibits, Phoenix, Wallet of Satoshi, etc.",
"dash.wallet_ln_address": "Adre\u00e7a Lightning",
"dash.wallet_change": "Canviar adre\u00e7a",
"dash.wallet_save": "Desar",
"dash.wallet_cancel": "Cancel\u00b7lar",
"dash.wallet_valid_error": "Introdueix una adre\u00e7a Lightning v\u00e0lida (p. ex. elteunon@minibits.cash)",
"dash.wallet_valid_error_short": "Introdueix una adre\u00e7a Lightning v\u00e0lida",
"dash.wallet_configured_banner": "\u26a1 Moneder configurat",
"dash.wallet_auto_sent": "Els guanys s'envien autom\u00e0ticament al teu moneder Lightning. No cal fer res.",
"dash.payout_threshold": "Llindar de pagament",
"dash.payout_threshold_hint": "M\u00e9s baix = pagaments m\u00e9s r\u00e0pids, comissions m\u00e9s altes. M\u00e9s alt = pagaments m\u00e9s lents, comissions m\u00e9s baixes.",
"dash.payout_fee_label": "Comissi\u00f3 Lightning est.:",
"dash.payout_unlock_50": "Desbloquejar m\u00ednim de 50 sats",
"dash.payout_low_warn": "\u26a0 ~10% consumit per comissions Lightning a aquest nivell",
"dash.accept_jobs": "Acceptar treballs",
"dash.job_always": "Sempre",
"dash.job_idle": "Quan inactiu",
"dash.job_never": "Mai",
"dash.job_hint_always": "Guanyant sempre que est\u00e0s connectat",
"dash.job_hint_idle": "Guanyant nom\u00e9s quan no hi ets",
"dash.job_hint_never": "No s'accepten treballs",
"dash.karma": "Karma",
"dash.karma_score": "Puntuaci\u00f3 de karma",
"dash.free_tier_jobs": "Treballs gratu\u00efts servits",
"dash.donate_free_tier": "Donar al nivell gratu\u00eft",
"dash.donate_hint": "Comparteix cicles ociosos amb usuaris gratu\u00efts. M\u00e9s donaci\u00f3 = m\u00e9s karma = m\u00e9s tr\u00e0fic de pagament cap a tu.",
"dash.keep_warm": "Mantenir model actiu",
"dash.keep_warm_hint": "Ping cada 4 min per evitar l'expulsi\u00f3 de VRAM",
"dash.context_length": "Longitud de context",
"dash.context_hint": "Valors m\u00e9s alts usen m\u00e9s VRAM. El model es recarrega en canviar.",
"dash.models": "Models",
"dash.downloading": "Descarregant\u2026",
"dash.show_slow_fmt": "Mostra %d models m\u00e9s grans (poden ser lents en aquesta m\u00e0quina)",
"dash.model_active": "ACTIU",
"dash.model_available": "DISPONIBLE",
"dash.model_activate": "Activar",
"dash.model_download": "Descarregar",
"dash.model_remove": "Eliminar model",
"dash.status": "Estat",
"dash.earnings": "Guanys",
"dash.pending_earnings": "Guanys pendents",
"dash.usd_approx": "USD aprox.",
"dash.today_earnings": "Guanys d'avui",
"dash.total_withdrawn": "Total retirat",
"dash.next_payout": "Pr\u00f2xim pagament est.",
"dash.payout_progress_fmt": "%d%% del llindar (%d sats)",
"dash.usd_disclaimer": "USD aproximat al preu BTC en temps real",
"dash.recent_payouts": "Pagaments recents",
"dash.fee_disclaimer": "Les comissions Lightning van a la xarxa Bitcoin, no a Owlrun. La nostra comissi\u00f3 sempre \u00e9s inferior al 10%.",
"dash.ecash_advanced": "Avan\u00e7at: Retirar com ecash (QR)",
"dash.ecash_local": "Ecash local",
"dash.ecash_proofs": "Proves",
"dash.ecash_claim_all": "Reclamar tot",
"dash.ecash_claiming": "Reclamant\u2026",
"dash.ecash_copied": "Copiat!",
"dash.ecash_copy": "Copiar",
"dash.ecash_recent": "Tokens recents:",
"dash.ecash_export_hint": "L'exportaci\u00f3 est\u00e0 disponible despr\u00e9s de reclamar. Usa Reclamar tot primer, despr\u00e9s copia el token.",
"dash.ecash_no_proofs": "No hi ha proves locals per exportar.",
"dash.ecash_claim_failed_fmt": "Reclamaci\u00f3 fallida: %s",
"dash.state.earning": "Connectat i guanyant",
"dash.state.ready": "Preparant-se",
"dash.state.idle": "Inactiu \u2014 esperant",
"dash.state.wallet": "Moneder no configurat",
"dash.state.error": "Error",
"dash.state.paused": "En pausa",
"dash.legend.earning": "Guanyant",
"dash.legend.ready": "Preparat",
"dash.legend.no_wallet": "Sense moneder",
"dash.legend.error": "Error",
"dash.legend.paused": "En pausa",
"dash.btc_price": "Preu del Bitcoin",
"dash.btc_live": "En directe",
"dash.btc_yesterday": "Fix d'ahir",
"dash.btc_24h": "Mitjana 24h",
"dash.btc_7d": "Mitjana 7d",
"dash.btc_status": "Estat",
"dash.btc_inactive": "Els pagaments en Bitcoin encara no estan actius en aquest gateway",
"dash.gw": "Gateway",
"dash.gw_connection": "Connexi\u00f3",
"dash.gw_connected": "Connectat",
"dash.gw_disconnected": "Desconnectat",
"dash.gw_jobs_today": "Treballs avui",
"dash.gw_tokens_today": "Tokens avui",
"dash.gw_queue": "Profunditat de cua",
"dash.gpu": "GPU",
"dash.gpu_none": "No s'ha detectat cap GPU",
"dash.gpu_util": "Utilitzaci\u00f3",
"dash.gpu_vram": "VRAM lliure",
"dash.gpu_temp": "Temperatura",
"dash.gpu_power": "Consum",
"dash.disk": "Disc",
"dash.disk_free": "Lliure",
"dash.disk_total": "Total",
"dash.disk_path": "Ruta",
"dash.updated_fmt": "actualitzat %s",
"dash.connection_lost": "connexi\u00f3 perduda\u2026",
"dash.charts_jobs": "Treballs",
"dash.charts_earnings": "Guanys (USD)",
"dash.charts_per_period": "Per per\u00edode",
"dash.charts_cumulative": "Acumulat",
"dash.confirm_remove_title": "Eliminar %s?",
"dash.confirm_remove_body": "Aix\u00f2 eliminar\u00e0 el model del disc i alliberar\u00e0 espai.\nEl pots tornar a descarregar m\u00e9s tard.",
"dash.confirm_switch_title": "Canviar el model actiu a %s?",
"dash.confirm_switch_body": "Aix\u00f2 recarregar\u00e0 el model a mem\u00f2ria i es tornar\u00e0 a registrar al gateway.",
"dash.confirm_download_title": "Descarregar %s?",
"dash.download_size_fmt": "Mida del model: ~%s GB",
"dash.download_size_est_fmt": "Mida del model: ~%s GB (estimat)",
"dash.download_disk_fmt": "Disc lliure: %s GB / %s GB total",
"dash.download_after_fmt": "Despr\u00e9s de la desc\u00e0rrega: ~%s%% usat",
"dash.download_time_hint": "Aix\u00f2 pot trigar uns minuts depenent de la teva connexi\u00f3.",
"dash.download_no_space_title": "No hi ha prou espai al disc!",
"dash.download_no_space_body": "Desc\u00e0rrega avortada \u2014 l'\u00fas del disc superaria el 90%.\nAllibera espai o elimina models no usats primer.",
"dash.download_starting": "Iniciant desc\u00e0rrega\u2026",
"dash.download_complete": "Desc\u00e0rrega completada!",
"dash.download_checking": "Comprovant\u2026",
"dash.sats_claimed_fmt": "%s sats reclamats",
"dash.settings": "Configuraci\u00f3",
"dash.model": "Model",
"dash.today": "Avui"
}
Loading