From b7ac502c145070bbc071dc0cc665a2a29330e464 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 15 Jun 2026 16:23:43 +0200 Subject: [PATCH 01/18] Update repository links for maintained fork --- CONTRIBUTING.md | 4 +- README.md | 66 +++++++------------ nix/hm-module.nix | 2 +- nix/module.nix | 2 +- nix/package.nix | 2 +- src/components/video-editor/SettingsPanel.tsx | 4 +- 6 files changed, 32 insertions(+), 48 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 651c80f3d..b5f30d4f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ Thank you for considering contributing to this project! By contributing, you hel ## Reporting Issues -If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/siddharthvaddem/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. +If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/EtienneLescot/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. ## Style Guide @@ -54,4 +54,4 @@ If you encounter a bug or have a feature request, please open an issue in the [I By contributing to this project, you agree that your contributions will be licensed under the [MIT License](./LICENSE). -Thank you for your contributions! \ No newline at end of file +Thank you for your contributions! diff --git a/README.md b/README.md index 867f21634..b7703d0cb 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,32 @@ +> [!NOTE] +> This repository is an independent continuation of OpenScreen. +> +> OpenScreen was originally created by [Siddharth Vaddem](https://github.com/siddharthvaddem). The original repository was archived after v1.5.0 and remains available here: [siddharthvaddem/openscreen](https://github.com/siddharthvaddem/openscreen). +> +> This fork continues development under the OpenScreen name with the original author's approval, while remaining fully MIT open source. + > [!WARNING] -> This started as a side project that blew up; not production grade and you'll hit bugs, but hopefully it covers what you need. **This project will soon be archived.** +> OpenScreen is not production-grade software. You should expect bugs, rough edges, and occasional breaking changes.

OpenScreen Logo -
-
- siddharthvaddem%2Fopenscreen | Trendshift - - -

#

OpenScreen

-

OpenScreen is your free, open-source alternative to Screen Studio.

+

OpenScreen is a free, open-source tool for creating polished screen recordings, product demos, and walkthroughs.

+ +OpenScreen was originally positioned as a free, open-source alternative to Screen Studio: something you can use to create quick, polished product demos and walkthroughs for X, Reddit, YouTube, documentation, landing pages, or internal demos. -If you don't want to pay $29/month for Screen Studio but want a version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit or Youtube. OpenScreen does not offer every Screen Studio feature, but covers a lot of the core functionality. +It is not a 1:1 clone of Screen Studio. Screen Studio is an excellent commercial product. OpenScreen focuses on covering the core open-source workflow: recording, zooms, cursor effects, webcam overlay, captions, editing, annotations, and export. -Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs. +The goal of this continuation is to keep OpenScreen alive as a fully open-source project and progressively evolve it toward a broader recording and editing workflow. -**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License. +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the license. > [!NOTE] ->Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall. +> Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.

