-
Notifications
You must be signed in to change notification settings - Fork 122
feat(recorder): add capture-safe floating self-view #500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Radioactive012
wants to merge
12
commits into
getopenscreen:main
Choose a base branch
from
Radioactive012:codex/floating-self-view
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
2cdabc6
feat(recorder): add floating macOS self-view
Radioactive012 3c04fbc
fix(mac): enforce safe self-capture exclusion
Radioactive012 01498b6
feat(editor): default new projects to 8 percent padding
Radioactive012 5476e96
test(recorder): cover self-view and exclusion lifecycle
Radioactive012 82444bd
fix(recorder): close self-view when HUD is destroyed
Radioactive012 b82fa29
fix(recorder): use capture-safe self-view window
Radioactive012 3d75d88
fix(build): support macOS paths with spaces
Radioactive012 b6baa35
fix(recorder): reject stale self-view completions
Radioactive012 408c4f7
fix(recorder): keep self-view above fullscreen apps
Radioactive012 9576834
fix(editor): match new recordings to source frame
Radioactive012 d86b7f7
fix(recorder): float self-view over fullscreen apps
Radioactive012 2f50129
fix(editor): seed recording imports atomically
Radioactive012 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| import type { BrowserWindow, WebContents } from "electron"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { FloatingSelfViewController } from "./floatingSelfView"; | ||
|
|
||
| function fixture() { | ||
| const windowHandlers = new Map<string, (...args: unknown[]) => void>(); | ||
| const webContentsHandlers = new Map<string, (...args: unknown[]) => void>(); | ||
| const sender = { | ||
| isDestroyed: vi.fn(() => false), | ||
| send: vi.fn(), | ||
| } as unknown as WebContents; | ||
| const hud = { | ||
| isDestroyed: vi.fn(() => false), | ||
| webContents: sender, | ||
| } as unknown as BrowserWindow; | ||
| const selfViewSender = { | ||
| isDestroyed: vi.fn(() => false), | ||
| isLoading: vi.fn(() => false), | ||
| send: vi.fn(), | ||
| on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { | ||
| webContentsHandlers.set(event, callback); | ||
| }), | ||
| once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { | ||
| webContentsHandlers.set(event, callback); | ||
| }), | ||
| } as unknown as WebContents; | ||
| const selfViewWindow = { | ||
| isDestroyed: vi.fn(() => false), | ||
| isVisible: vi.fn(() => false), | ||
| showInactive: vi.fn(), | ||
| setVisibleOnAllWorkspaces: vi.fn(), | ||
| setAlwaysOnTop: vi.fn(), | ||
| moveTop: vi.fn(), | ||
| hide: vi.fn(), | ||
| destroy: vi.fn(), | ||
| webContents: selfViewSender, | ||
| on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { | ||
| windowHandlers.set(event, callback); | ||
| }), | ||
| } as unknown as BrowserWindow; | ||
| const createWindow = vi.fn(() => selfViewWindow); | ||
| const controller = new FloatingSelfViewController({ | ||
| createWindow, | ||
| getHudWindow: () => hud, | ||
| showTimeoutMs: 50, | ||
| }); | ||
| return { | ||
| controller, | ||
| createWindow, | ||
| hud, | ||
| sender, | ||
| selfViewSender, | ||
| selfViewWindow, | ||
| webContentsHandlers, | ||
| windowHandlers, | ||
| }; | ||
| } | ||
|
|
||
| describe("capture-safe floating self-view controller", () => { | ||
| it("pre-creates one hidden BrowserWindow and opens it only after camera readiness", async () => { | ||
| const { controller, createWindow, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| controller.precreate(); | ||
| controller.precreate(); | ||
| expect(createWindow).toHaveBeenCalledTimes(1); | ||
|
|
||
| const show = controller.show(sender, " camera-id "); | ||
| expect(selfViewSender.send).toHaveBeenCalledWith("floating-self-view-command", { | ||
| visible: true, | ||
| requestId: 1, | ||
| deviceId: "camera-id", | ||
| }); | ||
| expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); | ||
|
|
||
| expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); | ||
| await expect(show).resolves.toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); | ||
| expect(selfViewWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledTimes(2); | ||
| expect(selfViewWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { | ||
| visibleOnFullScreen: true, | ||
| skipTransformProcessType: true, | ||
| }); | ||
| expect(selfViewWindow.setAlwaysOnTop).toHaveBeenCalledTimes(2); | ||
| expect(selfViewWindow.setAlwaysOnTop).toHaveBeenCalledWith(true, "screen-saver", 1); | ||
| expect(selfViewWindow.moveTop).toHaveBeenCalledTimes(1); | ||
| expect(controller.getState()).toEqual({ open: true }); | ||
| }); | ||
|
|
||
| it("rejects every sender except the active HUD webContents", async () => { | ||
| const { controller, selfViewSender } = fixture(); | ||
| const attacker = {} as WebContents; | ||
|
|
||
| await expect(controller.show(attacker)).resolves.toEqual({ | ||
| success: false, | ||
| error: "unauthorized-sender", | ||
| }); | ||
| expect(selfViewSender.send).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does not acquire a camera after the HUD is destroyed", async () => { | ||
| const { controller, sender, hud, selfViewSender } = fixture(); | ||
| vi.mocked(hud.isDestroyed).mockReturnValue(true); | ||
|
|
||
| await expect(controller.show(sender)).resolves.toEqual({ | ||
| success: false, | ||
| error: "hud-unavailable", | ||
| }); | ||
| expect(selfViewSender.send).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("contains camera failure, hides the window, and leaves the caller running", async () => { | ||
| const { controller, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| const recordingStillActive = vi.fn(() => true); | ||
| const show = controller.show(sender); | ||
|
|
||
| expect(controller.handleFailure(selfViewSender, 1)).toEqual({ success: true }); | ||
| await expect(show).resolves.toEqual({ | ||
| success: false, | ||
| error: "camera-unavailable", | ||
| }); | ||
| expect(selfViewWindow.hide).toHaveBeenCalled(); | ||
| expect(recordingStillActive()).toBe(true); | ||
| }); | ||
|
|
||
| it("stops the secondary stream command on manual close and HUD teardown", async () => { | ||
| const { controller, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| const firstShow = controller.show(sender); | ||
| controller.handleReady(selfViewSender, 1); | ||
| await firstShow; | ||
|
|
||
| expect(controller.handleWindowClose(selfViewSender)).toEqual({ success: true }); | ||
| expect(selfViewWindow.hide).toHaveBeenCalledTimes(1); | ||
| expect(selfViewSender.send).toHaveBeenLastCalledWith("floating-self-view-command", { | ||
| visible: false, | ||
| requestId: 2, | ||
| }); | ||
|
|
||
| const secondShow = controller.show(sender); | ||
| controller.handleReady(selfViewSender, 3); | ||
| await secondShow; | ||
| controller.hideForHudDestruction(); | ||
| expect(controller.getState()).toEqual({ open: false }); | ||
| expect(selfViewWindow.hide).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it("rejects readiness and close signals from any other renderer", () => { | ||
| const { controller } = fixture(); | ||
| controller.precreate(); | ||
| const attacker = {} as WebContents; | ||
| expect(controller.handleReady(attacker, 1)).toEqual({ | ||
| success: false, | ||
| error: "unauthorized-sender", | ||
| }); | ||
| expect(controller.handleWindowClose(attacker)).toEqual({ | ||
| success: false, | ||
| error: "unauthorized-sender", | ||
| }); | ||
| }); | ||
|
|
||
| it("does not accept a null request ID when no show request is active", () => { | ||
| const { controller, selfViewSender, selfViewWindow } = fixture(); | ||
| controller.precreate(); | ||
|
|
||
| expect(controller.handleReady(selfViewSender, null)).toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); | ||
| expect(selfViewWindow.hide).toHaveBeenCalledTimes(1); | ||
| expect(controller.getState()).toEqual({ open: false }); | ||
| }); | ||
|
|
||
| it("ignores readiness that arrives after the show request times out", async () => { | ||
| vi.useFakeTimers(); | ||
| try { | ||
| const { controller, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| const show = controller.show(sender); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(50); | ||
| await expect(show).resolves.toEqual({ | ||
| success: false, | ||
| error: "request-timeout", | ||
| }); | ||
| expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); | ||
| expect(selfViewWindow.hide).toHaveBeenCalled(); | ||
| expect(controller.getState()).toEqual({ open: false }); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| } | ||
| }); | ||
|
|
||
| it("keeps a replacement request pending when stale readiness arrives", async () => { | ||
| const { controller, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| const firstShow = controller.show(sender); | ||
| const replacementShow = controller.show(sender); | ||
|
|
||
| await expect(firstShow).resolves.toEqual({ | ||
| success: false, | ||
| error: "self-view-unavailable", | ||
| }); | ||
| expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); | ||
| expect(controller.getState()).toEqual({ open: false }); | ||
|
|
||
| expect(controller.handleReady(selfViewSender, 2)).toEqual({ success: true }); | ||
| await expect(replacementShow).resolves.toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); | ||
| expect(controller.getState()).toEqual({ open: true }); | ||
| }); | ||
|
|
||
| it("does not fail a replacement request when stale camera failure arrives", async () => { | ||
| const { controller, sender, selfViewSender, selfViewWindow } = fixture(); | ||
| const firstShow = controller.show(sender); | ||
| const replacementShow = controller.show(sender); | ||
|
|
||
| await expect(firstShow).resolves.toEqual({ | ||
| success: false, | ||
| error: "self-view-unavailable", | ||
| }); | ||
| expect(controller.handleFailure(selfViewSender, 1)).toEqual({ success: true }); | ||
| expect(controller.getState()).toEqual({ open: false }); | ||
|
|
||
| controller.handleReady(selfViewSender, 2); | ||
| await expect(replacementShow).resolves.toEqual({ success: true }); | ||
| expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Assert both native reassertions and their order.
The new behavior requires one
setVisibleOnAllWorkspacesandsetAlwaysOnToppair beforeshowInactive(), and one pair after it. These assertions check only the call count and whether at least one call matches the expected arguments. They do not catch an incorrect argument or incorrect call order. Assert each invocation and record the call order aroundshowInactive().As per coding guidelines, add a test for every new behavior in the same package as the code under test.
🤖 Prompt for AI Agents
Source: Coding guidelines