Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 94 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then
# `CSC_NAME` must name the identity WITHOUT its certificate type.
# electron-builder picks the type itself and rejects a qualified name
# outright:
#
# ⨯ Please remove prefix "Developer ID Application:" from the
# specified name — appropriate certificate will be chosen
# automatically
#
# It does that at `Package .app bundle`, which sits after the ffmpeg
# build and the compositor addon — about twelve minutes in, and only
# on macOS. Since the same secret also feeds `codesign --sign` at
# `Sign DMG`, the mistake is easy to make: codesign accepts the full
# common name, so the qualified form looks right until electron-builder
# sees it. The short form satisfies both, because codesign matches on a
# substring of the common name.
case "$MAC_CSC_NAME" in
# Every pattern ends at the colon on purpose, so a company whose
# name merely starts with one of these words is not rejected.
"Developer ID Application:"*|"Developer ID Installer:"*|"Apple Development:"*|"Apple Distribution:"*|"3rd Party Mac Developer Application:"*|"3rd Party Mac Developer Installer:"*)
echo "::error::MAC_CSC_NAME carries a certificate-type prefix. Set it to the identity name alone, e.g. 'Jane Doe (AB12CD34EF)' rather than 'Developer ID Application: Jane Doe (AB12CD34EF)'. Read it from: security find-identity -v -p codesigning"
exit 1
;;
esac
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -260,9 +283,52 @@ jobs:
exit 1
fi

# electron-builder used to do this itself. Its macPackager carried a
# `noIdentity && fallBackToAdhoc` branch that handed back `Identity("-")`
# whenever no certificate was found — mandatory on arm64, where an unsigned
# binary will not launch at all. 26.15.3 replaced that path with
# `findSigningIdentity`, which returns null instead, and `sign()` leaves on
# `return false`. Nothing signs the bundle, and what ships is the bare
# linker signature on the Electron binary: `Identifier=Electron`,
# `Sealed Resources=none`.
#
# That is not cosmetic. macOS keys TCC grants to an app's code signature,
# so a bundle signed as "Electron" cannot hold one. v1.9.0-rc.1 asked for
# Accessibility, the user granted it, `AXIsProcessTrusted()` still returned
# false, and the editable-cursor preflight in useScreenRecorder re-opened
# the same dialog on every press of record — recording was impossible.
#
# Signed with the same runtime and entitlements electron-builder applies,
# so a locally signed build and a certificate-signed one differ only in the
# identity. Both arches on purpose: 26.8.1 only fell back on arm64, which
# left Intel DMGs unsigned for their whole existence.
- name: Ad-hoc sign the .app
if: steps.signing.outputs.enabled != 'true'
run: |
codesign --force --deep --sign - \
--options runtime \
--entitlements macos.entitlements \
"${{ steps.find_app.outputs.app_bundle }}"

# UNCONDITIONAL. Gated on `enabled == 'true'`, this step never ran for the
# RC builds — the only ones that could be unsigned — so the regression
# above shipped with every macOS check in this job green.
- name: Verify .app code signature
if: steps.signing.outputs.enabled == 'true'
run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
run: |
APP="${{ steps.find_app.outputs.app_bundle }}"
codesign --verify --deep --strict "$APP"

# The identifier, not just the structure: `--verify` passes on the bare
# linker signature too, so it alone would not have caught this. What
# distinguishes a bundle macOS can attach permissions to is that its
# signing identifier matches the bundle id.
EXPECTED="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")"
ACTUAL="$(codesign -dv --verbose=2 "$APP" 2>&1 | sed -n 's/^Identifier=//p')"
echo "signature identifier=${ACTUAL} expected=${EXPECTED}"
if [[ "$ACTUAL" != "$EXPECTED" ]]; then
echo "::error::The .app is signed as '${ACTUAL}', not '${EXPECTED}' — macOS cannot attach Accessibility or Screen Recording permissions to a bundle whose signature does not carry its own identifier"
exit 1
fi

- name: Create DMG
id: dmg
Expand Down Expand Up @@ -301,16 +367,38 @@ jobs:
rm -rf "$STAGING"
echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT"

