diff --git a/electron/ipc/register/permissions.test.ts b/electron/ipc/register/permissions.test.ts
index 208cd2d7..60866734 100644
--- a/electron/ipc/register/permissions.test.ts
+++ b/electron/ipc/register/permissions.test.ts
@@ -42,6 +42,31 @@ it("does not prompt or open settings merely by registering permission handlers",
expect(mocks.open).not.toHaveBeenCalled();
});
+it.each([
+ "mailto:support@recordly.dev",
+ "mailto:youngchen3442@gmail.com",
+])("opens the fixed contact address %s in the default mail client", async (url) => {
+ expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toEqual({
+ success: true,
+ });
+ expect(mocks.open).toHaveBeenCalledExactlyOnceWith(url);
+});
+
+it.each([
+ "mailto:other@example.com",
+ "mailto:support@recordly.dev?bcc=other@example.com",
+ "mailto:support@recordly.dev.evil.test",
+ "mailto:youngchen3442@gmail.com?bcc=other@example.com",
+ "mailto:youngchen3442@gmail.com.evil.test",
+ "file:///tmp/example",
+ "custom:example",
+])("blocks unsupported external URL %s", async (url) => {
+ expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toMatchObject({
+ success: false,
+ });
+ expect(mocks.open).not.toHaveBeenCalled();
+});
+
it("reports microphone and camera status without requesting access", async () => {
mocks.status.mockImplementation((device: string) =>
device === "microphone" ? "denied" : "not-determined",
diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts
index 4ae5b48e..f1a093de 100644
--- a/electron/ipc/register/permissions.ts
+++ b/electron/ipc/register/permissions.ts
@@ -20,9 +20,14 @@ export function registerPermissionHandlers() {
ipcMain.handle("close-permission-accessory", ({ sender }) => dismissPermissionAccessory(sender));
ipcMain.handle("open-external-url", async (_, url: string) => {
try {
- // Security: only allow http/https URLs to prevent file:// or custom protocol abuse
+ // Only the two displayed contact addresses may launch a mail client; other protocols stay blocked.
const parsed = new URL(url);
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ if (
+ parsed.protocol !== "https:" &&
+ parsed.protocol !== "http:" &&
+ url !== "mailto:support@recordly.dev" &&
+ url !== "mailto:youngchen3442@gmail.com"
+ ) {
return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` };
}
await shell.openExternal(url);
diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx
index 599d4fea..6d31b903 100644
--- a/src/components/video-editor/TutorialHelp.tsx
+++ b/src/components/video-editor/TutorialHelp.tsx
@@ -1,4 +1,4 @@
-import { GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react";
+import { EnvelopeSimple, GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { UpgradeButton } from "@/components/billing/UpgradeButton";
import { Button } from "@/components/ui/button";
@@ -111,43 +111,86 @@ export function CommunityLinks() {
truncateLabel
className="h-8 w-full min-w-0 px-1.5 text-xs"
/>
-
+
+
+
+
);
}
diff --git a/tests/ui/settings-fixed-sidebar.spec.ts b/tests/ui/settings-fixed-sidebar.spec.ts
index f88db9bc..fb7e506c 100644
--- a/tests/ui/settings-fixed-sidebar.spec.ts
+++ b/tests/ui/settings-fixed-sidebar.spec.ts
@@ -167,6 +167,13 @@ for (const { width, height, theme } of [
const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true });
const socials = home.getByRole("button", { name: "Socials", exact: true });
await expect(socials).toBeInViewport({ ratio: 1 });
+ const contact = home.getByRole("button", { name: "Contact", exact: true });
+ await expect(contact).toBeInViewport({ ratio: 1 });
+ const socialsBox = (await socials.boundingBox())!;
+ const contactBox = (await contact.boundingBox())!;
+ expect(contactBox.y).toBe(socialsBox.y);
+ expect(contactBox.x).toBeGreaterThanOrEqual(socialsBox.x + socialsBox.width);
+
await expect(home.getByRole("button", { name: "YouTube", exact: true })).toHaveCount(0);
await socials.click();
const links = page.getByRole("dialog", { name: "Socials", exact: true });
@@ -196,6 +203,23 @@ for (const { width, height, theme } of [
await expect(socials).toBeFocused();
await expect(home).toBeVisible();
await home.locator("aside").screenshot({ path: test.info().outputPath(`footer-${theme}.png`) });
+ await contact.click();
+ const contactDialog = page.getByRole("dialog", { name: "Contact", exact: true });
+ await expect(contactDialog).toBeVisible();
+ for (const email of ["support@recordly.dev", "youngchen3442@gmail.com"]) {
+ const emailButton = contactDialog.getByRole("button", { name: email, exact: true });
+ await expect(emailButton).toBeInViewport({ ratio: 1 });
+ await emailButton.focus();
+ await page.keyboard.press("Enter");
+ await expect(page.locator("html")).toHaveAttribute("data-opened-community-url", `mailto:${email}`);
+ await expect(page).toHaveURL(url);
+ }
+ await contactDialog.screenshot({ path: test.info().outputPath(`contact-${theme}.png`) });
+ await page.keyboard.press("Escape");
+ await expect(contactDialog).toBeHidden();
+ await expect(contact).toBeFocused();
+ await expect(home).toBeVisible();
+
});
}
@@ -240,7 +264,7 @@ test("Japanese community footer fits Upgrade and Socials without overlap", async
}
boxes.push(box);
}
- await expect(footer.getByRole("button")).toHaveCount(2);
+ await expect(footer.getByRole("button")).toHaveCount(3);
const socials = footer.getByRole("button", { name: "SNS", exact: true });
await expect(socials).toBeVisible();
await socials.click();