= {
+ "sourceSelector.defaultSourceName": "Screen",
+ "recording.selectSource": "Please select a source to record",
+ "tooltips.useVerticalTray": "Use vertical tray",
+ "tooltips.useHorizontalTray": "Use horizontal tray",
+ "audio.enableSystemAudio": "Enable system audio",
+ "audio.disableSystemAudio": "Disable system audio",
+ "audio.enableMicrophone": "Enable microphone",
+ "audio.disableMicrophone": "Disable microphone",
+ "audio.defaultMicrophone": "Default Microphone",
+ "webcam.enableWebcam": "Enable webcam",
+ "webcam.disableWebcam": "Disable webcam",
+ "webcam.defaultCamera": "Default Camera",
+ "webcam.searching": "Searching...",
+ "webcam.noneFound": "No camera found",
+ "webcam.unavailable": "Camera unavailable",
+ "cursor.useEditableCursor": "Use editable cursor",
+ "cursor.useSystemCursor": "Use system cursor",
+ "tooltips.openStudio": "Open Studio",
+ "tooltips.hideHUD": "Hide HUD",
+ "tooltips.closeApp": "Close App",
+ language: "Language",
+ };
+ return translations[key] ?? key;
+ },
+}));
+
+function renderLaunchWindow() {
+ return render(
+
+
+ ,
+ );
+}
+
+function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) {
+ window.electronAPI = {
+ ...window.electronAPI,
+ getSelectedSource,
+ openSourceSelector: vi.fn(async () => ({ opened: true })),
+ requestScreenAccess: vi.fn(async () => ({
+ success: true,
+ granted: true,
+ status: "granted",
+ })),
+ getPlatform: vi.fn(async () => "darwin"),
+ setHudOverlaySize: vi.fn(),
+ setHudOverlayIgnoreMouseEvents: vi.fn(),
+ moveHudOverlayBy: vi.fn(),
+ hudOverlayHide: vi.fn(),
+ hudOverlayClose: vi.fn(),
+ switchToEditor: vi.fn(async () => undefined),
+ onSelectedSourceChanged: vi.fn((callback) => {
+ selectedSourceChangedListeners.push(callback);
+ return () => {
+ selectedSourceChangedListeners = selectedSourceChangedListeners.filter(
+ (listener) => listener !== callback,
+ );
+ };
+ }),
+ onSourceSelectorClosed: vi.fn((callback) => {
+ sourceSelectorClosedListeners.push(callback);
+ return () => {
+ sourceSelectorClosedListeners = sourceSelectorClosedListeners.filter(
+ (listener) => listener !== callback,
+ );
+ };
+ }),
+ } as typeof window.electronAPI;
+}
+
+const displayOneSource = {
+ id: "screen:1:0",
+ name: "Display 1",
+ display_id: "1",
+ thumbnail: null,
+ appIcon: null,
+} satisfies ProcessedDesktopSource;
+
+async function waitForSourceSelectionSubscription() {
+ await waitFor(() => {
+ expect(selectedSourceChangedListeners.length).toBeGreaterThan(0);
+ });
+}
+
+function emitSelectedSourceChanged(source: ProcessedDesktopSource) {
+ act(() => {
+ selectedSourceChangedListeners.forEach((listener) => listener(source));
+ });
+}
+
+function emitSourceSelectorClosed() {
+ act(() => {
+ sourceSelectorClosedListeners.forEach((listener) => listener());
+ });
+}
+
+describe("LaunchWindow record button", () => {
+ beforeEach(() => {
+ class ResizeObserver {
+ observe() {
+ return undefined;
+ }
+ unobserve() {
+ return undefined;
+ }
+ disconnect() {
+ return undefined;
+ }
+ }
+ vi.stubGlobal("ResizeObserver", ResizeObserver);
+ recorderState.value.toggleRecording.mockClear();
+ selectedSourceChangedListeners = [];
+ sourceSelectorClosedListeners = [];
+ stubElectronAPI(vi.fn(async () => null));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("opens the source selector instead of disabling the primary action when no source is selected", async () => {
+ renderLaunchWindow();
+
+ const recordButton = await screen.findByTestId("launch-record-button");
+
+ expect(recordButton).toBeEnabled();
+ expect(recordButton).toHaveAttribute("title", "Please select a source to record");
+
+ fireEvent.click(recordButton);
+
+ await waitFor(() => {
+ expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1);
+ });
+ expect(recorderState.value.toggleRecording).not.toHaveBeenCalled();
+ });
+
+ it("records immediately after source selection when the record button opened the picker", async () => {
+ renderLaunchWindow();
+ await waitForSourceSelectionSubscription();
+
+ fireEvent.click(await screen.findByTestId("launch-record-button"));
+ emitSelectedSourceChanged(displayOneSource);
+
+ await waitFor(() => {
+ expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1);
+ });
+ expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1");
+ });
+
+ it("does not record after manual source selection", async () => {
+ renderLaunchWindow();
+ await waitForSourceSelectionSubscription();
+
+ emitSelectedSourceChanged(displayOneSource);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1");
+ });
+ expect(recorderState.value.toggleRecording).not.toHaveBeenCalled();
+ });
+
+ it("clears record-after-selection intent when the source picker closes without a selection", async () => {
+ renderLaunchWindow();
+ await waitForSourceSelectionSubscription();
+
+ fireEvent.click(await screen.findByTestId("launch-record-button"));
+ emitSourceSelectorClosed();
+ emitSelectedSourceChanged(displayOneSource);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1");
+ });
+ expect(recorderState.value.toggleRecording).not.toHaveBeenCalled();
+ });
+
+ it("starts recording when a source is already selected", async () => {
+ stubElectronAPI(vi.fn(async () => displayOneSource));
+
+ renderLaunchWindow();
+
+ const recordButton = await screen.findByTestId("launch-record-button");
+ await waitFor(() => {
+ expect(recordButton).toHaveAttribute("title", "Display 1");
+ });
+
+ fireEvent.click(recordButton);
+
+ expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1);
+ expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index a93514c5a..373aeac55 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -420,21 +420,31 @@ export function LaunchWindow() {
setHudMouseEventsEnabled(isLanguageMenuOpen);
}, [isLanguageMenuOpen, setHudMouseEventsEnabled]);
- const [selectedSource, setSelectedSource] = useState("Screen");
+ const defaultSourceName = t("sourceSelector.defaultSourceName");
+ const [selectedSource, setSelectedSource] = useState(defaultSourceName);
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [, setRecordPointerDownCount] = useState(0);
+ const recordAfterSourceSelectionRef = useRef(false);
+
+ const applySelectedSource = useCallback(
+ (source: ProcessedDesktopSource | null) => {
+ if (source) {
+ setSelectedSource(source.name);
+ setHasSelectedSource(true);
+ return;
+ }
+
+ setSelectedSource(defaultSourceName);
+ setHasSelectedSource(false);
+ },
+ [defaultSourceName],
+ );
useEffect(() => {
const checkSelectedSource = async () => {
if (window.electronAPI) {
const source = await window.electronAPI.getSelectedSource();
- if (source) {
- setSelectedSource(source.name);
- setHasSelectedSource(true);
- } else {
- setSelectedSource("Screen");
- setHasSelectedSource(false);
- }
+ applySelectedSource(source);
}
};
@@ -442,15 +452,51 @@ export function LaunchWindow() {
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
- }, []);
+ }, [applySelectedSource]);
+
+ useEffect(() => {
+ const cleanupSourceChanged = window.electronAPI?.onSelectedSourceChanged?.((source) => {
+ applySelectedSource(source);
+ if (!recordAfterSourceSelectionRef.current || recording) {
+ return;
+ }
+
+ recordAfterSourceSelectionRef.current = false;
+ toggleRecording();
+ });
+ const cleanupSelectorClosed = window.electronAPI?.onSourceSelectorClosed?.(() => {
+ recordAfterSourceSelectionRef.current = false;
+ });
+
+ return () => {
+ cleanupSourceChanged?.();
+ cleanupSelectorClosed?.();
+ };
+ }, [applySelectedSource, recording, toggleRecording]);
const openSourceSelector = async () => {
if (window.electronAPI) {
- await openSourceSelectorWithPermissionRetry({
+ return await openSourceSelectorWithPermissionRetry({
openSourceSelector: () => window.electronAPI.openSourceSelector(),
requestScreenAccess: () => window.electronAPI.requestScreenAccess(),
});
}
+
+ return { opened: false, reason: "electron-api-unavailable" };
+ };
+
+ const handleRecordButtonClick = () => {
+ if (!hasSelectedSource && !recording) {
+ recordAfterSourceSelectionRef.current = true;
+ void openSourceSelector().then((result) => {
+ if (!result.opened) {
+ recordAfterSourceSelectionRef.current = false;
+ }
+ });
+ return;
+ }
+
+ toggleRecording();
};
const sendHudOverlayHide = () => {
@@ -844,32 +890,41 @@ export function LaunchWindow() {
{/* Record/Stop group */}
-
+
+
{recording && (
{
await hudWindow.waitForLoadState("domcontentloaded");
await dismissLanguagePrompt(hudWindow);
- await expect(hudWindow.getByTestId("launch-record-button")).toBeDisabled();
+ await expect(hudWindow.getByTestId("launch-record-button")).toBeEnabled();
await expect(hudWindow.getByTestId("launch-source-selector-button")).toBeVisible();
await expect(hudWindow.getByTestId("launch-system-audio-button")).toBeEnabled();
await expect(hudWindow.getByTestId("launch-microphone-button")).toBeEnabled();