@@ -49,24 +52,14 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. If y ## Installation -Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. +Download the latest installer for your platform from the [GitHub Releases](https://github.com/EtienneLescot/openscreen/releases) page. -### macOS - -The easiest way to install on macOS is via [Homebrew](https://brew.sh): - -```bash -brew install --cask siddharthvaddem/openscreen/openscreen -``` - -Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it. - -To update later: `brew upgrade --cask openscreen` -To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data) +> [!NOTE] +> This fork is a continuation of the archived upstream project. Some package-manager distributions may still point to the original repository until the release pipeline is fully updated for this continuation. -#### Manual install (if you prefer) +### macOS -If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation: +Download the `.dmg` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). If Gatekeeper blocks the app, you can bypass it by running the following command in your terminal after installation: ```bash xattr -rd com.apple.quarantine /Applications/Openscreen.app @@ -81,20 +74,11 @@ After running this command, proceed to **System Preferences > Security & Privacy ### Windows -Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): - -```bash -winget install SiddharthVaddem.OpenScreen -``` - -To update later: `winget upgrade SiddharthVaddem.OpenScreen` -To uninstall: `winget uninstall SiddharthVaddem.OpenScreen` - -If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases). +Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). ### Linux -Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro: +Three packages are published to the [Releases page](https://github.com/EtienneLescot/openscreen/releases) for each version. Pick the one that matches your distro: **Debian / Ubuntu / Pop!_OS (`.deb`)** ```bash @@ -116,18 +100,18 @@ chmod +x Openscreen-Linux-*.AppImage Try without installing: ```bash -nix run github:siddharthvaddem/openscreen +nix run github:EtienneLescot/openscreen ``` Install into your user profile: ```bash -nix profile install github:siddharthvaddem/openscreen +nix profile install github:EtienneLescot/openscreen ``` For a NixOS system config (flake): ```nix { - inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + inputs.openscreen.url = "github:EtienneLescot/openscreen"; outputs = { nixpkgs, openscreen, ... }: { nixosConfigurations. = nixpkgs.lib.nixosSystem { diff --git a/nix/hm-module.nix b/nix/hm-module.nix index b04f82793..9ed0a7d4f 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -1,7 +1,7 @@ # Home Manager module for OpenScreen # Usage in flake-based Home Manager config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.homeManagerModules.default ]; diff --git a/nix/module.nix b/nix/module.nix index 3282d2d4f..0cb7242ca 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -1,7 +1,7 @@ # NixOS module for OpenScreen # Usage in flake-based NixOS config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.nixosModules.default ]; diff --git a/nix/package.nix b/nix/package.nix index bca77c91b..4570ef859 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -116,7 +116,7 @@ buildNpmPackage { meta = { description = "Desktop screen recorder with built-in editor"; - homepage = "https://github.com/siddharthvaddem/openscreen"; + homepage = "https://github.com/EtienneLescot/openscreen"; license = lib.licenses.mit; mainProgram = "openscreen"; platforms = lib.platforms.linux; diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b9cc7628c..a2c5d4012 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -746,7 +746,7 @@ export function SettingsPanel({ type="button" onClick={() => { window.electronAPI?.openExternalUrl( - "https://github.com/siddharthvaddem/openscreen/issues/new/choose", + "https://github.com/EtienneLescot/openscreen/issues/new/choose", ); }} className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors" @@ -767,7 +767,7 @@ export function SettingsPanel({ + + {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(); From 1668cf42bdef6176cad6624c2e2941ceb96c5ab5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 12:22:52 +0200 Subject: [PATCH 06/18] Address merged issue bookkeeping review --- .github/workflows/merged-pr-bookkeeping.yml | 50 ++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/workflows/merged-pr-bookkeeping.yml b/.github/workflows/merged-pr-bookkeeping.yml index ab84fbf37..8b0671325 100644 --- a/.github/workflows/merged-pr-bookkeeping.yml +++ b/.github/workflows/merged-pr-bookkeeping.yml @@ -105,7 +105,7 @@ jobs: } } - async function getClosingIssueNumbers() { + async function getClosingIssueRefs() { const query = ` query($owner: String!, $repo: String!, $pullNumber: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -113,6 +113,12 @@ jobs: closingIssuesReferences(first: 100, after: $cursor) { nodes { number + repository { + name + owner { + login + } + } } pageInfo { hasNextPage @@ -124,7 +130,7 @@ jobs: } `; - const numbers = []; + const issueRefs = new Map(); let cursor = null; let hasNextPage = true; @@ -136,12 +142,20 @@ jobs: cursor, }); const refs = result.repository.pullRequest.closingIssuesReferences; - numbers.push(...refs.nodes.map((issue) => issue.number)); + for (const issue of refs.nodes) { + const issueOwner = issue.repository.owner.login; + const issueRepo = issue.repository.name; + issueRefs.set(`${issueOwner}/${issueRepo}#${issue.number}`, { + owner: issueOwner, + repo: issueRepo, + number: issue.number, + }); + } hasNextPage = refs.pageInfo.hasNextPage; cursor = refs.pageInfo.endCursor; } - return [...new Set(numbers)]; + return [...issueRefs.values()]; } async function hasBookkeepingComment(issueNumber, marker) { @@ -158,13 +172,25 @@ jobs: await ensureLabel(pendingReleaseLabel); const fallbackMilestone = pullRequest.milestone || await ensureMilestone(nextReleaseMilestoneTitle); - const issueNumbers = await getClosingIssueNumbers(); - if (issueNumbers.length === 0) { + const issueRefs = await getClosingIssueRefs(); + if (issueRefs.length === 0) { core.info(`PR #${pullNumber} did not declare closing issue references. Nothing to update.`); return; } - for (const issueNumber of issueNumbers) { + for (const issueRef of issueRefs) { + if ( + issueRef.owner.toLowerCase() !== owner.toLowerCase() || + issueRef.repo.toLowerCase() !== repo.toLowerCase() + ) { + core.warning( + `Skipping cross-repository closing reference ${issueRef.owner}/${issueRef.repo}#${issueRef.number}; ` + + `this workflow only updates issues in ${owner}/${repo}.`, + ); + continue; + } + + const issueNumber = issueRef.number; const issueResponse = await github.rest.issues.get({ owner, repo, @@ -213,10 +239,12 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${marker} -Fixed by #${pullNumber} and merged into \`main\`. - -This change is assigned to the \`${milestoneTitle}\` release milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked as \`${pendingReleaseLabel.name}\` until a packaged release containing it is published.`, + body: [ + marker, + `Fixed by #${pullNumber} and merged into \`main\`.`, + "", + `This change is assigned to the \`${milestoneTitle}\` release milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked as \`${pendingReleaseLabel.name}\` until a packaged release containing it is published.`, + ].join("\n"), }); } From 6d022372bff499f7a531797bcf485b07a7b70958 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:07:40 +0200 Subject: [PATCH 07/18] Add official OpenScreen links --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index fdbf4726f..f58c068fc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ > [!WARNING] > OpenScreen is not production-grade software. You should expect bugs, rough edges, and occasional breaking changes. +## Official links + +This repository is the community-maintained continuation of OpenScreen. + +Official / trusted links: + +* Original archived repository: https://github.com/siddharthvaddem/openscreen +* Community continuation: https://github.com/EtienneLescot/openscreen + +Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. For safety, download OpenScreen only from GitHub Releases linked from the repositories above.

