Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2064e47
chore(release): bump to 1.9.0-rc.1 [skip ci]
github-actions[bot] Aug 4, 2026
a61d7a2
fix(ci): sign the macOS .app ad-hoc when no certificate is available
EtienneLescot Aug 4, 2026
a3cda04
fix(recording): stream the native webcam to disk instead of losing it
Aug 4, 2026
13a4fc0
test(recording): make the re-index suite run off Linux
Aug 4, 2026
a19207a
fix(ci): notarize release candidates like stable releases
EtienneLescot Aug 4, 2026
f02696d
fix(build): declare all 13 supported locales in the AppX package
EtienneLescot Aug 4, 2026
34b43f1
ci(macos): reject a MAC_CSC_NAME that carries its certificate type
EtienneLescot Aug 4, 2026
2bfc179
fix(recording): stop the WGC helper from hanging on stop
EtienneLescot Aug 4, 2026
a1ab4d6
fix(recording): stop a webcam finalize from discarding the screen cap…
EtienneLescot Aug 4, 2026
d44f39f
ci(diagnostic): build the bundle for release branches too
EtienneLescot Aug 4, 2026
91e3db8
fix(export): stop drawing the screen inside the PiP box on camera-les…
EtienneLescot Aug 4, 2026
449ae33
fix(layout): resolve the webcam layout per clip, not once per timeline
EtienneLescot Aug 4, 2026
57dd154
chore(release): bump to 1.9.0-rc.2 [skip ci]
github-actions[bot] Aug 4, 2026
6e18e8b
fix(notes): keep the note stylesheet in the production bundle
EtienneLescot Aug 5, 2026
ad78e04
feat(editor): turn on roundness, shadow and motion blur by default
EtienneLescot Aug 5, 2026
c4c4f34
fix(cursor): make the Windows cursor sampler DPI-aware
EtienneLescot Aug 5, 2026
9e3b537
fix(hud): stop being born click-through, wait for the renderer to ask
EtienneLescot Aug 5, 2026
d1251c3
test(hud): pin that click-through is asked for, never born with
EtienneLescot Aug 5, 2026
6142886
docs(hud): replace the guessed exposure window with a measured one
EtienneLescot Aug 5, 2026
3e4b8c0
fix(agent): stop cutting the transcript at the 800th word
EtienneLescot Aug 4, 2026
128047f
docs(agent): the transcript tool no longer caps, so stop saying it does
EtienneLescot Aug 4, 2026
d179641
fix(ci): scope RC release notes to the previous RC
EtienneLescot Aug 5, 2026
4294526
feat(agent): add addTrims and addZooms, so a cut stops costing a roun…
EtienneLescot Aug 4, 2026
c3b03e0
fix(agent): let a malformed batch entry fall alone, like every other one
EtienneLescot Aug 5, 2026
d176169
fix(media): watch the background path refresh instead of firing it in…
EtienneLescot Aug 4, 2026
97b7697
test(media): inject the failed registry write instead of chmod-ing th…
EtienneLescot Aug 5, 2026
b509880
perf(test): run Vitest in node, and jsdom only where a DOM is needed
EtienneLescot Aug 5, 2026
2927a0c
docs(agents): run the tests you touched, not the whole suite each step
EtienneLescot Aug 5, 2026
b86546e
fix(ci-scripts): clear the thread-validation timeout when fetch rejects
EtienneLescot Aug 5, 2026
aa4a513
fix(renderer): handle the detached promises that can actually reject
EtienneLescot Aug 5, 2026
1ecaffb
chore(release): bump to 1.9.0-rc.3 [skip ci]
github-actions[bot] Aug 5, 2026
f4fd424
fix(ai): stop compacting the chat on a context window we never measured
EtienneLescot Aug 5, 2026
a86b042
chore(release): bump to 1.9.0-rc.4 [skip ci]
github-actions[bot] Aug 5, 2026
2879746
chore(release): bump to 1.9.0 [skip ci]
github-actions[bot] Aug 5, 2026
fed6743
fix(wgc): add GPU DXGI path for Windows capture readback
Seb1900 Aug 8, 2026
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
12 changes: 9 additions & 3 deletions .github/scripts/discord-thread-validator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ export async function validateThreadChannel(threadId, prNumber, { botToken, foru
return false;
}
const VALIDATION_TIMEOUT_MS = 5_000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
const res = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
headers: { Authorization: `Bot ${botToken}` },
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
warning(`Thread validation failed: channel ${threadId} returned ${res.status}`);
return false;
Expand All @@ -38,5 +37,12 @@ export async function validateThreadChannel(threadId, prNumber, { botToken, foru
} catch (err) {
warning(`Thread validation threw: ${err && err.message ? err.message : err}`);
return false;
} finally {
// `finally`, not a line after the await: when `fetch` rejects — a real network
// error — the timer would otherwise stay armed and fire `controller.abort()`
// five seconds later, long after this returned. Under Vitest that lands after
// the worker has torn down, which is an intermittent post-run error rather
// than a failing test. Same shape as `callDiscord` in discord-bot-api.mjs.
clearTimeout(timeout);
}
}
18 changes: 18 additions & 0 deletions .github/scripts/discord-thread-validator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,22 @@ describe("validateThreadChannel", () => {
const result = await validateThreadChannel("500", number, { botToken });
expect(result).toBe(false);
});

it("leaves no timer armed once it has returned, on either path", async () => {
vi.useFakeTimers();
try {
vi.mocked(fetch).mockRejectedValue(new Error("network error"));
await validateThreadChannel("500", number, { botToken });
// The failing path is the one that used to leak: `clearTimeout` sat after
// the `await`, so a rejected fetch skipped it and left the 5s abort timer
// running past the end of the test.
expect(vi.getTimerCount()).toBe(0);

vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404 });
await validateThreadChannel("404", number, { botToken });
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
154 changes: 141 additions & 13 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 Expand Up @@ -386,6 +474,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
# Full history + tags: the RC notes below are built from `git log` over the
# range since the previous RC tag, and resolving that tag needs the tags.
fetch-depth: 0

- name: Resolve release tag
id: release
Expand Down Expand Up @@ -433,6 +525,21 @@ jobs:
else
NOTES_START_TAG="v$((PX - 1)).0.0"
fi
# For an RC, compare against the PREVIOUS RC of the same line, not the previous
# stable. Deriving the start tag from STABLE_VERSION alone made every RC of a
# line span the same range, so each re-cut just repeated the last RC's notes
# plus its own handful, and testers could not see what the re-cut changed.
# Walk down from the current rc number so a skipped or failed RC doesn't break it.
if [[ "$IS_PRERELEASE" == "true" ]]; then
RC_NUMBER="${VERSION##*.}"
for (( n = RC_NUMBER - 1; n >= 1; n-- )); do
CANDIDATE="v${STABLE_VERSION}-rc.${n}"
if git rev-parse -q --verify "refs/tags/${CANDIDATE}" >/dev/null; then
NOTES_START_TAG="$CANDIDATE"
break
fi
done
fi
echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}"

echo "tag=$TAG" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -482,17 +589,38 @@ jobs:
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "${FILES[@]}" --clobber
else
# --notes-start-tag controls which previous tag GitHub compares against
# when auto-generating the release notes. Default behaviour (most recent
# prior release by date) doesn't work for this fork because the v1.4.0
# release in the fork was re-published after v1.5.0, which makes GitHub
# pick v1.4.0 as the "previous" for any v1.5.x release.
if [[ -n "$PRERELEASE_FLAG" ]]; then
# RC notes come from `git log`, not --generate-notes. GitHub's generator
# lists only the PRs it manages to associate, and on this repo it silently
# drops real ones — #254 and #261 were merged into the release branch and
# never appeared in v1.9.0-rc.2's body — so an RC could omit the very fix
# the re-cut was for. The commit range is the actual diff and can't lie.
# Stable releases keep --generate-notes below: they're the public-facing
# ones and want the PR links and the New Contributors section.
{
echo "## Changes since ${NOTES_START_TAG}"
echo
git log --no-merges --reverse --pretty='- %s' \
--invert-grep --grep='^chore(release): bump to' \
"${NOTES_START_TAG}..${TAG}"
echo
echo "**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}"
} > "${RUNNER_TEMP}/rc-notes.md"
cat "${RUNNER_TEMP}/rc-notes.md"
NOTES_ARGS=(--notes-file "${RUNNER_TEMP}/rc-notes.md")
else
# --notes-start-tag controls which previous tag GitHub compares against
# when auto-generating the release notes. Default behaviour (most recent
# prior release by date) doesn't work for this fork because the v1.4.0
# release in the fork was re-published after v1.5.0, which makes GitHub
# pick v1.4.0 as the "previous" for any v1.5.x release.
NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG")
fi
# shellcheck disable=SC2086
gh release create "$TAG" "${FILES[@]}" \
--target "$GITHUB_SHA" \
--title "$TAG" \
--generate-notes \
--notes-start-tag "$NOTES_START_TAG" \
"${NOTES_ARGS[@]}" \
$PRERELEASE_FLAG
fi

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
14 changes: 8 additions & 6 deletions .harness/docs/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,22 @@ Conventions for the Mavis reins when working in this repo.

## CI (`.github/workflows/ci.yml`)

CI runs on every PR to `main` and every push to `main`:
CI runs on every PR to `main`, `feat/ai-edition` and `release/**`, and on every push to those:
- `npm run lint` (Biome)
- `npx tsc --noEmit` (TypeScript)
- `npx tsc --noEmit` (TypeScript, app code)
- `npx tsc -p tsconfig.test.json --noEmit` (TypeScript, test files — a separate gate at zero)
- `npm run test` (Vitest unit)
- `npm run test:browser` (Vitest + Playwright headless)
- `npm run docs:check`
- `npx vite build` (renderer build smoke)
- `cargo test` / `cargo check` for the compositor on macOS, Windows and Linux

All five must be green before merge. Native helper code is NOT covered by CI — manual smoke test is required for `electron/*-helper/` changes; note it in the PR description.
All must be green before merge. Native helper code is NOT covered by CI — manual smoke test is required for `electron/*-helper/` changes; note it in the PR description.

## Pull request flow

1. Branch from `main`.
2. Implement + add tests in the same package.
3. Run locally: `npm run lint && npx tsc --noEmit && npm run test`. For browser/e2e-touching changes, also run the relevant suite.
3. While implementing, run only the affected tests (`npx vitest --run <path>` or `--changed`); `npx tsc --noEmit` and `npm run lint` are the cheap inner-loop checks. Run the full `npm run test` **once**, here, before pushing.
4. Push and open the PR via `gh pr create`. Use `.github/pull_request_template.md`.
5. Wait for the Mavis reviewer (`openscreen-reviewer`) PASS or address the requested changes.
6. Merge once CI is green and review is PASS. PR titles must follow Conventional Commits (enforced by the `semantic-pr` job in `ci.yml`) — this keeps the auto-generated release notes clean.
Expand All @@ -56,7 +58,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
4 changes: 3 additions & 1 deletion .harness/reins/openscreen-dev/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ You are the generalist implementer for the OpenScreen project — a free, open-s

- `npx tsc --noEmit` passes.
- `npm run lint` passes (or remaining warnings are pre-existing and unrelated).
- `npm run test` passes for any unit tests you added or affected.
- The tests you added or affected pass — run those files, `npx vitest --run <path>`, not the
whole suite. `npm run test` is minutes; run it once at the end if at all, and let CI be the
full-suite gate. Never `npm run test:watch` (it never terminates).
- The change is documented in the PR description (what + why + how to test).
- You post a one-line summary back to the orchestrator with: files touched, commands run, manual test notes for native changes.
Loading
Loading