Skip to content
Open
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
11 changes: 10 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ interface Window {
startNativeMacRecording: (
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
onNativeMacPresenterOverlayChanged: (
callback: (
event: import("../src/lib/nativeMacRecording").NativeMacPresenterOverlayEvent,
) => void,
) => () => void;
onNativeMacSystemAudioUnavailable: (callback: () => void) => () => void;
pauseNativeMacRecording: () => Promise<{
success: boolean;
Expand Down Expand Up @@ -465,7 +470,11 @@ interface Window {
setTitleBarOverlay: (color: string, symbolColor: string) => void;
getPlatform: () => string;
getSystemVersion: () => string;
getAppInfo: () => Promise<{ version: string; canCheckForUpdates: boolean }>;
getAppInfo: () => Promise<{
version: string;
canCheckForUpdates: boolean;
supportsPresenterOverlay?: boolean;
}>;
checkForUpdates: () => Promise<void>;
showAbout: () => Promise<void>;
canCheckForUpdatesNow: () => Promise<boolean>;
Expand Down
28 changes: 28 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import {
collectMacCaptureExcludedWindowIds,
type NativeMacRecordingRequest,
updateMacPresenterOverlayState,
} from "../../src/lib/nativeMacRecording";
import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording";
import {
Expand Down Expand Up @@ -877,6 +878,7 @@ const nativeMacCaptureOutputs = new WeakMap<ChildProcessWithoutNullStreams, stri
* because the HUD that ran the stop closes as the editor opens.
*/
let nativeMacRecordingWarning: { screenVideoPath: string; message: string } | null = null;
let nativeMacPresenterOverlay = { active: false, cameraInterrupted: false };
/** True while stop-native-mac-recording runs: the take is ending on purpose. */
let nativeMacStopInFlight = false;
// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds).
Expand Down Expand Up @@ -1631,6 +1633,7 @@ function attachNativeMacCaptureOutputDrain(
proc: ChildProcessWithoutNullStreams,
onTakeEnded: () => void,
onSystemAudioUnavailable: () => void,
onPresenterOverlayChanged: (active: boolean) => void,
) {
let lineBuffer = "";
// Hooked here rather than on `nativeMacCaptureEvents`, which the start wait
Expand All @@ -1639,6 +1642,10 @@ function attachNativeMacCaptureOutputDrain(
() => nativeMacCaptureProcess === proc && !nativeMacStopInFlight,
onTakeEnded,
onSystemAudioUnavailable,
onPresenterOverlayChanged,
// Overlay transitions keep counting until the helper process is replaced: a start
// line can reach the drain after Stop was pressed and still belongs to this take.
() => nativeMacCaptureProcess === proc,
);
const drain = (chunk: Buffer) => {
const text = chunk.toString();
Expand Down Expand Up @@ -3254,6 +3261,7 @@ export function registerIpcHandlers(
nativeMacIsPaused = false;
nativeMacStopInFlight = false;
nativeMacRecordingWarning = null;
nativeMacPresenterOverlay = { active: false, cameraInterrupted: false };
activeMacCaptureBounds = null;

const cursorStartTimeMs = Date.now();
Expand Down Expand Up @@ -3288,6 +3296,20 @@ export function registerIpcHandlers(
hudWindow.webContents.send("native-mac-system-audio-unavailable");
}
},
(active) => {
nativeMacPresenterOverlay = updateMacPresenterOverlayState(
nativeMacPresenterOverlay,
active,
request.webcam.enabled,
);
const hudWindow = getMainWindow();
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.webContents.send("native-mac-presenter-overlay-changed", {
recordingId,
...nativeMacPresenterOverlay,
});
}
},
);

const captureStartedAtMs = (await waitForNativeMacCaptureStart(proc)) ?? Date.now();
Expand Down Expand Up @@ -3329,6 +3351,7 @@ export function registerIpcHandlers(
path: outputPath,
helperPath,
startedAtMs: captureStartedAtMs,
presenterOverlay: { ...nativeMacPresenterOverlay },
microphoneDefaulted,
microphoneUnavailable,
microphoneNotFound,
Expand Down Expand Up @@ -3737,6 +3760,11 @@ export function registerIpcHandlers(
truncatedBytes: salvage.truncatedBytes,
});
}
if (nativeMacPresenterOverlay.cameraInterrupted) {
warning = [warning, mainT("common", "presenterOverlay.interruptedDescription")]
.filter(Boolean)
.join("\n\n");
}
nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null;

// A recovered take most often follows a disk that filled up, and these writes
Expand Down
38 changes: 38 additions & 0 deletions electron/ipc/nativeMacMidCaptureErrorWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,42 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

expect(onSystemAudioUnavailable).not.toHaveBeenCalled();
});
it("reports overlay transitions before or after start without ending the take", () => {
const onEnded = vi.fn();
const onOverlay = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onEnded, vi.fn(), onOverlay);
watch({ event: "warning", code: "presenter-overlay-started" });
watch({ event: "recording-started" });
watch({ event: "warning", code: "presenter-overlay-started" });
watch({ event: "warning", code: "presenter-overlay-stopped" });
watch({ event: "warning", code: "presenter-overlay-stopped" });
expect(onOverlay.mock.calls).toEqual([[true], [false]]);
expect(onEnded).not.toHaveBeenCalled();
});

it("ignores overlay events from a previous or stopping take", () => {
const onOverlay = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => false, vi.fn(), vi.fn(), onOverlay);
watch({ event: "warning", code: "presenter-overlay-started" });
expect(onOverlay).not.toHaveBeenCalled();
});