OpenScreen Logo From 2cb982b1a97fe55e965dee447396fe4e78cd539d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:08:35 +0200 Subject: [PATCH 08/18] Refine official download guidance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f58c068fc..e0a74e192 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Official / trusted links: * Original archived repository: https://github.com/siddharthvaddem/openscreen * Community continuation: https://github.com/EtienneLescot/openscreen -Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. For safety, download OpenScreen only from GitHub Releases linked from the repositories above. +For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here.

OpenScreen Logo From 3aace1862f8cdaac17c5ce881a074bbbde491776 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:10:25 +0200 Subject: [PATCH 09/18] Move official links near installation --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e0a74e192..13ef2abb7 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,6 @@ > [!WARNING] > OpenScreen is not production-grade software. You should expect bugs, rough edges, and occasional breaking changes. -## Official links - -This repository is the community-maintained continuation of OpenScreen. - -Official / trusted links: - -* Original archived repository: https://github.com/siddharthvaddem/openscreen -* Community continuation: https://github.com/EtienneLescot/openscreen - -For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. -

OpenScreen Logo

@@ -152,6 +141,17 @@ Everything in the editor and export is the same on macOS, Windows, and Linux: zo - **Windows**: works out of the box. - **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work). +## Official links + +This repository is the community-maintained continuation of OpenScreen. + +Official / trusted links: + +* Original archived repository: https://github.com/siddharthvaddem/openscreen +* Community continuation: https://github.com/EtienneLescot/openscreen + +For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. + --- ## License From ff092fa9ed151197a918d609c6217e0ea5972032 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:19:29 +0200 Subject: [PATCH 10/18] Make Discord PR sync optional --- .github/workflows/discord.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml index 6da25d0d6..152abbd67 100644 --- a/.github/workflows/discord.yaml +++ b/.github/workflows/discord.yaml @@ -26,6 +26,8 @@ jobs: steps: - name: Sync PR activity to Discord forum thread id: sync + # Discord sync is helpful automation, but it should not block PR validation. + continue-on-error: true uses: actions/github-script@v7 env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} From 5ed545b8d8c1b8434c8b825c532c1d94d05a7121 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:20:27 +0200 Subject: [PATCH 11/18] Avoid failing PRs on Discord sync errors --- .github/workflows/discord.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml index 152abbd67..950abae3d 100644 --- a/.github/workflows/discord.yaml +++ b/.github/workflows/discord.yaml @@ -212,15 +212,10 @@ jobs: } if (!webhookUrl) { - const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]); const msg = `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets."; - if (strictEvents.has(context.eventName)) { - core.setFailed(msg); - } else { - core.warning(msg); - } + core.warning(msg); return; } @@ -423,7 +418,7 @@ jobs: core.info(`Posted update to Discord thread ${threadId}.`); } catch (err) { const msg = err && err.message ? err.message : String(err); - core.setFailed(msg); + core.warning(`Discord sync failed, but this optional automation will not block PR validation: ${msg}`); const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; if (alertWebhook) { From eb04f4e0887c6088883752d35e37a73bf65457ee Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:03:42 +0200 Subject: [PATCH 12/18] Fix issue 3 app launch and UI bugs --- electron/main.ts | 14 ++++++- src/components/launch/LaunchWindow.tsx | 42 ++++++++++++++++++- src/components/video-editor/SettingsPanel.tsx | 8 ++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 11d545fdf..16473e6f0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -108,6 +108,16 @@ function showMainWindow() { createWindow(); } +const hasSingleInstanceLock = app.requestSingleInstanceLock(); + +if (hasSingleInstanceLock) { + app.on("second-instance", () => { + showMainWindow(); + }); +} else { + app.quit(); +} + function isEditorWindow(window: BrowserWindow) { return window.webContents.getURL().includes("windowType=editor"); } @@ -456,7 +466,9 @@ app.on("will-quit", () => { unregisterAllGlobalShortcuts(); }); -app.whenReady().then(async () => { +const appReady = hasSingleInstanceLock ? app.whenReady() : null; + +appReady?.then(async () => { // Force "regular" activation policy so the Dock icon appears. The HUD overlay // (transparent, frameless, skipTaskbar) is the first window, and AppKit would // otherwise classify us as an accessory app. diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 373aeac55..6dde4543d 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -522,6 +522,45 @@ export function LaunchWindow() { } }; const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); + const dragAnimationFrameRef = useRef(null); + const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); + const flushHudDragMove = useCallback(() => { + dragAnimationFrameRef.current = null; + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + const scheduleHudDragMove = useCallback( + (deltaX: number, deltaY: number) => { + pendingDragDeltaRef.current = { + x: pendingDragDeltaRef.current.x + deltaX, + y: pendingDragDeltaRef.current.y + deltaY, + }; + + if (dragAnimationFrameRef.current === null) { + dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); + } + }, + [flushHudDragMove], + ); + const flushPendingHudDragMove = useCallback(() => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + dragAnimationFrameRef.current = null; + } + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + useEffect(() => { + return () => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + } + }; + }, []); const handleHudDragPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -535,10 +574,11 @@ export function LaunchWindow() { const deltaX = event.screenX - lastPosition.x; const deltaY = event.screenY - lastPosition.y; dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; - window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY); + scheduleHudDragMove(deltaX, deltaY); }; const handleHudDragPointerEnd = (event: React.PointerEvent) => { dragLastPositionRef.current = null; + flushPendingHudDragMove(); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index a2c5d4012..5e0cc8062 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1705,22 +1705,22 @@ export function SettingsPanel({ - + {t("background.image")} {t("background.color")} {t("background.gradient")} From 59cfe26f91d49f037b3464571c12d6fb586c31d5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 13:27:37 +0200 Subject: [PATCH 13/18] Guard duplicate HUD creation --- electron/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electron/main.ts b/electron/main.ts index 16473e6f0..57e489f30 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -92,6 +92,10 @@ const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); function createWindow() { + if (mainWindow && !mainWindow.isDestroyed()) { + return; + } + mainWindow = createHudOverlayWindow(); } From 779045381e04d6943e82eb12a1fe1bb02db9757c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 19 Jun 2026 15:29:35 +0200 Subject: [PATCH 14/18] Fix duplicate app instances --- electron/main.ts | 7 +- electron/singleInstanceLock.test.ts | 52 ++++++++++++++ electron/singleInstanceLock.ts | 104 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 electron/singleInstanceLock.test.ts create mode 100644 electron/singleInstanceLock.ts diff --git a/electron/main.ts b/electron/main.ts index 57e489f30..c2d7a4d48 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,6 +19,7 @@ import { } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; import { createCountdownOverlayWindow, createEditorWindow, @@ -112,13 +113,16 @@ function showMainWindow() { createWindow(); } -const hasSingleInstanceLock = app.requestSingleInstanceLock(); +const stableInstanceLock = acquireStableInstanceLock(); +const hasElectronSingleInstanceLock = app.requestSingleInstanceLock(); +const hasSingleInstanceLock = Boolean(stableInstanceLock && hasElectronSingleInstanceLock); if (hasSingleInstanceLock) { app.on("second-instance", () => { showMainWindow(); }); } else { + stableInstanceLock?.release(); app.quit(); } @@ -468,6 +472,7 @@ app.on("activate", () => { app.on("will-quit", () => { unregisterAllGlobalShortcuts(); + stableInstanceLock?.release(); }); const appReady = hasSingleInstanceLock ? app.whenReady() : null; diff --git a/electron/singleInstanceLock.test.ts b/electron/singleInstanceLock.test.ts new file mode 100644 index 000000000..4df35c72b --- /dev/null +++ b/electron/singleInstanceLock.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; + +const testDirs: string[] = []; + +function createTestLockDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-lock-test-")); + testDirs.push(dir); + return path.join(dir, "app.lock"); +} + +afterEach(() => { + for (const dir of testDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("acquireStableInstanceLock", () => { + it("prevents a second lock while the owning process is still running", () => { + const lockDir = createTestLockDir(); + const firstLock = acquireStableInstanceLock({ lockDir }); + + expect(firstLock).not.toBeNull(); + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + + firstLock?.release(); + }); + + it("reclaims a stale lock when its process is gone", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + fs.writeFileSync(path.join(lockDir, "pid"), "99999999\n"); + + const lock = acquireStableInstanceLock({ lockDir }); + + expect(lock).not.toBeNull(); + expect(fs.readFileSync(path.join(lockDir, "pid"), "utf8")).toBe(`${process.pid}\n`); + + lock?.release(); + }); + + it("does not remove a fresh empty lock directory", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + expect(fs.existsSync(lockDir)).toBe(true); + }); +}); diff --git a/electron/singleInstanceLock.ts b/electron/singleInstanceLock.ts new file mode 100644 index 000000000..d1a0a01af --- /dev/null +++ b/electron/singleInstanceLock.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const LOCK_DIR_PREFIX = "openscreen-single-instance"; +const PID_FILE_NAME = "pid"; +const EMPTY_LOCK_STALE_MS = 30_000; + +export type StableInstanceLock = { + lockDir: string; + release: () => void; +}; + +type LockOptions = { + lockDir?: string; + pid?: number; + now?: () => number; +}; + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readLockPid(lockDir: string): number | null { + try { + const rawPid = fs.readFileSync(path.join(lockDir, PID_FILE_NAME), "utf8").trim(); + const pid = Number(rawPid); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isEmptyLockStale(lockDir: string, now: () => number): boolean { + try { + const stat = fs.statSync(lockDir); + return now() - stat.mtimeMs > EMPTY_LOCK_STALE_MS; + } catch { + return false; + } +} + +function releaseLock(lockDir: string, pid: number) { + if (readLockPid(lockDir) !== pid) { + return; + } + fs.rmSync(lockDir, { recursive: true, force: true }); +} + +function getCurrentUserLockKey() { + if (typeof process.getuid === "function") { + return `uid-${process.getuid()}`; + } + + try { + const username = os.userInfo().username.replace(/[^a-zA-Z0-9._-]/g, "_"); + return username || "default"; + } catch { + return "default"; + } +} + +export function getStableInstanceLockDir() { + return path.join(os.tmpdir(), `${LOCK_DIR_PREFIX}-${getCurrentUserLockKey()}.lock`); +} + +export function acquireStableInstanceLock(options: LockOptions = {}): StableInstanceLock | null { + const lockDir = options.lockDir ?? getStableInstanceLockDir(); + const pid = options.pid ?? process.pid; + const now = options.now ?? Date.now; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fs.mkdirSync(lockDir, { mode: 0o700 }); + fs.writeFileSync(path.join(lockDir, PID_FILE_NAME), `${pid}\n`, { flag: "wx" }); + return { + lockDir, + release: () => releaseLock(lockDir, pid), + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + + const existingPid = readLockPid(lockDir); + if (existingPid && isProcessRunning(existingPid)) { + return null; + } + if (!existingPid && !isEmptyLockStale(lockDir, now)) { + return null; + } + + fs.rmSync(lockDir, { recursive: true, force: true }); + } + } + + return null; +} From f38c881e229c061a34e57c69c62320475bb0d6b7 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 12:37:54 +0200 Subject: [PATCH 15/18] Address source selector review feedback --- src/components/launch/LaunchWindow.test.tsx | 40 +++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 20 ++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 2bf9c2dbe..c12c04921 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -268,6 +268,46 @@ describe("LaunchWindow record button", () => { expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); + it("clears record-after-selection intent when opening the source picker fails", async () => { + window.electronAPI.openSourceSelector = vi.fn(async () => { + throw new Error("source selector failed"); + }); + + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("handles selected source polling failures", async () => { + const error = new Error("selected source unavailable"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + stubElectronAPI( + vi.fn(async () => { + throw error; + }), + ); + + renderLaunchWindow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith("Failed to refresh selected source:", error); + }); + + warnSpy.mockRestore(); + }); + it("starts recording when a source is already selected", async () => { stubElectronAPI(vi.fn(async () => displayOneSource)); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 6dde4543d..511ab8e5c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -442,9 +442,15 @@ export function LaunchWindow() { useEffect(() => { const checkSelectedSource = async () => { - if (window.electronAPI) { + if (!window.electronAPI) { + return; + } + + try { const source = await window.electronAPI.getSelectedSource(); applySelectedSource(source); + } catch (error) { + console.warn("Failed to refresh selected source:", error); } }; @@ -488,11 +494,15 @@ export function LaunchWindow() { const handleRecordButtonClick = () => { if (!hasSelectedSource && !recording) { recordAfterSourceSelectionRef.current = true; - void openSourceSelector().then((result) => { - if (!result.opened) { + void openSourceSelector() + .then((result) => { + if (!result.opened) { + recordAfterSourceSelectionRef.current = false; + } + }) + .catch(() => { recordAfterSourceSelectionRef.current = false; - } - }); + }); return; } From 4a90d6c5862dabd7959401654a81cf2804a3dcac Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 19 Jun 2026 15:41:27 +0200 Subject: [PATCH 16/18] Harden source selector rejection test --- src/components/launch/LaunchWindow.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index c12c04921..6440f1d41 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -282,6 +282,10 @@ describe("LaunchWindow record button", () => { expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); }); + await act(async () => { + await Promise.resolve(); + }); + emitSelectedSourceChanged(displayOneSource); await waitFor(() => { From 4a6d4d014a8d942fc738af81ac5eb690f54132de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=82=E3=82=8A=E3=81=93?= Date: Sat, 20 Jun 2026 07:14:00 +0900 Subject: [PATCH 17/18] Add regression test for encoder stall timeout Extracts the queue-full wait loop into waitForEncoderQueueSpace() so the timing logic can be unit tested without real WebCodecs. Covers the original false-positive: a long gap before the call (e.g. decoder discarding frames in a trim region) must not count against the 15s timeout, since the timer starts at call time, not from the encoder's last output. --- src/lib/exporter/videoExporter.test.ts | 129 ++++++++++++++++++++++++- src/lib/exporter/videoExporter.ts | 59 +++++++---- 2 files changed, 169 insertions(+), 19 deletions(-) diff --git a/src/lib/exporter/videoExporter.test.ts b/src/lib/exporter/videoExporter.test.ts index 1b64255a2..a5c330f70 100644 --- a/src/lib/exporter/videoExporter.test.ts +++ b/src/lib/exporter/videoExporter.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getSourceCopyFastPathBlockers, isSourceCopyFastPathEligible, type VideoExporterConfig, + waitForEncoderQueueSpace, } from "./videoExporter"; function createConfig(overrides: Partial = {}): VideoExporterConfig { @@ -119,3 +120,129 @@ describe("getSourceCopyFastPathBlockers", () => { ).toContain("output-size 1920x1080 differs from source 1920x1032"); }); }); + +describe("waitForEncoderQueueSpace", () => { + function fakeClock(start = 0) { + let elapsedMs = start; + return { + now: () => elapsedMs, + sleep: async (ms: number) => { + elapsedMs += ms; + }, + }; + } + + it("resolves immediately when the queue already has space", async () => { + const clock = fakeClock(); + const sleep = vi.fn(clock.sleep); + + await waitForEncoderQueueSpace({ + getQueueSize: () => 0, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).not.toHaveBeenCalled(); + }); + + it("waits for the queue to drain and then resolves", async () => { + const clock = fakeClock(); + let queueSize = 8; + // Queue drains well within the timeout. + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + queueSize = 0; + }); + + await waitForEncoderQueueSpace({ + getQueueSize: () => queueSize, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }); + + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("throws a hardware-specific error once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow( + "The hardware video encoder stopped responding. Retrying with a safer encoder.", + ); + }); + + it("throws a generic error for the software encoder once the queue stays full past the timeout", async () => { + const clock = fakeClock(); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-software", + now: clock.now, + sleep: clock.sleep, + }), + ).rejects.toThrow("The video encoder stopped responding during export."); + }); + + // Regression test for the false-positive stall: a long gap *before* this call + // (e.g. the decoder discarding frames inside a trim region) must not count + // against the timeout. Only time spent waiting *inside* this call should. + it("does not throw merely because a long time has already passed before this call", async () => { + // Simulate this call starting two minutes after some unrelated earlier event, + // then having the queue drain almost immediately once we're inside the wait. + const clock = fakeClock(2 * 60 * 1000); + let queueSize = 8; + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + queueSize = 0; + }); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => queueSize, + maxEncodeQueue: 8, + isCancelled: () => false, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }), + ).resolves.toBeUndefined(); + }); + + it("stops waiting without throwing once cancelled", async () => { + const clock = fakeClock(); + let cancelled = false; + const sleep = vi.fn(async (ms: number) => { + await clock.sleep(ms); + cancelled = true; + }); + + await expect( + waitForEncoderQueueSpace({ + getQueueSize: () => 8, + maxEncodeQueue: 8, + isCancelled: () => cancelled, + encoderPreference: "prefer-hardware", + now: clock.now, + sleep, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 3a7894098..8f239656c 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -20,6 +20,37 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; +/** + * Waits for the encoder's queue to drain below maxEncodeQueue before returning. + * + * The stall timer starts fresh on each call (not from the encoder's last output), so a + * long gap before this call — e.g. the decoder discarding frames inside a trim region — + * doesn't get blamed on the encoder once real frames resume. + */ +export async function waitForEncoderQueueSpace(params: { + getQueueSize: () => number; + maxEncodeQueue: number; + isCancelled: () => boolean; + encoderPreference: HardwareAcceleration; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const now = params.now ?? Date.now; + const sleep = params.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const stallWaitStartAt = now(); + while (params.getQueueSize() >= params.maxEncodeQueue && !params.isCancelled()) { + if (now() - stallWaitStartAt > ENCODER_STALL_TIMEOUT_MS) { + throw new Error( + params.encoderPreference === "prefer-hardware" + ? "The hardware video encoder stopped responding. Retrying with a safer encoder." + : "The video encoder stopped responding during export.", + ); + } + await sleep(5); + } +} + export interface VideoExporterConfig extends ExportConfig { videoUrl: string; webcamVideoUrl?: string; @@ -383,24 +414,16 @@ export class VideoExporter { exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration }); } - // Timer resets each time we enter the queue-full wait, so a long - // trimmed-region decode (decoder busy discarding frames) doesn't - // make the stall check fire spuriously. - const stallWaitStartAt = Date.now(); - while ( - this.encoder && - this.encoder.encodeQueueSize >= maxEncodeQueue && - !this.cancelled - ) { - if (Date.now() - stallWaitStartAt > ENCODER_STALL_TIMEOUT_MS) { - exportFrame.close(); - throw new Error( - encoderPreference === "prefer-hardware" - ? "The hardware video encoder stopped responding. Retrying with a safer encoder." - : "The video encoder stopped responding during export.", - ); - } - await new Promise((resolve) => setTimeout(resolve, 5)); + try { + await waitForEncoderQueueSpace({ + getQueueSize: () => this.encoder?.encodeQueueSize ?? 0, + maxEncodeQueue, + isCancelled: () => this.cancelled, + encoderPreference, + }); + } catch (error) { + exportFrame.close(); + throw error; } if (this.encoder && this.encoder.state === "configured") { From c81b38acf1947219fae6958aa31838c5c6cd3e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=82=E3=82=8A=E3=81=93?= Date: Sat, 20 Jun 2026 07:31:32 +0900 Subject: [PATCH 18/18] Remove redundant stall-timeout test, clarify regression rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "long gap before this call" case was mathematically identical to the queue-drain test once now()/sleep() are injected — shifting the fake clock's epoch doesn't change now() - stallWaitStartAt. Replaced with a comment on why the bug can't recur: the function takes no external "last output" timestamp to go stale in the first place. --- src/lib/exporter/videoExporter.test.ts | 31 +++++--------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/lib/exporter/videoExporter.test.ts b/src/lib/exporter/videoExporter.test.ts index a5c330f70..bd424548b 100644 --- a/src/lib/exporter/videoExporter.test.ts +++ b/src/lib/exporter/videoExporter.test.ts @@ -121,6 +121,12 @@ describe("getSourceCopyFastPathBlockers", () => { }); }); +// The original bug measured the timeout from the encoder's last *output* event +// (lastEncoderOutputAt), which went stale while the decoder discarded frames inside +// a trim region. waitForEncoderQueueSpace fixes this by starting the clock fresh on +// each call instead of accepting any such external timestamp — by construction, there +// is no "last output" state to go stale, so that regression can't be reintroduced +// without changing this function's signature. describe("waitForEncoderQueueSpace", () => { function fakeClock(start = 0) { let elapsedMs = start; @@ -201,31 +207,6 @@ describe("waitForEncoderQueueSpace", () => { ).rejects.toThrow("The video encoder stopped responding during export."); }); - // Regression test for the false-positive stall: a long gap *before* this call - // (e.g. the decoder discarding frames inside a trim region) must not count - // against the timeout. Only time spent waiting *inside* this call should. - it("does not throw merely because a long time has already passed before this call", async () => { - // Simulate this call starting two minutes after some unrelated earlier event, - // then having the queue drain almost immediately once we're inside the wait. - const clock = fakeClock(2 * 60 * 1000); - let queueSize = 8; - const sleep = vi.fn(async (ms: number) => { - await clock.sleep(ms); - queueSize = 0; - }); - - await expect( - waitForEncoderQueueSpace({ - getQueueSize: () => queueSize, - maxEncodeQueue: 8, - isCancelled: () => false, - encoderPreference: "prefer-hardware", - now: clock.now, - sleep, - }), - ).resolves.toBeUndefined(); - }); - it("stops waiting without throwing once cancelled", async () => { const clock = fakeClock(); let cancelled = false;