Skip to content
Closed
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
12 changes: 7 additions & 5 deletions electron/src/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import electron, { Menu, dialog } from 'electron'
import path from 'path'
import AutoUpdater from './AutoUpdater'
import TrayMenu from './TrayMenu'
import { t, setLanguage } from './i18n'
import {
EVENTS,
PROTOCOL,
Expand Down Expand Up @@ -35,6 +36,7 @@ export default class ElectronApp {
this.isMaximized = false
this.autoUpdater = new AutoUpdater()
this.protocol = PROTOCOL.substring(0, PROTOCOL.length - 3)
setLanguage(preferences.get().language)

if (!this.app.requestSingleInstanceLock()) {
Logger.warn('ANOTHER APP INSTANCE IS RUNNING. EXITING.')
Expand Down Expand Up @@ -79,8 +81,8 @@ export default class ElectronApp {
if (!this.quitSelected && !this.errorShown) {
this.errorShown = true
dialog.showErrorBox(
`${brand.appName} Failed to Start`,
`The app could not start because another instance is already running. Please close any other ${brand.appName} processes, or restart your computer.`
t('dialog.failedToStartTitle', { appName: brand.appName }),
t('dialog.failedToStartMessage', { appName: brand.appName })
)
}

Expand Down Expand Up @@ -166,9 +168,9 @@ export default class ElectronApp {
if (!this.window) return

const result = await dialog.showOpenDialog(this.window, {
title: 'Find application',
message: 'Select the application location',
buttonLabel: 'Select',
title: t('dialog.findApplicationTitle'),
message: t('dialog.findApplicationMessage'),
buttonLabel: t('dialog.findApplicationButton'),
})

let filePath = result?.filePaths[0]
Expand Down
36 changes: 21 additions & 15 deletions electron/src/TrayMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import headless, {
getApplication,
Logger,
brand,
preferences,
} from './backend'
import { t, setLanguage } from './i18n'

const MAX_MENU_SIZE = 10
const MAX_UPDATE_CHECKS = 16
Expand All @@ -37,11 +39,15 @@ export default class TrayMenu {
})
}

setLanguage(preferences.get().language)
this.render()

EventBus.on(User.EVENTS.signedIn, this.render)
EventBus.on(User.EVENTS.signedOut, this.render)
EventBus.on(ConnectionPool.EVENTS.pool, this.updatePool)
EventBus.on(EVENTS.preferences, ({ language }: IPreferences) => {
if (setLanguage(language)) this.render()
})
}

private render = () => {
Expand All @@ -59,20 +65,20 @@ export default class TrayMenu {
private remoteitMenu() {
return [
{
label: `Open ${brand.appName}...`,
label: t('tray.open', { appName: brand.appName }),
type: 'normal',
click: () => this.handleOpen(),
},
{
label: user.username,
submenu: [
{
label: 'Sign out',
label: t('tray.signOut'),
type: 'normal',
click: () => EventBus.emit(EVENTS.signOut),
},
{
label: 'Quit',
label: t('tray.quit'),
type: 'normal',
click: electron.app.quit,
},
Expand All @@ -86,8 +92,8 @@ export default class TrayMenu {
private connectionsMenu() {
let menu = []
const enabled = this.pool.filter(c => c.enabled)
if (enabled.length) menu.push({ label: 'Connections', enabled: false }, ...this.connectionsList(enabled))
return menu.length ? menu : [{ label: 'No connections', enabled: false }]
if (enabled.length) menu.push({ label: t('tray.connections'), enabled: false }, ...this.connectionsList(enabled))
return menu.length ? menu : [{ label: t('tray.noConnections'), enabled: false }]
}

private connectionsList(list: IConnection[]) {
Expand All @@ -104,36 +110,36 @@ export default class TrayMenu {
submenu: [
connection.enabled
? connection.connected
? { label: 'Stop connection', click: () => this.disconnect(connection) }
: { label: 'Remove from network', click: () => this.remove(connection) }
? { label: t('tray.stopConnection'), click: () => this.disconnect(connection) }
: { label: t('tray.removeFromNetwork'), click: () => this.remove(connection) }
: connection.online
? { label: 'Add to network', click: () => this.connect(connection) }
: { label: 'Offline', enabled: false },
? { label: t('tray.addToNetwork'), click: () => this.connect(connection) }
: { label: t('tray.offline'), enabled: false },
{ type: 'separator' },
{ label: hostName(connection), enabled: false },
{ label: 'Copy to clipboard', click: () => this.copy(connection) },
{ label: t('tray.copyToClipboard'), click: () => this.copy(connection) },
connection.online
? { label: 'Launch', enabled: connection.enabled, click: () => this.launch(connection) }
: { label: 'Remove', click: () => EventBus.emit(EVENTS.clear, connection) },
? { label: t('tray.launch'), enabled: connection.enabled, click: () => this.launch(connection) }
: { label: t('tray.remove'), click: () => EventBus.emit(EVENTS.clear, connection) },
],
})
}
return result
}, [])
if (more) menu.push({ label: `and ${more} more...`, click: () => this.handleOpen('connections') })
if (more) menu.push({ label: t('tray.andMore', { count: more }), click: () => this.handleOpen('connections') })
return menu
}

private signInMenu() {
return [
{ label: brand.appName, enabled: false },
{
label: 'Sign in...',
label: t('tray.signIn'),
type: 'normal',
click: () => this.handleOpen(),
},
{
label: 'Quit',
label: t('tray.quit'),
type: 'normal',
click: electron.app.quit,
},
Expand Down
32 changes: 32 additions & 0 deletions electron/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Minimal i18n for the Electron main process (tray, menus, native dialogs).
// The renderer owns language selection and syncs the resolved code to the main
// process via the `language` field on IPreferences (see EVENTS.preferences).
// A full i18next instance is unnecessary here — this is a handful of flat strings.
import en from './locales/en.json'
import ja from './locales/ja.json'
import de from './locales/de.json'
import es from './locales/es.json'

type Bundle = typeof en
const BUNDLES: { [code: string]: Bundle } = { en, ja, de, es }
const FALLBACK = 'en'

let current = FALLBACK

export function setLanguage(code?: string) {
const resolved = code && BUNDLES[code] ? code : FALLBACK
const changed = resolved !== current
current = resolved
return changed
}

// Dotted key lookup with {{var}} interpolation, falling back to English then the key.
export function t(key: string, vars?: { [k: string]: string | number }): string {
const lookup = (bundle: Bundle) =>
key.split('.').reduce<any>((node, part) => (node == null ? undefined : node[part]), bundle)
let value = lookup(BUNDLES[current])
if (typeof value !== 'string') value = lookup(BUNDLES[FALLBACK])
if (typeof value !== 'string') return key
if (vars) for (const [k, v] of Object.entries(vars)) value = value.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v))
return value
}
25 changes: 25 additions & 0 deletions electron/src/locales/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"tray": {
"open": "{{appName}} öffnen...",
"signOut": "Abmelden",
"quit": "Beenden",
"connections": "Verbindungen",
"noConnections": "Keine Verbindungen",
"stopConnection": "Verbindung trennen",
"removeFromNetwork": "Aus Netzwerk entfernen",
"addToNetwork": "Zum Netzwerk hinzufügen",
"offline": "Offline",
"copyToClipboard": "In Zwischenablage kopieren",
"launch": "Starten",
"remove": "Entfernen",
"andMore": "und {{count}} weitere...",
"signIn": "Anmelden..."
},
"dialog": {
"failedToStartTitle": "{{appName}} konnte nicht gestartet werden",
"failedToStartMessage": "Die App konnte nicht gestartet werden, da bereits eine andere Instanz läuft. Bitte schließen Sie alle anderen {{appName}}-Prozesse oder starten Sie Ihren Computer neu.",
"findApplicationTitle": "Anwendung suchen",
"findApplicationMessage": "Wählen Sie den Speicherort der Anwendung",
"findApplicationButton": "Auswählen"
}
}
25 changes: 25 additions & 0 deletions electron/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"tray": {
"open": "Open {{appName}}...",
"signOut": "Sign out",
"quit": "Quit",
"connections": "Connections",
"noConnections": "No connections",
"stopConnection": "Stop connection",
"removeFromNetwork": "Remove from network",
"addToNetwork": "Add to network",
"offline": "Offline",
"copyToClipboard": "Copy to clipboard",
"launch": "Launch",
"remove": "Remove",
"andMore": "and {{count}} more...",
"signIn": "Sign in..."
},
"dialog": {
"failedToStartTitle": "{{appName}} Failed to Start",
"failedToStartMessage": "The app could not start because another instance is already running. Please close any other {{appName}} processes, or restart your computer.",
"findApplicationTitle": "Find application",
"findApplicationMessage": "Select the application location",
"findApplicationButton": "Select"
}
}
25 changes: 25 additions & 0 deletions electron/src/locales/es.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"tray": {
"open": "Abrir {{appName}}...",
"signOut": "Cerrar sesión",
"quit": "Salir",
"connections": "Conexiones",
"noConnections": "Sin conexiones",
"stopConnection": "Detener conexión",
"removeFromNetwork": "Quitar de la red",
"addToNetwork": "Agregar a la red",
"offline": "Sin conexión",
"copyToClipboard": "Copiar al portapapeles",
"launch": "Iniciar",
"remove": "Quitar",
"andMore": "y {{count}} más...",
"signIn": "Iniciar sesión..."
},
"dialog": {
"failedToStartTitle": "No se pudo iniciar {{appName}}",
"failedToStartMessage": "La aplicación no pudo iniciarse porque ya hay otra instancia en ejecución. Cierre cualquier otro proceso de {{appName}} o reinicie su equipo.",
"findApplicationTitle": "Buscar aplicación",
"findApplicationMessage": "Seleccione la ubicación de la aplicación",
"findApplicationButton": "Seleccionar"
}
}
25 changes: 25 additions & 0 deletions electron/src/locales/ja.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"tray": {
"open": "{{appName}}を開く...",
"signOut": "サインアウト",
"quit": "終了",
"connections": "接続",
"noConnections": "接続なし",
"stopConnection": "接続を停止",
"removeFromNetwork": "ネットワークから削除",
"addToNetwork": "ネットワークに追加",
"offline": "オフライン",
"copyToClipboard": "クリップボードにコピー",
"launch": "起動",
"remove": "削除",
"andMore": "他{{count}}件...",
"signIn": "サインイン..."
},
"dialog": {
"failedToStartTitle": "{{appName}}を起動できませんでした",
"failedToStartMessage": "別のインスタンスがすでに実行されているため、アプリを起動できませんでした。他の{{appName}}プロセスを終了するか、コンピューターを再起動してください。",
"findApplicationTitle": "アプリケーションを検索",
"findApplicationMessage": "アプリケーションの場所を選択してください",
"findApplicationButton": "選択"
}
}
28 changes: 28 additions & 0 deletions frontend/i18next-parser.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Extracts t()/<Trans> keys from source into the catalogs so translators always
// have an up-to-date source of truth. Run: npm run i18n:extract
//
// Convention: give every key its English text as the inline default, e.g.
// t('options.language.label', 'Language')
// The parser writes that default into the English catalog and leaves ja/de/es
// empty for translators. `npm run i18n:check` fails CI on any missing key or
// empty English value.
export default {
locales: ['en', 'ja', 'de', 'es'],
defaultNamespace: 'app',
namespaceSeparator: ':',
keySeparator: '.',
input: ['src/**/*.{ts,tsx}'],
output: 'src/i18n/locales/$LOCALE/$NAMESPACE.json',
sort: true,
// Never auto-prune: some keys are built dynamically (e.g. cognito error keys)
// and the parser can't see them statically. `npm run i18n:check` reports dead
// keys so they can be removed deliberately.
keepRemoved: true,
// English keeps the inline default text; other locales stay empty until translated.
defaultValue: (locale, _ns, _key, value) => (locale === 'en' ? value?.usageContext?.defaultValue ?? '' : ''),
createOldCatalogs: false,
lexers: {
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
}
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"scripts": {
"start": "vite --port 3003 --host 0.0.0.0 --open",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"i18n:extract": "i18next -c i18next-parser.config.js",
"i18n:check": "node scripts/i18n-check.mjs"
},
"dependencies": {
"@airbrake/browser": "^2.1.9",
Expand Down Expand Up @@ -45,6 +47,7 @@
"humanize-duration": "^3.33.0",
"i18next": "^22.5.0",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-resources-to-backend": "^1.2.1",
"immer": "^9.0.21",
"localforage": "^1.10.0",
"lodash.debounce": "^4.0.8",
Expand Down Expand Up @@ -92,6 +95,7 @@
"@types/validator": "^13.12.2",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^8.53.0",
"i18next-parser": "^9.4.0",
"typescript": "^5.9.2",
"vite": "^6.4.3"
},
Expand Down
Loading
Loading