Skip to content

Commit 4cd7fb4

Browse files
committed
Merge branch 'main' into fix/1130-macos-mouse-pointer-alignment
2 parents bb751db + 1e91710 commit 4cd7fb4

241 files changed

Lines changed: 4354 additions & 990 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude-plugin/marketplace.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "openscreen",
3+
"owner": { "name": "Etienne Lescot", "url": "https://getopenscreen.com" },
4+
"description": "OpenScreen's plugin for Claude Code: demo videos made with the free, open-source screen recorder and editor.",
5+
"plugins": [
6+
{
7+
"name": "openscreen",
8+
"source": "./plugin",
9+
"description": "Make a demo video from one sentence: record the screen, edit and export it with OpenScreen."
10+
}
11+
]
12+
}

‎.gitattributes‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ CMakeLists.txt text eol=lf
1919

2020
# Adopted measurement manifests hash exact bytes; keep their artifacts unchanged.
2121
workbench/measurements/** -text
22+
23+
# Claude's plugin directory refuses a repository whose GitHub archive exceeds
24+
# 50 MiB, and this one was at 50.4. These ~8 MB of README media are copies of
25+
# files in public/, served from the tree (README, raw.githubusercontent links),
26+
# never from an archive, so leaving them out of archives costs nothing.
27+
docs/assets/** export-ignore

‎.github/scripts/previous-stable-tag.mjs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
// keeps the RC notes' `git log <tag>..` from dying on `unknown revision`, as
1313
// v1.9.4-rc.1's did when the guess named a 1.9.3 that never shipped.
1414

15-
import { readFileSync } from "node:fs";
16-
import { argv } from "node:process";
15+
import { argv, stdin } from "node:process";
16+
// Not readFileSync(0): on a non-blocking stdin it throws EAGAIN instead of waiting,
17+
// which failed promote.yml's main sync at v2.1.2. The stream waits for the data.
18+
import { text } from "node:stream/consumers";
1719

1820
const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
1921

@@ -37,5 +39,5 @@ export function previousStableTag(version, tags) {
3739

3840
// Only run when invoked directly, so the test can import the function.
3941
if (import.meta.filename === argv[1]) {
40-
console.log(previousStableTag(argv[2], readFileSync(0, "utf8").split(/\s+/)));
42+
console.log(previousStableTag(argv[2], (await text(stdin)).split(/\s+/)));
4143
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Prints the release-branch commits that main does not have, version bumps aside.
2+
// Reads `git cherry -v origin/main origin/release/vX.Y.Z` on stdin; prints nothing
3+
// when main already holds every fix.
4+
//
5+
// promote.yml syncs only the version into main: every fix on a release branch is a
6+
// cherry-pick of a commit already on main, so the bumps are all main lacks. A fix
7+
// committed straight to the release branch breaks that contract, and this is what
8+
// names it instead of leaving it behind. A cherry-pick resolved differently from its
9+
// original also shows up here: `git cherry` matches on the patch, not the subject.
10+
11+
import { argv, stdin } from "node:process";
12+
// Not readFileSync(0): on a non-blocking stdin it throws EAGAIN instead of waiting,
13+
// which failed promote.yml's main sync at v2.1.2. The stream waits for the data.
14+
import { text } from "node:stream/consumers";
15+
16+
const VERSION_BUMP = /^chore\(release\): bump to /;
17+
18+
/**
19+
* @param {string} cherry Output of `git cherry -v <main> <release>`.
20+
* @returns {string[]} "<sha> <subject>" for each commit missing from main.
21+
*/
22+
export function unsyncedFixes(cherry) {
23+
return cherry
24+
.split("\n")
25+
.filter((line) => line.startsWith("+ "))
26+
.map((line) => line.slice(2))
27+
.filter((commit) => !VERSION_BUMP.test(commit.slice(commit.indexOf(" ") + 1)));
28+
}
29+
30+
// Only run when invoked directly, so the test can import the function.
31+
if (import.meta.filename === argv[1]) {
32+
for (const commit of unsyncedFixes(await text(stdin))) console.log(commit);
33+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { spawn } from "node:child_process";
2+
import { fileURLToPath } from "node:url";
3+
import { describe, expect, it } from "vitest";
4+
import { unsyncedFixes } from "./release-unsynced-fixes.mjs";
5+
6+
// `git cherry -v origin/main origin/release/v2.1.0-sync` at the v2.1.0 promotion: the
7+
// fixes were cherry-picks already on main ("-"), only the version bumps were not ("+").
8+
const V2_1_0 = [
9+
"- 421802f3f fix(export): no Full Camera trail at a cut whose chunk start rounds up",
10+
"+ 216aed223 chore(release): bump to 2.1.0-rc.1 [skip ci]",
11+
"- 57686f501 fix(preview): restart the playback clock at the requested time on a held frame",
12+
"+ 36b3814f0 chore(release): bump to 2.1.0-rc.6 [skip ci]",
13+
"+ 514105057 chore(release): bump to 2.1.0 [skip ci]",
14+
"",
15+
].join("\n");
16+
17+
describe("unsyncedFixes", () => {
18+
it("finds nothing when main has every fix and lacks only the bumps", () => {
19+
expect(unsyncedFixes(V2_1_0)).toEqual([]);
20+
});
21+
22+
it("names a fix committed to the release branch alone", () => {
23+
const cherry = `${V2_1_0}+ 0a1b2c3d4 fix(export): patch made on the release branch only\n`;
24+
expect(unsyncedFixes(cherry)).toEqual([
25+
"0a1b2c3d4 fix(export): patch made on the release branch only",
26+
]);
27+
});
28+
29+
it("keeps a commit that only mentions a bump further in its subject", () => {
30+
const cherry = "+ 0a1b2c3d4 fix(ci): do not chore(release): bump to twice\n";
31+
expect(unsyncedFixes(cherry)).toEqual([
32+
"0a1b2c3d4 fix(ci): do not chore(release): bump to twice",
33+
]);
34+
});
35+
36+
it("finds nothing in empty input", () => {
37+
expect(unsyncedFixes("")).toEqual([]);
38+
});
39+
40+
it("waits for stdin that arrives late, as `git cherry` writes it", async () => {
41+
const script = fileURLToPath(new URL("./release-unsynced-fixes.mjs", import.meta.url));
42+
const child = spawn(process.execPath, [script]);
43+
let out = "";
44+
child.stdout.on("data", (chunk) => {
45+
out += chunk;
46+
});
47+
setTimeout(() => child.stdin.end("+ 0a1b2c3d4 fix(export): release branch only\n"), 200);
48+
const code = await new Promise((resolve) => child.on("close", resolve));
49+
expect({ code, out }).toEqual({ code: 0, out: "0a1b2c3d4 fix(export): release branch only\n" });
50+
});
51+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Exits 0 when the first version is at or past the second, 1 when it is behind.
2+
// promote.yml asks it whether main already has the stable version: syncing on
3+
// inequality alone would set main back to 2.0.1 if 2.0.1 were promoted after
4+
// 2.1.0. A prerelease sits below the release of the same core: 2.1.0-rc.1 < 2.1.0.
5+
6+
import { argv, exit } from "node:process";
7+
8+
const VERSION = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/;
9+
10+
const parse = (version) => {
11+
const match = VERSION.exec(version);
12+
if (!match) throw new Error(`invalid version ${JSON.stringify(version)}`);
13+
return { core: match.slice(1, 4).map(Number), prerelease: Boolean(match[4]) };
14+
};
15+
16+
/**
17+
* @param {string} version e.g. main's package.json version.
18+
* @param {string} target e.g. the stable version just released.
19+
*/
20+
export function versionAtLeast(version, target) {
21+
const a = parse(version);
22+
const b = parse(target);
23+
const order = a.core[0] - b.core[0] || a.core[1] - b.core[1] || a.core[2] - b.core[2];
24+
return order > 0 || (order === 0 && (!a.prerelease || b.prerelease));
25+
}
26+
27+
// Only run when invoked directly, so the test can import the function.
28+
if (import.meta.filename === argv[1]) {
29+
exit(versionAtLeast(argv[2], argv[3]) ? 0 : 1);
30+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { versionAtLeast } from "./version-at-least.mjs";
3+
4+
describe("versionAtLeast", () => {
5+
it("holds for the same version", () => {
6+
expect(versionAtLeast("2.1.0", "2.1.0")).toBe(true);
7+
});
8+
9+
it("holds when main is past the release, so promote never sets it back", () => {
10+
expect(versionAtLeast("2.1.0", "2.0.1")).toBe(true);
11+
expect(versionAtLeast("3.0.0", "2.10.0")).toBe(true);
12+
});
13+
14+
it("fails when main is behind", () => {
15+
// v2.1.0's promotion found main at 2.0.0.
16+
expect(versionAtLeast("2.0.0", "2.1.0")).toBe(false);
17+
expect(versionAtLeast("2.9.0", "2.10.0")).toBe(false);
18+
});
19+
20+
it("puts a prerelease below the release of the same core", () => {
21+
expect(versionAtLeast("2.1.0-rc.1", "2.1.0")).toBe(false);
22+
expect(versionAtLeast("2.2.0-rc.1", "2.1.0")).toBe(true);
23+
});
24+
25+
it("refuses what is not a version", () => {
26+
expect(() => versionAtLeast("v2.1.0", "2.1.0")).toThrow(/invalid version/);
27+
});
28+
});

‎.github/workflows/promote.yml‎

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -152,48 +152,80 @@ jobs:
152152
# Bookkeeping, not publishing: the release is already out by now. Allowed to fail
153153
# so a stuck PR never masks a successful release — the summary reports it and it
154154
# is trivially redone by hand.
155-
- name: Merge release branch into main
155+
- name: Sync the released version into main
156156
id: sync_main
157157
continue-on-error: true
158158
env:
159159
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
160160
STABLE_VERSION: ${{ steps.version.outputs.stable_version }}
161161
run: |
162162
set -euo pipefail
163-
# After the stable tag is published, sync main with the released snapshot via
164-
# a rebase PR (PAT is a ruleset bypass actor so no approval is needed).
163+
# Main gets the release's version, not its history. Every fix on the release
164+
# branch is a cherry-pick of a commit already on main, so the version bumps are
165+
# all main lacks. This step used to rebase-merge the whole release branch,
166+
# replaying those cherry-picks too, and once main had moved on they no longer
167+
# applied: v2.1.0's sync PR (#1312) failed five times and main stayed at 2.0.0
168+
# until #1313 did by hand what this step does now. The PR is rebase-merged via
169+
# the PAT, a ruleset bypass actor, so no approval is needed.
165170
BRANCH="release/v${STABLE_VERSION}"
166-
# If main already contains the release branch (clean fast-forward), there's
167-
# nothing to merge — just make sure the branch is tracked locally.
168171
git fetch origin main "$BRANCH"
169-
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main; then
170-
echo "origin/${BRANCH} is already an ancestor of origin/main — nothing to merge."
172+
main_version() {
173+
# An argument, not stdin: readFileSync(0) can throw EAGAIN on a CI pipe.
174+
node -p 'JSON.parse(process.argv[1]).version' "$(git show origin/main:package.json)"
175+
}
176+
# At or past the release, not just equal: promoting an older version after a
177+
# newer one must not set main back.
178+
main_is_current() {
179+
node .github/scripts/version-at-least.mjs "$(main_version)" "$STABLE_VERSION"
180+
}
181+
# From here on, main's own scripts: a release branch cut before one of them
182+
# existed would not have it.
183+
git checkout -B "${BRANCH}-sync" origin/main
184+
# A fix committed to the release branch alone would be left out of a
185+
# version-only sync. Name it, sync the version anyway, and fail the step so
186+
# the summary says main is incomplete.
187+
UNSYNCED=$(git cherry -v origin/main "origin/${BRANCH}" | node .github/scripts/release-unsynced-fixes.mjs)
188+
finish() {
189+
if [[ -n "$UNSYNCED" ]]; then
190+
echo "::error::origin/${BRANCH} has commits main lacks, and the version sync leaves them out. Cherry-pick them onto main, or check that main has them in another form:"
191+
echo "$UNSYNCED"
192+
exit 1
193+
fi
171194
exit 0
195+
}
196+
if main_is_current; then
197+
echo "main is at $(main_version), not behind ${STABLE_VERSION}: nothing to sync."
198+
finish
172199
fi
173-
# Push the release branch's commits onto main as a fresh branch and PR it.
174-
git checkout -b "${BRANCH}-sync" "origin/${BRANCH}"
175-
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH}-sync"
200+
node .github/scripts/set-release-version.mjs "${STABLE_VERSION}"
201+
# The lockfile's version is part of Nix's npm hash, and the hash must match
202+
# main's lockfile, not the release's.
203+
bash .github/scripts/refresh-nix-npm-hash.sh
204+
git add package.json package-lock.json nix/package.nix
205+
git commit -m "chore(release): release v${STABLE_VERSION} into main"
206+
# --force: a rerun commits again on top of a newer main.
207+
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH}-sync"
176208
gh pr create \
177209
--base main \
178210
--head "${BRANCH}-sync" \
179211
--title "chore(release): release v${STABLE_VERSION} into main" \
180-
--body "Sync main with the released snapshot (RC + cherry-picked bugfixes + version bump). Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \
212+
--body "Sets main to ${STABLE_VERSION}, the version just released. Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \
181213
--repo "$GITHUB_REPOSITORY" || echo "(PR already exists — skipping)"
182214
PR_NUMBER=$(gh pr list --head "${BRANCH}-sync" --state open --json number -q '.[0].number' --repo "$GITHUB_REPOSITORY")
183215
if [[ -z "$PR_NUMBER" ]]; then
184216
# L'échec de `gh pr create` ci-dessus est converti en echo, donc on
185217
# arrive ici aussi bien quand la PR existait déjà que quand sa création
186218
# a échoué pour une autre raison. Sortir en 0 dans le second cas
187-
# reportait sync_main en succès alors que le test d'ascendance du haut
188-
# avait justement établi que main ne contient pas la branche, et
189-
# l'avertissement de fin sautait. On re-teste plutôt que de supposer :
190-
# quelqu'un a pu merger la synchro à la main entre-temps.
191-
git fetch origin main "$BRANCH"
192-
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main; then
193-
echo "origin/${BRANCH} is already an ancestor of origin/main — nothing to merge."
194-
exit 0
219+
# reportait sync_main en succès alors que le test de version du haut
220+
# avait justement établi que main n'est pas à jour, et l'avertissement
221+
# de fin sautait. On re-teste plutôt que de supposer : quelqu'un a pu
222+
# merger la synchro à la main entre-temps.
223+
git fetch origin main
224+
if main_is_current; then
225+
echo "main is at $(main_version), not behind ${STABLE_VERSION}: nothing to sync."
226+
finish
195227
fi
196-
echo "::error::No open sync PR exists and main does not contain ${BRANCH}."
228+
echo "::error::No open sync PR exists and main is behind ${STABLE_VERSION}."
197229
exit 1
198230
fi
199231
# GitHub computes mergeability asynchronously. Merging ~3s after creating the
@@ -226,6 +258,7 @@ jobs:
226258
# The release branch itself was already used to publish and contains frozen
227259
# history — leave it in place for forensics. A v1.6.0 release branch should
228260
# never be deleted until the next major cuts over.
261+
finish
229262
230263
- name: Workflow summary
231264
run: |
@@ -238,8 +271,9 @@ jobs:
238271
if [[ "${{ steps.sync_main.outcome }}" != "success" ]]; then
239272
echo ""
240273
echo "> [!WARNING]"
241-
echo "> **main was not synced.** The release is published and the build was"
242-
echo "> dispatched; only the \`release/v${{ steps.version.outputs.stable_version }}-sync\` → main PR is"
243-
echo "> outstanding. Merge it by hand, then main is back in line."
274+
echo "> **main sync is incomplete.** The release is published and the build was"
275+
echo "> dispatched. The \`Sync the released version into main\` step's error says what is"
276+
echo "> left: the \`release/v${{ steps.version.outputs.stable_version }}-sync\` → main PR to merge by hand,"
277+
echo "> or release-branch commits that main lacks."
244278
fi
245279
} >> "$GITHUB_STEP_SUMMARY"

‎README.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
# <p align="center">OpenScreen</p>
66

7+
<p align="center"><strong>Make a demo video with one sentence in your AI agent:</strong></p>
8+
9+
```text
10+
Install the OpenScreen demo skill with `npx skills add getopenscreen/openscreen --skill openscreen-demo -y`, then follow it to make a demo video of this feature.
11+
```
12+
713
<p align="center"><strong>A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.</strong></p>
814

915
<p align="center">
@@ -63,30 +69,31 @@ openscreen export demo.openscreen -o demo.mp4 --json
6369

6470
See the [CLI reference](https://getopenscreen.com/docs/cli/).
6571

66-
### Let your coding agent ship the feature with its video
72+
### Let your AI agent ship the feature with its video
6773

68-
Paste this into Claude Code:
74+
Paste this into your AI agent (Claude Code, Codex, Cursor…):
6975

7076
```text
71-
Install the OpenScreen demo skill with `npx skills add getopenscreen/openscreen --skill openscreen-demo -a claude-code`, then follow it to add a video of this change to the PR.
77+
Install the OpenScreen demo skill with `npx skills add getopenscreen/openscreen --skill openscreen-demo -y`, then follow it to add a video of this change to the PR.
7278
```
7379

7480
The skill sets everything up: it installs OpenScreen if it is missing (once you
7581
agree), connects its MCP server, then asks you to open a new session and say
7682
*"continue the OpenScreen demo"*, the one step an agent cannot do for itself.
77-
By hand, it is these two commands, with the executable of your system:
83+
By hand, in Claude Code, it is these two commands, where `<executable>` is the app's executable
84+
on your system ([where to find it](https://getopenscreen.com/docs/cli/#running-the-cli)):
7885

7986
```bash
80-
claude mcp add openscreen -- "/Applications/Openscreen.app/Contents/MacOS/Openscreen" mcp
87+
claude mcp add openscreen -- "<executable>" mcp
8188
npx skills add getopenscreen/openscreen --skill openscreen-demo
8289
```
8390

8491
From then on, ask: *"Add a video of this change to the PR."* The agent plans the
8592
storyboard, records the app, edits the take with OpenScreen's own editing tools,
8693
exports the MP4, checks it frame by frame, and attaches it to the pull request,
8794
where reviewers watch it in place. Ask for a launch demo instead and it drafts a
88-
post for X, which it publishes only once you say yes. The executable on Windows
89-
and Linux, and the other clients, are in [AI editing](https://getopenscreen.com/docs/ai-editing/#letting-an-agent-make-the-whole-video).
95+
post for X, which it publishes only once you say yes. Codex and the other clients
96+
are in [AI editing](https://getopenscreen.com/docs/ai-editing/#letting-an-agent-make-the-whole-video).
9097

9198
## Installation
9299

‎build/appx-extensions.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
<uap5:Extension xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" Category="windows.appExecutionAlias" Executable="app\Openscreen.exe" EntryPoint="Windows.FullTrustApplication">
3+
<uap5:AppExecutionAlias>
4+
<uap5:ExecutionAlias Alias="openscreen.exe" />
5+
</uap5:AppExecutionAlias>
6+
</uap5:Extension>

0 commit comments

Comments
 (0)