diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts index dc4a287ee86..38b10d17d0e 100644 --- a/apps/chrome-extension/e2e/overlay-ui.spec.ts +++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts @@ -346,9 +346,7 @@ test("clicking X in the panel closes the panel and the camera preview", async () const panelFrame = frameWithUrl(targetPage, "popup.html"); if (!panelFrame) throw new Error("panel frame missing"); - await panelFrame - .locator('button[aria-label="Close Cap and hide all recorder UI"]') - .click(); + await panelFrame.getByTestId("close-recorder-ui").click(); // The panel and the camera preview should both tear down. await expect @@ -419,7 +417,7 @@ test("a failed recording start reopens the panel with the error", async () => { .toBe(true); const panelFrame = frameWithUrl(targetPage, "popup.html"); if (!panelFrame) throw new Error("panel frame missing"); - await expect(panelFrame.getByText("Recording failed.")).toBeVisible({ + await expect(panelFrame.getByTestId("recording-start-error")).toBeVisible({ timeout: 10_000, }); await targetPage.screenshot({ diff --git a/apps/chrome-extension/public/_locales/en/messages.json b/apps/chrome-extension/public/_locales/en/messages.json new file mode 100644 index 00000000000..9534de3b6d5 --- /dev/null +++ b/apps/chrome-extension/public/_locales/en/messages.json @@ -0,0 +1,17 @@ +{ + "actionDefaultTitle": { + "message": "Record your screen with Cap" + }, + "extensionDescription": { + "message": "Free, open source screen recorder. Capture your screen, tab, camera & mic in Chrome and share a video link the moment you stop." + }, + "extensionName": { + "message": "Cap - Screen Recorder & Screen Capture" + }, + "extensionShortName": { + "message": "Cap" + }, + "offscreenJustification": { + "message": "Record and upload Cap videos from an extension page." + } +} diff --git a/apps/chrome-extension/public/_locales/zh_CN/messages.json b/apps/chrome-extension/public/_locales/zh_CN/messages.json new file mode 100644 index 00000000000..52a44647182 --- /dev/null +++ b/apps/chrome-extension/public/_locales/zh_CN/messages.json @@ -0,0 +1,17 @@ +{ + "actionDefaultTitle": { + "message": "使用 Cap 录制屏幕" + }, + "extensionDescription": { + "message": "免费开源的屏幕录制工具。在 Chrome 中录制屏幕、标签页、摄像头和麦克风,停止录制后即可分享视频链接。" + }, + "extensionName": { + "message": "Cap - 屏幕录制与截图" + }, + "extensionShortName": { + "message": "Cap" + }, + "offscreenJustification": { + "message": "从扩展页面录制并上传 Cap 视频。" + } +} diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json index eeb058f457c..3a6b6a850fc 100644 --- a/apps/chrome-extension/public/manifest.json +++ b/apps/chrome-extension/public/manifest.json @@ -1,8 +1,9 @@ { "manifest_version": 3, - "name": "Cap - Screen Recorder & Screen Capture", - "short_name": "Cap", - "description": "Free, open source screen recorder. Capture your screen, tab, camera & mic in Chrome and share a video link the moment you stop.", + "name": "__MSG_extensionName__", + "short_name": "__MSG_extensionShortName__", + "description": "__MSG_extensionDescription__", + "default_locale": "en", "version": "1.0.2", "homepage_url": "https://cap.so", "minimum_chrome_version": "116", @@ -13,7 +14,7 @@ "128": "icons/icon-128.png" }, "action": { - "default_title": "Record your screen with Cap", + "default_title": "__MSG_actionDefaultTitle__", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 43eccb06c54..7133794f03e 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -5,6 +5,7 @@ import { parseAuthResponse, revokeAuth, } from "../shared/api"; +import { msg } from "../shared/i18n"; import { isRecordingStatusBroadcast, isServiceWorkerRequest, @@ -219,7 +220,7 @@ const createOffscreenDocument = () => { url: OFFSCREEN_URL, reasons: ["USER_MEDIA", "DISPLAY_MEDIA", "BLOBS", "AUDIO_PLAYBACK"], - justification: "Record and upload Cap videos from an extension page.", + justification: msg("offscreenJustification"), }, () => { const error = chrome.runtime.lastError; diff --git a/apps/chrome-extension/src/popup/components/recorder-header.tsx b/apps/chrome-extension/src/popup/components/recorder-header.tsx index 3b6d92e130c..8b2294c114e 100644 --- a/apps/chrome-extension/src/popup/components/recorder-header.tsx +++ b/apps/chrome-extension/src/popup/components/recorder-header.tsx @@ -42,6 +42,7 @@ export const RecorderHeader = ({ : "cursor-pointer transition-transform hover:scale-110", )} aria-label="Close Cap and hide all recorder UI" + data-testid="close-recorder-ui" > )} {status.phase === "error" && ( -
+
Recording failed.{" "} {status.message}
diff --git a/apps/chrome-extension/src/shared/i18n.test.ts b/apps/chrome-extension/src/shared/i18n.test.ts new file mode 100644 index 00000000000..54c20b985dc --- /dev/null +++ b/apps/chrome-extension/src/shared/i18n.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import englishMessages from "../../public/_locales/en/messages.json"; +import chineseMessages from "../../public/_locales/zh_CN/messages.json"; +import { type MessageKey, msg } from "./i18n"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("extension messages", () => { + it("keeps locale catalogs aligned", () => { + expect(Object.keys(chineseMessages).sort()).toEqual( + Object.keys(englishMessages).sort(), + ); + }); + + it("uses the browser locale when a translation is available", () => { + const getMessage = vi.fn(() => "使用 Cap 录制屏幕"); + vi.stubGlobal("chrome", { i18n: { getMessage } }); + + expect(msg("actionDefaultTitle")).toBe("使用 Cap 录制屏幕"); + expect(getMessage).toHaveBeenCalledWith("actionDefaultTitle", undefined); + }); + + it("falls back to English outside an extension context", () => { + vi.stubGlobal("chrome", undefined); + + expect(msg("actionDefaultTitle")).toBe("Record your screen with Cap"); + }); + + it("falls back to English when Chrome i18n is unavailable", () => { + vi.stubGlobal("chrome", {}); + + expect(msg("actionDefaultTitle")).toBe("Record your screen with Cap"); + }); + + it("falls back to English when Chrome cannot resolve a message", () => { + vi.stubGlobal("chrome", { + i18n: { getMessage: vi.fn(() => "") }, + }); + + expect(msg("offscreenJustification")).toBe( + "Record and upload Cap videos from an extension page.", + ); + }); + + it("applies substitutions to the English fallback", () => { + const originalMessage = englishMessages.actionDefaultTitle.message; + vi.stubGlobal("chrome", undefined); + + try { + englishMessages.actionDefaultTitle.message = "Record $1"; + expect(msg("actionDefaultTitle", "your screen")).toBe( + "Record your screen", + ); + + englishMessages.actionDefaultTitle.message = "Record $1 with $2"; + expect(msg("actionDefaultTitle", ["your screen", "Cap"])).toBe( + "Record your screen with Cap", + ); + } finally { + englishMessages.actionDefaultTitle.message = originalMessage; + } + }); + + it("returns the key when the English fallback is missing", () => { + vi.stubGlobal("chrome", undefined); + + expect(msg("missingMessage" as MessageKey)).toBe("missingMessage"); + }); +}); diff --git a/apps/chrome-extension/src/shared/i18n.ts b/apps/chrome-extension/src/shared/i18n.ts new file mode 100644 index 00000000000..7bff04c1ca6 --- /dev/null +++ b/apps/chrome-extension/src/shared/i18n.ts @@ -0,0 +1,22 @@ +import englishMessages from "../../public/_locales/en/messages.json"; + +export type MessageKey = keyof typeof englishMessages & string; + +export const msg = (key: MessageKey, substitutions?: string | string[]) => { + const localizedMessage = + typeof chrome === "undefined" || + typeof chrome.i18n?.getMessage !== "function" + ? "" + : chrome.i18n.getMessage(key, substitutions); + + if (localizedMessage) return localizedMessage; + + const fallback = englishMessages[key]?.message || key; + if (!substitutions) return fallback; + + const values = Array.isArray(substitutions) ? substitutions : [substitutions]; + return fallback.replace( + /\$(\d+)/g, + (_match, index: string) => values[Number(index) - 1] ?? "", + ); +};