# The four steps below used to carry `&& !contains(github.ref_name, '-')`,
# which skipped them for every pre-release, `-rc.N` tags included. Two
# costs, and the second is the one that mattered.
#
# Testers paid the first: a DMG signed with Developer ID but not notarized
# is still refused by Gatekeeper — `spctl` answers `rejected, source=
# Unnotarized Developer ID` — so every RC tester had to know about
# `xattr -rd com.apple.quarantine` before they could open the thing they
# were being asked to test.
#
# The release paid the second. With the skip in place, notarization never
# ran until the stable tag, so the first exercise of the credentials, the
# certificate chain and Apple's acceptance of every nested Mach-O landed on
# the highest-stakes build there is. That is not theoretical: the run that
# first enabled signing here died in `Package .app bundle` on a malformed
# `MAC_CSC_NAME`, and it was only visible because a full build was run
# deliberately. Notarizing each RC turns every candidate into a rehearsal.
#
# The trade is a few minutes per macOS job and a dependency on Apple's
# notary service being reachable — `--wait` is capped at 15 minutes below.
# If that ever becomes flaky enough to block RCs, the fix is
# `continue-on-error` on pre-releases, not going back to skipping them.
- name: Sign DMG
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
if: steps.signing.outputs.enabled == 'true'
run: |
codesign --force \
--sign "${{ secrets.MAC_CSC_NAME }}" \
--timestamp \
"${{ steps.dmg.outputs.dmg_path }}"

- name: Notarize DMG
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
if: steps.signing.outputs.enabled == 'true'
run: |
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
--apple-id "${{ secrets.APPLE_ID }}" \
Expand All @@ -320,11 +408,11 @@ jobs:
timeout-minutes: 15

- name: Staple notarization ticket
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
if: steps.signing.outputs.enabled == 'true'
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"

- name: Validate stapled DMG
if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-')
if: steps.signing.outputs.enabled == 'true'
run: |
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/diagnostic-artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ name: Diagnostic artifact

on:
push:
branches: [main]
branches: [main, "release/**"]
# Release branches too: a recording fix targeting a release is exactly when a
# reviewer needs the compiled helper, and filtering on main alone meant
# retargeting a PR silently removed the artifact its own test steps ask for.
pull_request:
branches: [main]
branches: [main, "release/**"]
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .harness/docs/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The workflow:
1. Computes the next SemVer from `package.json` + `bump`, builds `vX.Y.Z-rc.N`.
2. Migrates every issue/PR in the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone. Each migrated item gets a hidden marker comment so re-running is idempotent.
3. Commits `package.json` → `X.Y.Z-rc.N` on a fresh branch `release/vX.Y.Z-rc.N`. **The branch is NOT merged into `main`** — it stays frozen so the RC build only contains what was on `main` at the moment of cut.
4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). macOS notarization is skipped on RC tags.
4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). RC tags are signed and notarized like stable ones, so testers do not have to clear the quarantine attribute by hand.
5. Posts in `#rc-testing` on Discord with the download link.

Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're already gated on `!prerelease`.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi

Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC) first, then promote to stable. Trunk-based, no extra branch. Full operational guide in `.harness/docs/git-workflow.md` § Release flow.

- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. Notarization is skipped on RCs. Notifies `#rc-testing` on Discord.
- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. RCs are notarized like stable releases, which also rehearses the credentials before the promotion build depends on them. Notifies `#rc-testing` on Discord.
- **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord.
- **Manual fallback**: `git tag vX.Y.Z-rc.N <sha> && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts.

Expand Down
6 changes: 6 additions & 0 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,12 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du
/// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

pub fn set_scene(&self, s: Option<Scene>) {
*self.scene.borrow_mut() = s;
}
Expand Down
6 changes: 6 additions & 0 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Cf. `compositor_windows::set_has_webcam` — le seul champ de `LiveParams` qui dépend du
/// clip courant, rebranché par `walk_composited_timeline` sans écraser le reste.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

pub fn set_scene(&self, s: Option<Scene>) {
*self.scene.borrow_mut() = s;
}
Expand Down
8 changes: 8 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,14 @@ impl Compositor {
*self.live_params.borrow_mut() = p;
}

/// Rebranche le seul champ qui dépend du CLIP et non des réglages (cf. `LiveParams::has_webcam`).
/// L'export pose ses `LiveParams` une fois pour toute la timeline, mais chaque clip a sa propre
/// réponse à « y a-t-il une caméra ? » : d'où un setter ciblé plutôt qu'un `set_live_params`
/// par clip, qui écraserait les réglages posés par l'appelant.
pub fn set_has_webcam(&self, v: bool) {
self.live_params.borrow_mut().has_webcam = v;
}

/// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements
/// depuis le layout preset au lieu du planning fixture.
pub fn set_scene(&self, s: Option<Scene>) {
Expand Down
31 changes: 28 additions & 3 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,37 @@ pub struct LiveParams {
/// False when the "webcam" decoder is actually just the screen video again (the TS side
/// falls `webcamPath` back to the screen asset's own path when a clip has no real camera,
/// purely so the decoder pipeline has something valid to open) — drawing the PiP box in
/// that case duplicates the screen video into its own corner. Live-only: derived in
/// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw)
/// so fixture/bench renders and any caller that never sets it keep their old behavior.
/// that case duplicates the screen video into its own corner. Derived per clip from the
/// screen/webcam paths via `webcam_is_real`: in `live.rs` for the preview, in
/// `timeline_walk.rs` for every export. Defaults `true` (draw) so fixture/bench renders
/// and any caller that never sets it keep their old behavior.
pub has_webcam: bool,
}

fn same_source_path(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}

/// True when this clip really has a camera to draw.
///
/// TWO ways the app says "no camera", and both must be caught here, because the
/// webcam decoder is opened either way — the live path falls back to the SCREEN
/// file when the webcam path won't open, and `ExportDialog` sends the screen path
/// outright, so the decoder always yields frames. Whether those frames are the
/// camera or a second copy of the screen is decided HERE and nowhere else.
///
/// - the empty string, which is what `sceneDescription.ts` and
/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`;
/// - the screen's own path, which `ExportDialog.tsx` sends and which older
/// scenes still use.
///
/// Missing the empty-string case is what put the screen recording inside the PiP
/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind
/// it was the screen fallback.
pub fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool {
!webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path)
}