it("counts an overlay transition that arrives while a stop is in flight", () => {
const onEnded = vi.fn();
const onOverlay = vi.fn();
const onSystemAudioUnavailable = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(
() => false,
onEnded,
onSystemAudioUnavailable,
onOverlay,
() => true,
);
watch({ event: "warning", code: "presenter-overlay-started" });
watch({ event: "warning", code: "system-audio-unavailable" });
watch({ event: "error", message: "still ignored" });
expect(onOverlay).toHaveBeenCalledWith(true);
expect(onSystemAudioUnavailable).not.toHaveBeenCalled();
expect(onEnded).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions electron/ipc/nativeMacMidCaptureErrorWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ export function createNativeMacMidCaptureErrorWatch(
isLiveTake: () => boolean,
onTakeEnded: () => void,
onSystemAudioUnavailable: () => void,
onPresenterOverlayChanged?: (active: boolean) => void,
// Presenter Overlay transitions still matter while a stop is in flight: the warning is
// written after the helper exits, so a line that arrives during the stop must update it.
// Ending the take and the system-audio notice stay restricted to a live take.
isCurrentTake: () => boolean = isLiveTake,
) {
let recordingStarted = false;
let presenterOverlayActive = false;
let reportedSystemAudioUnavailable = false;
const watch = (event: Record<string, unknown>) => {
if (event.event === "recording-started") {
Expand All @@ -32,6 +38,18 @@ export function createNativeMacMidCaptureErrorWatch(
reportedSystemAudioUnavailable = true;
onSystemAudioUnavailable();
}
if (event.event === "warning" && isCurrentTake()) {
const active =
event.code === "presenter-overlay-started"
? true
: event.code === "presenter-overlay-stopped"
? false
: null;
if (active !== null && active !== presenterOverlayActive) {
presenterOverlayActive = active;
onPresenterOverlayChanged?.(active);
}
}
if (event.event === "error" && recordingStarted && isLiveTake()) {
onTakeEnded();
}
Expand Down
7 changes: 7 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
shell,
Tray,
} from "electron";
import { supportsMacPresenterOverlay } from "../src/lib/nativeMacRecording";
import { ShortcutBinding } from "../src/lib/shortcuts";
import {
type AboutFacts,
Expand Down Expand Up @@ -1340,6 +1341,12 @@ appReady?.then(async () => {
ipcMain.handle("get-app-info", () => ({
version: app.getVersion(),
canCheckForUpdates: channelAllowsUpdateCheck(),
supportsPresenterOverlay: supportsMacPresenterOverlay(
process.platform,
process.getSystemVersion(),
process.arch,
process.platform === "darwin" && app.runningUnderARM64Translation,
),
}));

// The FULL veto, permanent and transient, for a caller that can ask again at the moment it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
}
}

/// The effect belongs to the screen stream, never the auxiliary audio stream.
@available(macOS 14.0, *)
func outputVideoEffectDidStart(for stream: SCStream) {
guard stream === self.stream else { return }
emit(["event": "warning", "code": "presenter-overlay-started",
"message": "macOS Presenter Overlay started."])
}

/// Stopping the effect does not establish that a separate camera track recovered.
@available(macOS 14.0, *)
func outputVideoEffectDidStop(for stream: SCStream) {
guard stream === self.stream else { return }
emit(["event": "warning", "code": "presenter-overlay-stopped",
"message": "macOS Presenter Overlay stopped."])
}

func stream(_ stream: SCStream, didStopWithError error: Error) {
// Losing the audio-only stream costs the take its system audio, not the take.
if stream === systemAudioStream {
Expand Down
13 changes: 12 additions & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { contextBridge, ipcRenderer, sharedTexture, webUtils } from "electron";
import type { NativeLinuxRecordingRequest } from "../src/lib/nativeLinuxRecording";
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
import type {
NativeMacPresenterOverlayEvent,
NativeMacRecordingRequest,
} from "../src/lib/nativeMacRecording";
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
import type { ShortcutBinding } from "../src/lib/shortcuts";
Expand Down Expand Up @@ -295,6 +298,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
return ipcRenderer.invoke("start-native-mac-recording", request);
},
onNativeMacPresenterOverlayChanged: (
callback: (event: NativeMacPresenterOverlayEvent) => void,
) => {
const listener = (_: unknown, event: NativeMacPresenterOverlayEvent) => callback(event);
ipcRenderer.on("native-mac-presenter-overlay-changed", listener);
return () => ipcRenderer.removeListener("native-mac-presenter-overlay-changed", listener);
},
onNativeMacSystemAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("native-mac-system-audio-unavailable", listener);
Expand Down Expand Up @@ -479,6 +489,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("get-app-info") as Promise<{
version: string;
canCheckForUpdates: boolean;
supportsPresenterOverlay?: boolean;
}>,
/** Resolves once the check has a verdict. The dialogs that verdict leads to — download,
* restart — are the main process's conversation, not the caller's. */
Expand Down
70 changes: 70 additions & 0 deletions src/components/ai-edition/v4/RecStage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -848,3 +848,73 @@ describe("RecStage names and tooltips", () => {
expect(screen.queryByTestId("rec-auto-zoom-button")).toBeNull();
});
});

describe("Presenter Overlay preflight guidance", () => {
beforeEach(() => localStorage.clear());
it("remembers acknowledgement instead of nagging on every visit", async () => {
stubRecordingPrefs({ camEnabled: true });
window.electronAPI.getAppInfo = vi.fn(async () => ({
version: "2.1.2",
canCheckForUpdates: false,
supportsPresenterOverlay: true,
}));
const first = renderRecStage();
fireEvent.click(await screen.findByRole("button", { name: "presenterOverlay.acknowledge" }));
expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument();
first.unmount();
renderRecStage();
await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalledTimes(2));
expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument();
});

it("hides the guidance when another window acknowledges it", async () => {
stubRecordingPrefs({ camEnabled: true });
window.electronAPI.getAppInfo = vi.fn(async () => ({
version: "2.1.2",
canCheckForUpdates: false,
supportsPresenterOverlay: true,
}));
renderRecStage();
await screen.findByText("presenterOverlay.beforeDescription");
act(() => {
localStorage.setItem(
"openscreen_user_preferences",
JSON.stringify({ presenterOverlayGuidanceAcknowledged: true }),
);
window.dispatchEvent(new StorageEvent("storage", { key: "openscreen_user_preferences" }));
});
await waitFor(() =>
expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument(),
);
});

it.each([
false,
true,
])("only shows guidance for supported camera-on recording (camera=%s)", async (camEnabled) => {
stubRecordingPrefs({ camEnabled });
window.electronAPI.getAppInfo = vi.fn(async () => ({
version: "2.1.2",
canCheckForUpdates: false,
supportsPresenterOverlay: true,
}));
renderRecStage();
await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled());
if (camEnabled) {
expect(await screen.findByText("presenterOverlay.beforeDescription")).toBeInTheDocument();
} else {
expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument();
}
});
it("keeps unsupported platforms free of macOS guidance", async () => {
stubRecordingPrefs({ camEnabled: true });
window.electronAPI.getAppInfo = vi.fn(async () => ({
version: "2.1.2",
canCheckForUpdates: false,
supportsPresenterOverlay: false,
}));
renderRecStage();
await waitFor(() => expect(window.electronAPI.getAppInfo).toHaveBeenCalled());
expect(screen.queryByText("presenterOverlay.beforeDescription")).not.toBeInTheDocument();
});
});
Loading
Loading