From e129e408770d073f43ed5b2f8960146a8a24211a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 15 Jul 2026 17:27:29 +0200 Subject: [PATCH 01/13] fix(release): pin build.yml dispatch to the release tag ref prerelease.yml triggered build.yml without --ref, so the build ran on the default branch (main), where package.json is still the previous stable version. The RC version bump lives only on the release branch / RC tag, so build.yml's publish-release guard rejected it ("package.json version 1.6.0 does not match 1.7.0-rc.1 from tag v1.7.0-rc.1") and no release was created. Pass --ref so the build checks out the tag's tree (bumped package.json + frozen code). Apply the same pin in promote.yml for the stable build, which previously only worked because the main-merge happened to leave main at the stable version first. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit f62162b18274566d9d2f25e51bb7fa9f50094db1) --- .github/workflows/prerelease.yml | 7 +++++++ .github/workflows/promote.yml | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index a592488c7..f9e8d2c73 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -133,7 +133,14 @@ jobs: # GITHUB_TOKEN tag pushes don't fire the build.yml trigger in this setup, # so dispatch it explicitly. The PAT ensures the build's release creation # propagates to Tier 3 (homebrew/winget/nix/aur) via release: published. + # + # --ref is REQUIRED: the RC version bump lives ONLY on the release branch / + # RC tag, never on the default branch. Without --ref the build runs on main + # (still the previous stable version), so build.yml's publish-release step + # fails its guard ("package.json version X does not match from tag"). + # Pin the build to the RC tag so checkout gets the bumped package.json + code. gh workflow run build.yml \ + --ref "${RC_TAG}" \ -f release_tag="${RC_TAG}" \ -f arch=both \ --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index ac4cfc079..3e8214b9c 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -142,7 +142,13 @@ jobs: run: | set -euo pipefail # GITHUB_TOKEN tag pushes don't fire build.yml in this setup, dispatch it. + # + # --ref pins the build to the stable tag (the frozen release-branch tip). The + # prior main-merge step usually leaves main at the stable version already, but + # relying on that is fragile; building the tag guarantees checkout has the + # matching package.json + code and enables signing/notarization (tag has no '-'). gh workflow run build.yml \ + --ref "${STABLE_TAG}" \ -f release_tag="${STABLE_TAG}" \ -f arch=both \ --repo "$GITHUB_REPOSITORY" From 49338719f13a7e2fd48c372d82f34da0133e4c26 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 16 Jul 2026 17:12:57 +0200 Subject: [PATCH 02/13] fix(release): name the release branch per stable version, reuse it across RCs prerelease.yml created release/v${PRERELEASE} (e.g. release/v1.7.0-rc.1) but promote.yml resolves release/v${STABLE_VERSION} (release/v1.7.0), so promote would die at `git fetch origin release/v1.7.0`. v1.7.0-rc.1 is the first RC cut under the release-branch freeze, so this path had never run. Worse, each re-cut created a *new* branch off main and force-deleted the remote branch first, so cutting rc.2 would both sweep in whatever had landed on main and destroy the cherry-picks on the rc.1 branch. That silently defeats the freeze the contract is supposed to guarantee. Use one branch per stable version (release/vX.Y.Z), created at rc.1 and reused for later RCs: fetch and check it out when it exists, branch from the dispatched ref when it doesn't, and never delete it. Fold the package.json bump into the same step so the version is written after the correct branch is checked out. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit d5966ed5aff956feb58a0b1603e27e7b512ab47e) --- .github/workflows/prerelease.yml | 53 +++++++++++++++++++------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f9e8d2c73..85b0f63af 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -75,34 +75,43 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: node .github/scripts/release-milestone-migrate.mjs - - name: Bump package.json to pre-release version - env: - PRERELEASE: ${{ steps.version.outputs.prerelease }} - run: | - set -euo pipefail - sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json - echo "package.json version:" - grep '"version"' package.json - - - name: Commit package.json bump on a release branch + - name: Create or reuse the release branch and bump package.json env: TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} PRERELEASE: ${{ steps.version.outputs.prerelease }} + NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # The release branch is FROZEN between RC cut and stable promotion. The bump - # commit lives on release/v${PRERELEASE} only; nothing is merged into main - # until promote.yml publishes the stable tag, so any features merged into - # main after this step are NOT in the RC build. - BRANCH="release/v${PRERELEASE}" - # Delete remote branch first so the push below is always fast-forward (idempotent on rerun). - git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":${BRANCH}" 2>/dev/null || true - git checkout -b "$BRANCH" + # ONE release branch per STABLE version (release/vX.Y.Z), created at rc.1 and + # FROZEN until promote publishes the stable tag. Later RCs (rc.2, rc.3, ...) are + # cut from this same branch, so they carry the rc.1 snapshot plus any + # cherry-picked bugfixes and NOT whatever has landed on main since. + # + # The name must stay in sync with promote.yml, which resolves + # release/v${STABLE_VERSION}. Naming this branch release/v${PRERELEASE} + # (with the -rc.N suffix) breaks promote and makes every re-cut a fresh + # branch off main, which silently defeats the freeze. + BRANCH="release/v${NEXT}" + REMOTE="https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + # Re-cut (rc.2+): build on the frozen branch. Never delete it — it carries the + # cherry-picks that make this RC differ from the previous one. + echo "Reusing frozen release branch ${BRANCH}" + git fetch origin "$BRANCH" + git checkout -B "$BRANCH" "origin/${BRANCH}" + else + # First cut (rc.1): branch from the dispatched ref (main). + echo "Creating release branch ${BRANCH} from ${GITHUB_REF_NAME}" + git checkout -b "$BRANCH" + fi + sed -i -E "s|(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1${PRERELEASE}\2|" package.json + echo "package.json version:" + grep '"version"' package.json git add package.json - git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" - git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" || echo "(version already at ${PRERELEASE})" + git push "$REMOTE" "$BRANCH" - name: Push RC tag on the release branch env: @@ -111,10 +120,10 @@ jobs: # Note: GITHUB_TOKEN tag pushes do NOT trigger build.yml in this org's setup, # so we explicitly trigger it via gh workflow run right after. RC_TAG: ${{ steps.version.outputs.rc_tag }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} + NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - BRANCH="release/v${PRERELEASE}" + BRANCH="release/v${NEXT}" git fetch origin "$BRANCH" git checkout "$BRANCH" git reset --hard "origin/${BRANCH}" From d75cb57e918c71d19d035c27180748caaeb780d1 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 09:09:20 +0200 Subject: [PATCH 03/13] fix(wgc): stop holding the frame mutex across blocking WriteSample calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The video-writer thread held the shared frame-state mutex across IMFSinkWriter::WriteSample, which the main thread's stop-wait also locks to check control.stopRequested. With the software H.264 encoder fallback (preferSoftwareEncoder: true), WriteSample is slow enough that the writer thread could keep re-acquiring the lock every frame faster than the main thread could win it after a stop request, starving the stop-wait indefinitely — explaining why no [stop-timing] line ever printed. Split MFEncoder::writeFrame/writeBgraFrame into a capture step (GPU readback + IMFSample construction, still under the shared mutex since it touches latestFrameTexture) and a submit step (WriteSample, only under the encoder's own low-contention writerMutex_), and call submit outside the shared mutex in main.cpp's writeVideoFrames. Fixes #115. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 7e3295949f97280a960faa9f831b62252ef8a327) --- electron/native/wgc-capture/src/main.cpp | 65 +++++++++--- .../native/wgc-capture/src/mf_encoder.cpp | 98 +++++++++++++------ electron/native/wgc-capture/src/mf_encoder.h | 21 +++- 3 files changed, 141 insertions(+), 43 deletions(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 810cf667c..120cf0b02 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -624,8 +624,8 @@ int main(int argc, char* argv[]) { // SampleTime we attach (confirmed empirically -- varying, correctly // increasing input timestamps still produced perfectly even output // spacing). Since we cannot make the encoder respect real capture - // time, we instead make the encoder's assumption true: call - // writeBgraFrame() on a real-time-paced cadence (duplicating the + // time, we instead make the encoder's assumption true: feed the + // webcam encoder on a real-time-paced cadence (duplicating the // latest available camera frame when the camera hasn't produced a // newer one yet), so "sample N is at N/fps" is actually correct. int64_t nextWebcamWriteDueHns = 0; @@ -633,6 +633,11 @@ int main(int argc, char* argv[]) { static_cast(10'000'000ULL / std::max(1, webcamCapture.fps())); while (!control.stopRequested && !encodeFailed) { + Microsoft::WRL::ComPtr videoSample; + Microsoft::WRL::ComPtr webcamSample; + bool hasVideoSample = false; + bool hasWebcamSample = false; + { std::unique_lock lock(mutex); control.cv.wait(lock, [&] { @@ -691,7 +696,7 @@ int main(int argc, char* argv[]) { // sequentially at its configured nominal rate regardless of the // SampleTime attached to each input sample. So the only way to // keep the encoded webcam file in sync with real elapsed time is - // to call writeBgraFrame() *at* that nominal cadence, duplicating + // to feed the encoder *at* that nominal cadence, duplicating // the latest available camera frame when the camera hasn't // produced a newer one yet (VFR capture -> CFR encode resampling). if (targetElapsedHns >= nextWebcamWriteDueHns) { @@ -699,11 +704,16 @@ int main(int argc, char* argv[]) { if (lastWebcamTimestampHns >= 0 && webcamTimestampHns <= lastWebcamTimestampHns) { webcamTimestampHns = lastWebcamTimestampHns + nominalWebcamIntervalHns; } - if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + // Capture the sample under `mutex` (the frame copy), but + // submit it to the sink writer OUTSIDE the mutex below + // (issue #115) so a slow WriteSample can't starve the main + // thread's stop-wait. + hasWebcamSample = webcamEncoder.captureBgraSample(webcamFrame, webcamTimestampHns, webcamSample); + if (!hasWebcamSample) { encodeFailed = true; control.stopRequested = true; control.cv.notify_all(); - return; + break; } lastWebcamTimestampHns = webcamTimestampHns; nextWebcamWriteDueHns += nominalWebcamIntervalHns; @@ -714,20 +724,49 @@ int main(int argc, char* argv[]) { } } } - if (latestFrameTexture && !encoder.writeFrame( + if (latestFrameTexture) { + // captureVideoSample performs the GPU readback + // (CopyResource/Map) from latestFrameTexture, which must + // stay serialized (via `mutex`) against the WGC + // frame-arrival callback above, which writes new data + // into the same texture on another thread. + hasVideoSample = encoder.captureVideoSample( latestFrameTexture.Get(), frameTimestampHns, - !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { - encodeFailed = true; - control.stopRequested = true; - control.cv.notify_all(); - return; - } - if (latestFrameTexture) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr, + videoSample); + if (!hasVideoSample) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } lastEncodedVideoTimestampHns = frameTimestampHns; } } + // Submit the captured samples to their sink writers OUTSIDE + // `mutex`. IMFSinkWriter::WriteSample runs the H.264 encode + // synchronously and can be slow (especially the software encoder + // fallback used when preferSoftwareEncoder is set). Holding + // `mutex` across it would block the main thread's stop-wait + // (which locks the same mutex to check control.stopRequested) + // for as long as this thread keeps re-acquiring the lock faster + // than the main thread can, hanging the helper indefinitely + // after a stop request (issue #115). + if (hasWebcamSample && !webcamEncoder.submitVideoSample(webcamSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + if (hasVideoSample && !encoder.submitVideoSample(videoSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + frameIndex += 1; std::this_thread::sleep_for(frameDuration); } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 42b708910..60f82e9f5 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -603,23 +603,38 @@ bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destinat return true; } -bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; + // The GPU readback below (copyFrameToBuffer -> CopyResource/Map on + // `texture`) is not internally synchronized here. Callers must hold their + // own lock around this call that also serializes against whatever thread + // writes new frame data into `texture` (see main.cpp's writeVideoFrames, + // which holds the shared frame-state mutex across this call but not + // across submitVideoSample). Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) { @@ -648,25 +663,34 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); + outSample = sample; + return true; } -bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); @@ -696,7 +720,25 @@ bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); + outSample = sample; + return true; +} + +bool MFEncoder::submitVideoSample(IMFSample* sample) { + if (!sample) { + return false; + } + + // This is the potentially slow, blocking step (especially with the + // software H.264 encoder fallback): IMFSinkWriter::WriteSample runs the + // encode synchronously on the calling thread. Callers must NOT hold any + // lock shared with a thread that needs to make timely progress (e.g. a + // stop-request check) across this call. + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample), "WriteSample"); } bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 8f5787481..e5fbd74c8 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -53,8 +53,25 @@ class MFEncoder { ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr, MFEncoderOptions options = {}); - bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); - bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); + // Capturing a video/webcam sample (GPU readback + IMFSample creation) is + // split from submitting it to the sink writer (IMFSinkWriter::WriteSample) + // so callers that hold an external lock across the GPU-touching capture + // step (to serialize against a producer thread writing into the same + // texture) are not forced to also hold that lock across the potentially + // slow, blocking WriteSample call. See main.cpp's writeVideoFrames for why + // this split exists: holding the shared frame-state mutex across + // WriteSample let the software H.264 encoder path starve the main + // thread's stop-request check indefinitely (issue #115). + bool captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample); + bool captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample); + bool submitVideoSample(IMFSample* sample); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); const char* videoEncoderSelection() const; From 21828bfd7dd163d5013ec78c2b2e03047eccf665 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 10:34:48 +0200 Subject: [PATCH 04/13] fix(recording): round window-capture dimensions up to even for WGC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #60: recording a single window (as opposed to a full display) on Windows produced a solid black video for the entire clip. Root cause: GraphicsCaptureItem::Size() for a window capture item reports the window's real client-area pixel dimensions verbatim, which are frequently odd (arbitrary resize, DPI rounding) — unlike monitor/display capture items, which are always even. H.264 encoding (and the RGB32->NV12 conversion feeding it) requires even width/height. The frame pool and captureWidth()/ captureHeight() (which configures the encoder's input media type) both used the raw, possibly-odd item size, so an odd-dimensioned window capture fed the Media Foundation H.264 encoder a size it can't encode correctly, producing black output. Fix: round window capture dimensions up to the nearest even value once, in WgcSession::createCaptureItem(HWND), and use that same rounded size (not the raw item size) for both the Direct3D11CaptureFramePool buffer and captureWidth()/captureHeight(), so every consumer of the session agrees on one even-dimensioned size. Monitor/display capture is untouched (always even in practice). Verified: built wgc-capture.exe via CMake/Ninja (VS 18 Insiders x64), opened a real Notepad window resized to an odd 789x595 client area, and drove the helper directly against it (sourceType:"window", real HWND). - Original (pre-fix) binary: 5.4KB output, ffmpeg blackdetect flags the entire clip as black (black_duration ~= full clip length). - Fixed binary: 36.5KB output (consistent with real, compressible frame content vs. a solid-color clip), ffmpeg blackdetect reports zero black segments across the same clip. - Sanity-checked normal display/monitor capture is unaffected (unchanged code path). (cherry picked from commit 527895638a2f84daf7925c6058596b8ec826491b) --- .../native/wgc-capture/src/wgc_session.cpp | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index e20096c4e..89f0b55fe 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -32,6 +32,28 @@ int64_t timeSpanToHns(wf::TimeSpan const& value) { return value.count(); } +// H.264 encoding (and the RGB32->NV12 conversion feeding it) requires even +// frame dimensions. Monitor resolutions are always even in practice, so +// CreateForMonitor items never hit this. Windows, however, frequently have +// odd client-area dimensions (arbitrary drag-resize, DPI rounding), and +// GraphicsCaptureItem::Size() reports the window's *actual* size verbatim. +// If we requested a Direct3D11CaptureFramePool sized to that odd value while +// the rest of the pipeline (main.cpp's bitrate calc, MFEncoder) rounds down +// to even, the frame pool's real DXGI textures end up one pixel wider/taller +// than the staging texture the encoder allocates. ID3D11DeviceContext:: +// CopyResource silently no-ops on a size mismatch (it only emits a debug- +// layer warning), so the staging texture never receives pixel data and the +// output is solid black for the entire recording -- or, if the mismatch +// trips up the video MFT's input negotiation, SetInputMediaType fails +// outright. Rounding up to the nearest even size here, and using that +// rounded size (not the raw item size) for both the frame pool and +// `captureWidth()`/`captureHeight()`, keeps every consumer of this session +// looking at the exact same dimensions as the real captured texture. +int roundUpToEven(int value) { + const int clamped = std::max(2, value); + return (clamped % 2 == 0) ? clamped : clamped + 1; +} + } // namespace WgcSession::~WgcSession() { @@ -135,8 +157,8 @@ bool WgcSession::createCaptureItem(HWND window) { item_ = item; const auto size = item_.Size(); - width_ = static_cast(size.Width); - height_ = static_cast(size.Height); + width_ = roundUpToEven(static_cast(size.Width)); + height_ = roundUpToEven(static_cast(size.Height)); return width_ > 0 && height_ > 0; } @@ -199,7 +221,7 @@ bool WgcSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { winrtDevice_, wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, - item_.Size()); + winrt::Windows::Graphics::SizeInt32{width_, height_}); session_ = framePool_.CreateCaptureSession(item_); if (!applySessionOptions(captureCursor)) { @@ -223,7 +245,7 @@ bool WgcSession::initialize(HWND window, int fps, bool captureCursor) { winrtDevice_, wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, - item_.Size()); + winrt::Windows::Graphics::SizeInt32{width_, height_}); session_ = framePool_.CreateCaptureSession(item_); if (!applySessionOptions(captureCursor)) { From 90d9bf2f5d6e876a480b8548802c973b2820b5e9 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 11:25:53 +0200 Subject: [PATCH 05/13] fix(recording): bound the video-writer frame wait as a hang safety net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #119 (Fixes #115). CodeRabbit flagged, on the now-superseded #121, that the video-writer thread's per-frame cv.wait() has no timeout: if a notification were ever missed, the thread would block indefinitely, hanging videoWriterThread.join() and the whole stop sequence — exactly the failure mode this release is eliminating. #119 already fixed the actual root cause (the blocking WriteSample call no longer runs under this mutex, so the main thread's stop-wait is never starved), but the wait itself was still unbounded. Switch it to wait_for(100ms) so the loop always re-checks stopRequested/encodeFailed even in that circumstance, instead of relying solely on a notification reaching an already-waiting thread. Verified: rebuilt wgc-capture.exe on top of current main (includes #119), re-ran the #115 repro (screen-only, all extras disabled) and the full-featured regression (system audio + cursor) - both stop in under 110ms with a full [stop-timing] log and a valid MP4. (cherry picked from commit 3f51d68c67ba19b682c133e4c37bcdf052ec0490) --- electron/native/wgc-capture/src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 120cf0b02..f8036c24a 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -640,7 +640,7 @@ int main(int argc, char* argv[]) { { std::unique_lock lock(mutex); - control.cv.wait(lock, [&] { + control.cv.wait_for(lock, std::chrono::milliseconds(100), [&] { return control.stopRequested.load() || encodeFailed.load() || (!control.paused.load() && latestFrameTexture); From cd088673e3a18fe7dcd3763cc499f043c6b40c6c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 12:10:47 +0200 Subject: [PATCH 06/13] fix(overlay): stop the HUD drag from drifting away from the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #100: dragging the HUD by its grip handle would drift away from the cursor rather than tracking it 1:1. Root cause: two independent IPC channels can both reposition the HUD BrowserWindow. Pointer drags send incremental deltas via hud-overlay-move-by; separately, a ResizeObserver watching the HUD's content calls hud-overlay-set-size whenever the observed content size changes, which recomputes the window's bounds from a bottom-centre anchor using the window's *current* bounds at the time of the call. If a resize observation fires mid-drag (even a spurious one from transient reflow), its anchor recompute races the drag's own incremental repositioning, and the two compound into a net offset — read by users as the HUD "drifting away". Fix: freeze content-size measurement while a drag is in progress (isDraggingHudRef), so hud-overlay-set-size cannot fire mid-drag. The content size is re-measured once via measureHudSize() right after the drag ends, so a real size change (e.g. from a toggle that was clicked mid-drag) still gets picked up promptly. Verified via computer-use against a real dev build: multiple multi-step drags (7-9 incremental pointer moves each, matching how a real drag event stream looks) all track the cursor to within a few px, both immediately on release and after a 2s settle -- no drift, no post-drop jump. (cherry picked from commit 348a8ef25af4315a12e843bace34359146ceb10c) --- src/components/launch/LaunchWindow.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 05a3bbc66..eccc3308e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -453,9 +453,16 @@ export function LaunchWindow() { // (instant, GPU-composited, no native resize at all), and only a genuinely new maximum // triggers the animated resize -- rare enough that its motion is acceptable. const hudAllocatedSizeRef = useRef({ width: 0, height: 0, orientation: trayLayout }); + const isDraggingHudRef = useRef(false); const measureHudSize = useCallback(() => { const barEl = hudBarRef.current; if (!barEl || !window.electronAPI?.setHudOverlaySize) return; + // While the user is dragging the HUD, ignore content-size measurements. A + // ResizeObserver-driven resize (hud-overlay-set-size) re-centres the window from + // its own bottom-centre anchor, which fights the position "hud-overlay-move-by" is + // actively applying frame-by-frame -- the two IPC channels racing is what produces + // the reported drift. Content size is re-measured once the drag ends instead. + if (isDraggingHudRef.current) return; // Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the // slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom @@ -809,6 +816,7 @@ export function LaunchWindow() { setHudMouseEventsEnabled(true); event.currentTarget.setPointerCapture(event.pointerId); dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; + isDraggingHudRef.current = true; }; const handleHudDragPointerMove = (event: React.PointerEvent) => { const lastPosition = dragLastPositionRef.current; @@ -825,6 +833,8 @@ export function LaunchWindow() { event.currentTarget.releasePointerCapture(event.pointerId); } setHudMouseEventsEnabled(false); + isDraggingHudRef.current = false; + measureHudSize(); }; return ( From c6cd9436aa7ae278b70282e5c7aa654677d7dbe5 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 14:14:29 +0200 Subject: [PATCH 07/13] test(launch): add regression coverage for HUD drag measurement suppression Per CodeRabbit review on #125: covers the fix's actual behavior end-to-end through the real drag handlers rather than testing isDraggingHudRef directly. Adds data-testid="hud-drag-handle" (the drag grip had no selector) and a test that: fires a real pointerdown on the handle, triggers a ResizeObserver callback mid-drag and asserts setHudOverlaySize is NOT called (the race this PR fixes), then fires pointermove/pointerup and asserts a measurement DOES happen once released -- so a real size change made mid-drag still gets picked up promptly. Full suite: 53 files / 424 tests pass. tsc --noEmit and biome check clean. (cherry picked from commit 8b4a118b37ea51e4009313458872d46d32e043d2) --- src/components/launch/LaunchWindow.test.tsx | 69 +++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 1 + 2 files changed, 70 insertions(+) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 154feef73..0b745c80c 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -460,6 +460,75 @@ describe("LaunchWindow system language prompt", () => { }); }); +describe("LaunchWindow HUD drag", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + // jsdom doesn't implement the Pointer Capture API; stub it so the drag handlers + // (which call set/has/releasePointerCapture) don't throw. + HTMLElement.prototype.setPointerCapture = vi.fn(); + HTMLElement.prototype.hasPointerCapture = vi.fn(() => true); + HTMLElement.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("suppresses ResizeObserver-driven measurement while dragging, and measures once on release", async () => { + renderLaunchWindow(); + + const dragHandle = await screen.findByTestId("hud-drag-handle"); + + // Give the bar a non-zero, changing size so a resize observation would actually + // trigger a `setHudOverlaySize` call if it weren't suppressed during the drag. + const bar = dragHandle.closest("[data-tray-layout]") as HTMLElement | null; + if (bar) { + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({ + top: 700, + left: 200, + right: 600, + bottom: 756, + width: 400, + height: 56, + x: 200, + y: 700, + toJSON: () => ({}), + }); + Object.defineProperty(bar, "scrollHeight", { value: 56, configurable: true }); + Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true }); + } + + const sizeMock = window.electronAPI.setHudOverlaySize as unknown as { + mockClear: () => void; + }; + sizeMock.mockClear(); + + fireEvent.pointerDown(dragHandle, { screenX: 100, screenY: 100 }); + + // Simulate a ResizeObserver firing mid-drag (e.g. transient reflow) -- this must + // NOT reposition/resize the HUD while the user's pointer is still down. + await act(async () => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); + expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled(); + + fireEvent.pointerMove(dragHandle, { screenX: 140, screenY: 130 }); + fireEvent.pointerUp(dragHandle, { screenX: 140, screenY: 130 }); + + // Content is re-measured once the drag ends, so a real size change made mid-drag + // still gets picked up promptly. + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + }); +}); + describe("LaunchWindow software encoder fallback notice", () => { beforeEach(() => { platformState.value = "darwin"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index eccc3308e..9f8f164c9 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1047,6 +1047,7 @@ export function LaunchWindow() { > {/* Drag handle */}
Date: Tue, 21 Jul 2026 01:14:00 +0200 Subject: [PATCH 08/13] feat(release): add Microsoft Store (MSIX) packaging Adds an appx target to electron-builder alongside the existing NSIS installer, with the app identity/capabilities reserved in Partner Center, and a build-windows-store CI job that produces the package on every tag build. NSIS remains the default Windows distribution channel; this is additive. (cherry picked from commit f508a215f034333af770e84821ac823007bd91f3) --- .github/workflows/build.yml | 31 +++++++++++++++++++++++++++++++ electron-builder.json5 | 13 +++++++++++++ package.json | 1 + 3 files changed, 45 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 625966ab3..e5b9b190b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,37 @@ jobs: if-no-files-found: error retention-days: 30 + build-windows-store: + name: Windows Store package + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + + # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp + # with native DTW token timestamps). No separate VAD model is fetched + # here. See docs/engineering/stt-spec.md for the full architecture. + + - name: Build Windows Store package + run: npm run build:win:store -- --publish never + + - name: Upload Windows Store package + uses: actions/upload-artifact@v4 + with: + name: openscreen-windows-store + path: release/**/Openscreen.Setup.*.appx + if-no-files-found: error + retention-days: 30 + build-macos: name: macOS ${{ matrix.arch }} DMG runs-on: macos-latest diff --git a/electron-builder.json5 b/electron-builder.json5 index ecf9055f0..cbce12e3e 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -123,5 +123,18 @@ "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true + }, + // Microsoft Store packaging identity, from Partner Center's "Product identity" page + // (Applications et jeux > OpenScreen > Product identity). + "appx": { + "identityName": "EtienneLescot.OpenScreen", + "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", + "publisherDisplayName": "Etienne Lescot", + "applicationId": "Openscreen", + "displayName": "OpenScreen", + "backgroundColor": "transparent", + "capabilities": ["runFullTrust", "microphone", "webcam"], + "languages": ["en-US", "fr-FR"], + "showNameOnTiles": true } } diff --git a/package.json b/package.json index 0a144abc9..7c1614046 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", "build:win": "npm run build:native:win && npm run build:native:compositor && npm run fetch:ffmpeg && tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:win:store": "npm run build:native:win && npm run build:native:compositor && npm run fetch:ffmpeg && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test": "vitest --run", From 1c01a873f0e884ba9e7f9bf3f532495fdfc0cec7 Mon Sep 17 00:00:00 2001 From: Glen Barnes Date: Fri, 17 Jul 2026 16:02:15 +1200 Subject: [PATCH 09/13] fix(export): mix all source audio tracks so the mic isn't dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native macOS recordings write system audio and the microphone as two separate AAC tracks in the screen recording, and both are flagged `default`. The exporter decoded audio through web-demuxer's bare "audio" selector, which resolves to the single stream FFmpeg's `av_find_best_stream` picks — the first (system-audio) track. When nothing was playing, that track is silent, so the exported video had no audible audio even though the mic was recorded fine. The browser recorder already blends system + mic into one track; the native path never did. Decode every audio stream and mix them into one timeline before encoding, mirroring the browser recorder: - `mixPlanarSources` (pure, unit-tested) sums each decoded source, downmixed to the target channels and aligned at its source-time offset, clamped to [-1, 1]. - Per-stream decode via `readAVPacket(streamIndex)` targets each track by container index instead of the best-stream heuristic. - The trim-only and offline (speed) export paths both mix multi-track sources; multi-track speed projects are routed to the offline path because the real-time