impl Default for LiveParams {
fn default() -> Self {
Self {
Expand Down
21 changes: 1 addition & 20 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use crate::scene::Scene;
use crate::config::{self, Cfg};
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::Decoder;
use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime};
use anyhow::Result;
Expand Down Expand Up @@ -590,26 +591,6 @@ fn same_source_path(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}

/// True when the active clip really has a camera to draw.
///
/// TWO ways the app says "no camera", and both must be caught here, because the
/// webcam decoder is opened either way — `open_and_seek_clip` falls back to the
/// SCREEN file when the webcam path won't open, so `wdec` always yields frames.
/// Whether those frames are the camera or a second copy of the screen is decided
/// HERE and nowhere else.
///
/// - the empty string, which is what `sceneDescription.ts` and
/// `NativeCompositorOverlay` send for an asset with no `cameraTrack`;
/// - the screen's own path, the older convention kept working for scenes that
/// still use it.
///
/// Missing the empty-string case is what put the screen recording inside the PiP
/// box: `"" != "/…/recording.mp4"`, so the box was drawn, and the decoder behind
/// it was the screen fallback.
fn webcam_is_real(webcam_path: &str, screen_path: &str) -> bool {
!webcam_path.trim().is_empty() && !same_source_path(webcam_path, screen_path)
}

fn scene_clip_matches(
clip: &crate::scene::SceneClip,
screen_path: &str,
Expand Down
8 changes: 8 additions & 0 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::compositor::Compositor;
use crate::config::Cfg;
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::{ClipSource, Decoder};
use crate::regions::{speed_segments_for_window, SpeedSegment};
use crate::scene::Scene;
Expand Down Expand Up @@ -164,6 +165,13 @@ pub(crate) unsafe fn walk_composited_timeline(
let mut frames: u64 = 0;

for (clip_index, clip) in clips.iter().enumerate() {
// Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la
// caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam
// et un import qui n'en a pas. Le preset ne doit donc s'appliquer qu'aux clips qui ont
// vraiment une caméra — sinon la boîte PiP est dessinée avec, derrière, le décodeur de
// repli, c'est-à-dire l'écran lui-même recopié dans son propre coin (issue #248).
// La preview vive fait exactement ça dans `live.rs` ; c'est ici l'équivalent export.
comp.set_has_webcam(webcam_is_real(&clip.webcam, &clip.screen));
if !screen_decs.contains_key(&clip.screen) {
screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?);
}
Expand Down
7 changes: 6 additions & 1 deletion crates/poc-d3d/src/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub fn run() -> Result<()> {

// poc-d3d.exe --cfg C0..C8 --fixture <dir> --repeat 3 --out out/
// --cfg GIF → bench natif GIF (slice 1)
// --webcam <path> → force le chemin caméra (défaut `<fixture>/webcam.mp4`)
fn run_bench(args: &[String]) -> Result<()> {
let get = |k: &str, d: &str| -> String { arg(args, k, d) };
let fixture = get("--fixture", "fixture");
Expand All @@ -56,7 +57,11 @@ fn run_bench(args: &[String]) -> Result<()> {
let cfg_arg = get("--cfg", "C0..C8");

let screen = format!("{fixture}/screen.mp4");
let webcam = format!("{fixture}/webcam.mp4");
// Override explicite parce que le cas « pas de caméra » n'est PAS un fichier
// différent : l'app renvoie le chemin de l'écran lui-même (`ExportDialog`) ou la
// chaîne vide (`sceneDescription`). Le reproduire demande donc de piloter le chemin,
// pas le contenu — `--webcam <screen.mp4>` rejoue exactement l'issue #248.
let webcam = get("--webcam", &format!("{fixture}/webcam.mp4"));
std::fs::create_dir_all(&out).ok();

// sélection des cfg
Expand Down
Loading
Loading