Skip to content
Merged
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
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}

Expand Down
59 changes: 59 additions & 0 deletions electron/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.

import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";

type Locale = "en" | "zh-CN" | "es";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;

const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
};

let currentLocale: Locale = "en";

export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
currentLocale = locale;
}
}

export function getMainLocale(): Locale {
return currentLocale;
}

function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}

function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}

export function mainT(
namespace: Namespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[currentLocale]?.[namespace], key) ??
getMessageValue(messages.en?.[namespace], key);

if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
34 changes: 23 additions & 11 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type RecordingSession,
type StoreRecordedSessionInput,
} from "../../src/lib/recordingSession";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";

const PROJECT_FILE_EXTENSION = "openscreen";
Expand Down Expand Up @@ -472,11 +473,13 @@ export function registerIpcHandlers(
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: "GIF Image", extensions: ["gif"] }]
: [{ name: "MP4 Video", extensions: ["mp4"] }];
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];

const result = await dialog.showSaveDialog({
title: isGif ? "Save Exported GIF" : "Save Exported Video",
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
Expand Down Expand Up @@ -510,11 +513,14 @@ export function registerIpcHandlers(
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Video File",
title: mainT("dialogs", "fileDialogs.selectVideo"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] },
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
Expand Down Expand Up @@ -590,10 +596,13 @@ export function registerIpcHandlers(
: `${safeName}.${PROJECT_FILE_EXTENSION}`;

const result = await dialog.showSaveDialog({
title: "Save OpenScreen Project",
title: mainT("dialogs", "fileDialogs.saveProject"),
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
Expand Down Expand Up @@ -629,12 +638,15 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Open OpenScreen Project",
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
Expand Down
39 changes: 25 additions & 14 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
systemPreferences,
Tray,
} from "electron";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";

Expand Down Expand Up @@ -130,28 +131,28 @@ function setupApplicationMenu() {

template.push(
{
label: "File",
label: mainT("common", "actions.file") || "File",
submenu: [
{
label: "Load Project…",
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => sendEditorMenuAction("menu-load-project"),
},
{
label: "Save Project…",
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => sendEditorMenuAction("menu-save-project"),
},
{
label: "Save Project As…",
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
],
},
{
label: "Edit",
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
Expand All @@ -163,7 +164,7 @@ function setupApplicationMenu() {
],
},
{
label: "View",
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
Expand All @@ -177,7 +178,7 @@ function setupApplicationMenu() {
],
},
{
label: "Window",
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
Expand Down Expand Up @@ -215,7 +216,7 @@ function updateTrayMenu(recording: boolean = false) {
const menuTemplate = recording
? [
{
label: "Stop Recording",
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
Expand All @@ -225,13 +226,13 @@ function updateTrayMenu(recording: boolean = false) {
]
: [
{
label: "Open",
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: "Quit",
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
Expand Down Expand Up @@ -281,12 +282,16 @@ function createEditorWindowWrapper() {

const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: ["Save & Close", "Discard & Close", "Cancel"],
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: "Unsaved Changes",
message: "You have unsaved changes.",
detail: "Do you want to save your project before closing?",
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});

const windowToClose = mainWindow;
Expand Down Expand Up @@ -354,6 +359,12 @@ app.whenReady().then(async () => {
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
ipcMain.handle("set-locale", (_, locale: string) => {
setMainLocale(locale);
setupApplicationMenu();
updateTrayMenu();
});

createTray();
updateTrayMenu();
setupApplicationMenu();
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
Expand Down
82 changes: 82 additions & 0 deletions scripts/i18n-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares zh-CN and es against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/

import fs from "node:fs";
import path from "node:path";

const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es"];

function getKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}

let hasErrors = false;

const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
const namespaces = fs
.readdirSync(baseDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));

for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);

for (const locale of COMPARE_LOCALES) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);

if (!fs.existsSync(localePath)) {
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
hasErrors = true;
continue;
}

const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
const localeKeys = getKeys(localeData);

const missing = baseKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !baseKeys.includes(k));

if (missing.length > 0) {
console.error(`MISSING in ${locale}/${namespace}.json:`);
for (const key of missing) {
console.error(` - ${key}`);
}
hasErrors = true;
}

if (extra.length > 0) {
console.error(`EXTRA in ${locale}/${namespace}.json:`);
for (const key of extra) {
console.error(` + ${key}`);
}
hasErrors = true;
}
}
}

if (hasErrors) {
console.error("\ni18n check FAILED — translation files are out of sync.");
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
Loading
Loading