Sei decisioni che mantengono gestibile un piccolo livello di i18n in Electron
Un design pratico per l'i18n in Electron: preferenze di locale, cataloghi tipizzati, comportamento di fallback, formattazione con Intl e sincronizzazione dal renderer al main process.
12 min di letturaGuideDi Temposhot Team
Un'app desktop con cinque o dieci lingue raramente richiede un grande framework di i18n. Servono però alcune regole chiare, perché un'app Electron ha due processi che mostrano entrambi del testo: il renderer disegna l'interfaccia, mentre il main process gestisce menu, finestre di dialogo e notifiche. Le sei decisioni seguenti mantengono corretto un piccolo livello di i18n scritto a mano, man mano che l'app e i suoi cataloghi crescono.
1. Salva una preferenza, non solo una locale
Salvare "de" quando l'utente sceglie il tedesco funziona, finché non serve che l'app segua la lingua del sistema operativo. Salva quindi la preferenza dell'utente e risolvi la locale effettiva a runtime:
export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;
const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };
export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
if (pref !== "system") return pref;
for (const tag of systemLanguages) {
const lower = tag.toLowerCase();
const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
if (exact) return exact;
if (ALIASES[lower]) return ALIASES[lower];
}
return "en";
}
Nel main process, app.getPreferredSystemLanguages() restituisce la lista ordinata delle lingue del sistema operativo. Con "system" salvato, un utente che cambia la lingua del sistema operativo ottiene la nuova lingua al successivo avvio, senza modificare le impostazioni dell'app.
Il resolver scorre l'intera lista delle preferenze, non solo il primo elemento. Un utente con "portoghese, poi inglese" ottiene l'inglese perché è la sua seconda scelta, non perché è il default. Il matching per prefisso associa de-CH al tedesco e zh-Hans-CN al cinese semplificato. zh-TW non corrisponde a nessuna lingua, quindi passa alla preferenza successiva invece di diventare silenziosamente cinese semplificato.
Ordine di risoluzione
La funzione restituisce il primo match. L'ordine dei controlli è importante, perché un locale salvato non deve mai essere sovrascritto dalla lista di sistema.
Quando due voci della lista di sistema sono entrambe supportate, vince la voce precedente. macOS mantiene la lista nell'ordine impostato dall'utente in Language & Region, quindi la prima voce supportata è la prima scelta supportata dall'utente.
Tre preferenze salvate in pratica
La tabella seguente mostra come la stessa funzione tratta tre utenti. Ogni riga è una chiamata a resolveLocale con un valore salvato e una lista del sistema operativo.
L'utente 1 parla francese come prima lingua, ma l'app non include il francese. Il ciclo salta fr-FR, trova de-DE alla seconda voce e restituisce il tedesco. L'utente 3 ottiene l'inglese per un motivo diverso: zh-TW non è un alias e non corrisponde per prefisso al cinese semplificato, quindi il ciclo arriva a en-GB e restituisce l'inglese. L'utente 2 non legge mai la lista di sistema, perché il valore salvato è una locale e non "system".
Valori salvati dopo la rimozione di una lingua
Una release successiva può rimuovere una lingua. Il valore salvato non compare più in SUPPORTED e la ricerca nel catalogo non trova alcun file corrispondente. Quando le impostazioni vengono caricate, sostituisci i valori salvati sconosciuti con "system" e riscrivi il valore corretto una sola volta, così il file salvato corrisponde al codice. L'utente mantiene una lingua funzionante al primo avvio dopo l'aggiornamento.
2. Mantieni leggibili le scelte di lingua dopo una selezione sbagliata
Se un utente seleziona il giapponese per errore e tutti i nomi delle lingue vengono mostrati in giapponese, diventa difficile ritrovare "English". Mostra ogni lingua nella propria lingua (il suo endonimo), così l'elenco resta leggibile qualunque sia la lingua dell'interfaccia:
export const LANGUAGE_NAMES: Record<Locale, string> = {
en: "English",
de: "Deutsch",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
};
Intl.DisplayNames può generare questi nomi, ma il suo output per le varianti di script cambia tra motori e versioni. Una tabella fissa di sei stringhe è più facile da verificare in code review. Solo la voce "System default" viene tradotta nel catalogo, perché è una frase e non un nome di lingua.
La stessa regola in Temposhot
L'app Temposhot per Mac usa lo stesso schema degli endonimi per il menu Language. Il menu si trova in Settings, nella scheda General, e contiene sette voci. La guida indica che ogni nome di lingua è scritto nella propria lingua.
L'ultima riga riguarda direttamente il lettore di questo articolo. L'app salva la scelta e cambia subito la locale. Parte del testo dell'interfaccia resta in inglese dopo il cambio, finché le traduzioni delle altre sei lingue non vengono rilasciate.
Ordine del menu
Il menu segue l'ordine di SUPPORTED, con l'inglese per primo. Un ordine fisso è semplice da testare e non cambia quando un traduttore modifica un nome. Ordinare per endonimo produrrebbe un ordine che dipende dalle regole di collation di ogni script, più difficile da verificare in una pull request.
Aggiungere una locale significa aggiungerla in fondo a SUPPORTED. Il tipo Record<Locale, string> di LANGUAGE_NAMES fa fallire la build finché la nuova voce non ha un nome, quindi una locale non può comparire nel resolver senza un'etichetta visibile nel menu.
Controlla il menu prima di una release
Un menu delle lingue si rompe in modi piccoli che un unit test non rileva: un nome mostrato con lo script sbagliato, una voce mancante o una voce che non viene salvata. Un controllo manuale breve prima di ogni release li individua.
Esegui il controllo su un Mac la cui lingua di sistema non è l'inglese. Un controllo su un sistema in inglese dimostra solo che il percorso in inglese funziona.
3. Usa il catalogo inglese come contratto TypeScript
Rendi l'inglese la fonte di verità e ricava il tipo da esso:
export const en = {
"settings.language": "Language",
"settings.language.system": "System default",
"capture.count": "{count} screenshots",
} as const;
export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;
Ogni chiamata a t("settings.language") ora viene controllata dal compilatore. Un errore di battitura o una chiave rimossa dall'inglese fanno fallire la build. I traduttori lavorano sugli altri cataloghi, ma nessuno può aggiungere una chiave che manca nell'inglese, quindi il file inglese elenca sempre tutto ciò che usa l'interfaccia.
Le chiavi piatte con punti mantengono semplice il tipo e rendono leggibili i diff. Anche gli oggetti annidati funzionano, ma richiedono tipi ricorsivi e rendono più difficili i report sulle chiavi mancanti.
Chi modifica quale file
Gli ingegneri modificano en.ts. I traduttori modificano un file per ogni locale e non cambiano en.ts. Una pull request che aggiunge una chiave solo a en.ts supera comunque il compilatore, perché gli altri cataloghi sono tipizzati come parziali. Il test di completezza elenca poi le chiavi mancanti per ogni locale, dando al team un unico punto dove vedere le traduzioni aperte per una release.
Aggiungere una chiave
Una nuova chiave tocca quattro punti. L'ordine seguente mantiene utile il compilatore fin dal primo passo.
L'ultimo passo è quello che i team saltano più spesso. Le stringhe in tedesco e finlandese sono spesso più lunghe dell'inglese, quindi un'etichetta di pulsante che sta in inglese può andare a capo o venire tagliata. Il compilatore e i test non lo vedono, quindi serve comunque un controllo visivo delle stringhe più lunghe.
Struttura della cartella dei cataloghi
La cartella contiene un file per locale e gli helper che li leggono. Una struttura piatta mantiene brevi gli import e rende evidente quale file modifica un traduttore.
Solo en.ts esporta l'oggetto as const. Gli altri file di catalogo importano Partial<Catalog> da index.ts, quindi un file di traduzione non può definire una chiave che l'inglese non ha.
Denominazione delle chiavi
Inizia ogni chiave con la schermata o l'area che la usa. settings.language e capture.count indicano dove si trova una stringa senza aprire il codice, e una ricerca per settings. elenca ogni stringa di quella schermata. Evita chiavi che sono frasi in inglese. Una chiave come "Language" costringe a cambiare anche la chiave quando il testo viene riformulato.
Una chiave per significato
Una chiave segue il significato di una frase, non il numero di punti in cui viene mostrata. L'app di esempio mostra un conteggio delle catture nell'intestazione dei risultati, nella barra di stato e nella finestra di esportazione. Tutti e tre usano capture.count, con {count} riempito dal chiamante. Un traduttore corregge quindi la formulazione una sola volta per tutti e tre i punti, mentre una seconda chiave con lo stesso testo inglese finirebbe per divergere nel tempo.
4. Mantieni il fallback a runtime e il controllo di completezza
Altri cataloghi arrivano spesso come JSON da uno strumento di traduzione, quindi il compilatore non può garantirli. Tipizzali come Partial<Catalog> e usa l'inglese come fallback per chiave a runtime:
let current: Partial<Catalog> = {};
export function setCatalog(next: Partial<Catalog>) {
current = next;
}
export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
const template = current[key] ?? en[key];
return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}
Il fallback mantiene utilizzabile una release quando una stringa arriva in ritardo. Però nasconde il vuoto, quindi aggiungi un test che fallisce quando un locale distribuito è incompleto. Servono entrambi: il fallback protegge gli utenti, il test protegge il team dal distribuire fallback per errore. La sezione sui test mostra il controllo.
Come appare una stringa mancante
L'esempio seguente usa un catalogo tedesco interrotto prima che il traduttore finisse la schermata delle impostazioni. I numeri provengono dall'esempio, non da una build distribuita.
L'utente vede l'inglese su tre etichette e il tedesco nel resto. Una chiave grezza come settings.language sullo schermo sarebbe un esito peggiore, e il fallback lo impedisce perché l'inglese definisce sempre ogni chiave.
Le variabili mancanti restano visibili
La funzione t lascia {name} nell'output quando vars non contiene un valore per quel nome. L'app continua a funzionare, e chi revisiona vede il segnaposto non riempito in uno screenshot o in un test fallito. Il segnaposto è più facile da trovare di una stringa vuota silenziosa, e non impedisce al resto della schermata di essere visualizzato.
Testa il percorso di fallback
Un fallback che non viene mai eseguito nei test può rompersi senza che nessuno se ne accorga. Un test breve con un catalogo vuoto copre il percorso:
it("falls back to English for a missing key", () => {
setCatalog({});
expect(t("settings.language")).toBe("Language");
});
Chiama setCatalog con il catalogo reale negli altri test, così ogni test parte da uno stato noto.
Il test di completezza deve leggere la lista delle locale da SUPPORTED, non dai file presenti su disco. Un file presente ma non elencato non può quindi essere distribuito per errore, e una locale elencata senza file fallisce subito il test.
Il fallback agisce per chiave
Il fallback si applica a ogni chiave, non a un'intera schermata. Un catalogo tedesco con 40 chiavi su 42 delle impostazioni tradotte mostra l'inglese per le due etichette mancanti e il tedesco per le altre 40. Un fallback per schermata mostrerebbe 40 stringhe finite in inglese a causa di due mancanti, un risultato peggiore per l'utente.
5. Formatta date e numeri al di fuori del catalogo dei messaggi
I cataloghi devono contenere frasi, non regole di formattazione. Date, numeri, dimensioni dei file e plurali vengono da Intl, usando la locale risolta:
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);
Una stringa del catalogo contiene quindi un segnaposto, come {date} o {count}, e il chiamante formatta il valore prima di passarlo. Così i traduttori non devono occuparsi dei pattern di data, e un utente inglese degli Stati Uniti vede "Mar 2, 2026" mentre un utente tedesco vede "02.03.2026" senza che nessuno scriva l'uno o l'altro pattern. Le forme del plurale variano da una lingua all'altra, quindi scegli la variante del messaggio con Intl.PluralRules invece di usare count === 1.
Le categorie del plurale variano per lingua
Intl.PluralRules restituisce una delle sei categorie possibili: zero, one, two, few, many e other. L'inglese ne usa due, il russo quattro. Un controllo count === 1 copre l'inglese e non funziona per il russo.
Il catalogo contiene una variante per ogni categoria usata dalla locale. L'helper legge plural.select(count) e cerca la chiave corrispondente, come capture.count.one o capture.count.few. Una categoria mancante ricade su other, il che mantiene utilizzabile un plurale tradotto solo in parte.
Le unità fanno parte del valore
Intl.NumberFormat gestisce i separatori delle migliaia e i decimali, quindi 1.2 MB in inglese e 1,2 MB in tedesco arrivano dallo stesso percorso di codice. L'unità fa parte del valore formattato, non del catalogo. Una voce come "{size} saved" riceve la dimensione formattata dal chiamante, e il traduttore modifica solo la frase attorno al valore.
L'output esatto delle date dipende dal runtime
L'output di Intl.DateTimeFormat segue i dati ICU del runtime, quindi la stringa esatta può cambiare tra versioni di Electron. Un test che fissa "Mar 2, 2026, 9:05 AM" fallisce dopo un aggiornamento per un motivo che non riguarda l'app. Confronta l'output con il risultato della stessa chiamata Intl nel test e imposta un fuso orario fisso, così il test passa su ogni macchina.
6. Sincronizza la scelta del renderer con il main process
La schermata delle impostazioni è nel renderer, ma menu nativi, finestre di dialogo, tray e notifiche vengono creati nel main process. Rendi il main process l'unico responsabile della preferenza:
// preload.ts
contextBridge.exposeInMainWorld("i18n", {
setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
onChange: (cb: (locale: Locale) => void) =>
ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});
// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
if (!isPreference(pref)) throw new Error("invalid language preference");
store.set("language", pref);
const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
rebuildMenu(locale);
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
return locale;
});
Il renderer chiede, il main process decide, salva e trasmette. Ogni finestra, incluse quelle aperte in seguito, legge la stessa locale risolta. L'handler verifica il valore ricevuto, perché l'input IPC non è attendibile.
Salvare la scelta nel localStorage del renderer è una scorciatoia comune, perché la schermata delle impostazioni gira lì. Il valore finisce però nello storage di una sola finestra, e il main process non può leggerlo prima che una finestra si apra. Il menu e il tray vengono costruiti prima della prima finestra, quindi hanno bisogno del valore dallo store del main process.
Il percorso di una modifica della lingua
Una singola modifica attraversa entrambi i processi. La sequenza seguente mostra quale lato gestisce ogni passo.
Il renderer non risolve da sé "system". Se ogni processo risolvesse la preferenza separatamente, una finestra potrebbe mostrare una lingua diversa dal menu dopo un cambio della lingua del sistema operativo. Un solo resolver nel main process mantiene tutto identico.
Valida il valore nell'handler
Il preload tipizza l'argomento, ma il renderer può inviare qualsiasi valore tramite ipcRenderer.invoke. Il main process controlla il valore rispetto alla stessa lista da cui deriva il tipo:
const isPreference = (value: unknown): value is LanguagePreference =>
value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));
Un valore rifiutato genera un'eccezione e lo store non viene scritto. Solo i valori delle due liste possono raggiungere store.set.
Finestre aperte dopo la modifica
Una finestra creata dopo la modifica non deve attendere una trasmissione. Al caricamento chiede il valore al main process:
ipcMain.handle("i18n:get", () =>
resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);
Il renderer chiama i18n:get una volta all'avvio e usa i18n:changed dopo. Entrambi i percorsi chiamano lo stesso resolver, quindi una finestra nuova e una vecchia non possono essere in disaccordo.
Finestre chiuse durante una modifica
Una finestra chiusa prima della trasmissione non compare più in BrowserWindow.getAllWindows(), quindi il ciclo la salta. Una finestra che si ricarica chiama di nuovo i18n:get e riceve il valore corrente. La trasmissione è best effort, e il percorso di caricamento porta il valore corretto.
Test che proteggono i confini
- Tabella del resolver. Input come
["de-CH", "en-US"](tedesco),["zh-TW", "en"](inglese) e[](inglese) producono le locale attese. - Completezza. Per ogni locale distribuito, ogni chiave di
enesiste e non è vuota. - Parità dei segnaposto. Ogni traduzione usa gli stessi segnaposto dell'inglese, quindi
{count}non viene mai eliminato o rinominato. - Formattazione. L'output delle date viene verificato con un fuso orario fisso, così i test passano su ogni macchina.
- Handler IPC. Una preferenza non valida viene rifiutata, mentre una valida provoca la ricostruzione del menu e una trasmissione.
for (const [locale, catalog] of Object.entries(catalogs)) {
for (const key of Object.keys(en) as MessageKey[]) {
expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
}
}
Ogni test copre un confine. La tabella seguente indica cosa segnala un test che fallisce.
| Test | Un fallimento significa |
|---|---|
| Tabella del resolver | È cambiato l'ordine dei controlli o la tabella degli alias |
| Completezza | Un catalogo distribuito non ha una chiave che l'inglese definisce |
| Parità dei segnaposto | Una traduzione ha rinominato o rimosso una variabile |
| Formattazione | L'output di data o numero dipende dalla macchina, non dalla locale |
| Handler IPC | Un input non valido ha raggiunto lo store, oppure una finestra non è stata avvisata |
Esegui la suite a ogni modifica di un catalogo, non solo prima di una release. Una traduzione che rinomina un segnaposto si corregge con meno costi nella pull request che la introduce, mentre il taglio della release è il punto più costoso per scoprirla.
Un piccolo livello di i18n richiede confini chiari
Ogni decisione traccia una linea: intento contro locale risolta, nomi contro etichette tradotte, inglese come contratto, fallback contro completezza, frasi contro formattazione, renderer contro main process. Se mantieni queste linee, un livello scritto a mano di qualche centinaio di righe resta più facile da ragionare di un framework configurato per fare lo stesso.
Un framework diventa conveniente quando i cataloghi richiedono funzioni che il livello non ha, come l'estrazione dei messaggi dal codice sorgente, strumenti per i traduttori che tracciano lo stato di revisione o il caricamento a runtime di cataloghi distribuiti separatamente dall'app. Finché non esiste una di queste esigenze, le sei decisioni sopra coprono i problemi più comuni.
Gli stessi confini valgono per altre finestre che mostrano testo, come una web view o un foglio delle impostazioni. Ognuna legge la locale risolta dal main process, usa lo stesso tipo di catalogo e supera lo stesso test di completezza. Una nuova superficie non richiede quindi nuove regole, solo una nuova chiamata a t.
Temposhot, l'app per Mac a cui è dedicato questo sito, è un'app macOS nativa costruita con Xcode, non un'app Electron. La sua impostazione Language offre 7 opzioni: System default più sei lingue mostrate con il proprio nome.
Trasforma i clic in una guida passo passo chiara
Per documentare una modifica delle impostazioni come quella sopra, registrala con Action Capture. Temposhot è gratuito: puoi scaricarlo per Mac con Apple Silicon e macOS 14 o successivo.
electroni18ntypescriptengineering
Trasforma gli screenshot ripetibili in lavoro completato
Temposhot scatta screenshot a intervalli regolari o a ogni clic ed esporta il risultato come PDF, immagini, ZIP o MP4.
Post correlati
- Automatizzare gli screenshot su Mac dalla riga di comandoUsa i loop di screencapture di macOS per screenshot a schermo intero, su un'area fissa e con cambio pagina, con nomi di file sicuri e risoluzione dei problemi.
- Come automatizzare gli screenshot di siti web con PlaywrightUsa Playwright per automatizzare gli screenshot di siti web con JavaScript. Cattura una pagina, pagine intere, viste mobile e batch di URL con retry.
- Come automatizzare gli screenshot di siti web con PuppeteerUsa Puppeteer per automatizzare gli screenshot di siti web con JavaScript. Cattura una pagina, pagine intere, batch di URL e viewport mobile con uno script riutilizzabile.
- Migliori alternative a Xnapper per Mac nel 2026Confronta le alternative a Xnapper per Mac: acquisizione ripetuta, acquisizione e registrazione a scorrimento, sfondi gratuiti, guide multipiattaforma e l'app integrata.