Skip to content

Commit 92fef6f

Browse files
Merge branch 'release/v1.8.0' into claude/subtitle-padding-linux-1.8.0
2 parents 34981ab + 3f4ac46 commit 92fef6f

8 files changed

Lines changed: 189 additions & 37 deletions

File tree

.github/workflows/build.yml

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,18 +224,42 @@ jobs:
224224
VERSION="$(node -e "console.log(require('./package.json').version)")"
225225
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
226226
227+
# `--${{ matrix.arch }}` above does NOT restrict the architecture: the
228+
# `arch` list in electron-builder.json5's `mac.target` names both x64 and
229+
# arm64 and the config wins, so BOTH bundles are produced in every job —
230+
# x64 in release/<ver>/mac/, arm64 in release/<ver>/mac-arm64/. The old
231+
# `find release/<ver> ... | head -n1` took whichever came first in
232+
# directory order (x64, in practice), so the arm64 job could package the
233+
# x64 bundle into a DMG named `-arm64-`. Nothing downstream compared the
234+
# name against the contents, so that would have published silently.
227235
- name: Find .app bundle
228236
id: find_app
229237
run: |
230238
VERSION="${{ steps.version.outputs.version }}"
231-
APP_BUNDLE="$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)"
239+
if [[ "${{ matrix.arch }}" == "arm64" ]]; then ARCH_DIR="mac-arm64"; else ARCH_DIR="mac"; fi
240+
APP_BUNDLE="$(find "release/${VERSION}/${ARCH_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)"
232241
if [[ -z "$APP_BUNDLE" ]]; then
233-
echo "::error::No .app bundle found in release/${VERSION}/"
242+
echo "::error::No .app bundle found in release/${VERSION}/${ARCH_DIR}/"
234243
find "release/${VERSION}" -maxdepth 4 -print || true
235244
exit 1
236245
fi
237246
echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT"
238247
248+
# The guard for the above: refuse to build a DMG whose name would not
249+
# match its contents. An Intel bundle on an Apple Silicon Mac runs under
250+
# Rosetta 2 — compositor, encoder and whisper all translated — which is
251+
# slow enough to be unusable, so a mislabelled DMG is a real user harm.
252+
- name: Verify .app architecture matches the job
253+
run: |
254+
BIN="${{ steps.find_app.outputs.app_bundle }}/Contents/MacOS/Openscreen"
255+
if [[ "${{ matrix.arch }}" == "arm64" ]]; then EXPECTED="arm64"; else EXPECTED="x86_64"; fi
256+
ACTUAL="$(lipo -archs "$BIN")"
257+
echo "job arch=${{ matrix.arch }} expected=${EXPECTED} actual=${ACTUAL}"
258+
if [[ " ${ACTUAL} " != *" ${EXPECTED} "* ]]; then
259+
echo "::error::The ${{ matrix.arch }} job produced a '${ACTUAL}' bundle — refusing to publish a mislabelled DMG"
260+
exit 1
261+
fi
262+
239263
- name: Verify .app code signature
240264
if: steps.signing.outputs.enabled == 'true'
241265
run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
@@ -245,7 +269,17 @@ jobs:
245269
run: |
246270
VERSION="${{ steps.version.outputs.version }}"
247271
ARCH="${{ matrix.arch }}"
248-
DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg"
272+
# Name the DMG after the machine, not the instruction set. "x64" reads
273+
# to most people as "the normal 64-bit one" and "arm64" as the exotic
274+
# variant, which is exactly backwards on any Mac sold since 2020 — and
275+
# picking the wrong one silently costs Rosetta 2. `Intel` and
276+
# `Apple-Silicon` are what About This Mac shows the user.
277+
case "$ARCH" in
278+
arm64) ARCH_LABEL="Apple-Silicon" ;;
279+
x64) ARCH_LABEL="Intel" ;;
280+
*) ARCH_LABEL="$ARCH" ;;
281+
esac
282+
DMG_NAME="Openscreen-macOS-${ARCH_LABEL}-${VERSION}.dmg"
249283
RELEASE_DIR="release/${VERSION}"
250284
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
251285
STAGING="${RELEASE_DIR}/dmg-staging"

.github/workflows/update-homebrew-cask.yml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,22 @@ jobs:
4848
TIMEOUT_MINUTES=12
4949
POLL_INTERVAL=30
5050
MAX_ATTEMPTS=$(( (TIMEOUT_MINUTES * 60) / POLL_INTERVAL ))
51-
VERSION="${TAG#v}"
52-
ARM_DMG="Openscreen-Mac-arm64-${VERSION}.dmg"
53-
X64_DMG="Openscreen-Mac-x64-${VERSION}.dmg"
5451
52+
# Match on the arch marker, not on an exact filename. build.yml names
53+
# the DMGs `Openscreen-macOS-Apple-Silicon-<ver>.dmg` and
54+
# `-Intel-`; older releases used `-Mac-arm64-` / `-Mac-x64-`. An
55+
# exact-name wait would poll for the full 12 minutes and warn, on a
56+
# release whose assets were there the whole time. These are the same
57+
# patterns the "Find macOS DMG assets" step below already matches on,
58+
# so the two steps cannot disagree about what counts as present.
5559
for i in $(seq 1 $MAX_ATTEMPTS); do
56-
if gh release view "$TAG" --repo "$REPO" --json assets --jq \
57-
--arg arm "$ARM_DMG" --arg x64 "$X64_DMG" \
58-
'[.assets[] | select(.name == $arm or .name == $x64)] | length' 2>/dev/null | grep -q '^2$'; then
59-
echo "Both DMG assets present: $ARM_DMG and $X64_DMG"
60+
NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' 2>/dev/null || true)
61+
DMGS=$(echo "$NAMES" | grep -iE '\.dmg$' || true)
62+
ARM_FOUND=$(echo "$DMGS" | grep -icE '(arm64|apple[-_. ]?silicon)' || true)
63+
X64_FOUND=$(echo "$DMGS" | grep -icE '(x64|x86[-_]?64|intel)' || true)
64+
if [[ "$ARM_FOUND" -ge 1 && "$X64_FOUND" -ge 1 ]]; then
65+
echo "Both DMG assets present:"
66+
echo "$DMGS"
6067
exit 0
6168
fi
6269
echo "Waiting for DMG assets... (attempt $i/$MAX_ATTEMPTS)"

ROADMAP.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,18 @@ Still open on this axis:
3434
- [ ] **Sanctioned ChatGPT / GitHub Copilot sign-in** — both were removed in 1.8.0: reaching a user's subscription meant shipping GitHub's and OpenAI's own client IDs and an editor `User-Agent` against endpoints reserved for first-party clients, from inside a signed installer. They come back on the vendors' sanctioned surfaces — GitHub's Copilot SDK (we register our own OAuth App) and `codex app-server` (drives the user's own `codex login`, no client ID shipped at all). Separate integrations, not a header swap.
3535

3636
## 🖥️ Rendering & platform parity
37-
The live preview and MP4 export run on one native Rust + Direct3D 11 compositor: demux → decode → composite → hardware encode → mux, GPU-resident, no CPU readback between stages. Both consume the same scene description, so the frame you see in the editor is the frame the export writes — there is no second renderer that can drift.
37+
The live preview and MP4 export run on one native Rust compositor: demux → decode → composite → hardware encode → mux, GPU-resident, no CPU readback between stages. Both consume the same scene description, so the frame you see in the editor is the frame the export writes — there is no second renderer that can drift.
3838

39-
That engine is **Windows-only today**, which makes this the largest gap on the roadmap:
39+
That engine ran on Direct3D 11 only until 1.8.0, which made this the largest gap on the roadmap. It now has three backends behind the same scene contract:
4040

41-
- [ ] **MP4 export on macOS and Linux** — needs a Metal and a Vulkan backend behind the same scene contract. Recording, editing, transcription and GIF export already work on all three platforms; MP4 export does not.
42-
- [ ] **Feature:** software H.264 fallback when no GPU encoder is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments.
41+
- [x] **MP4 export on macOS** — Metal render pipeline with VideoToolbox decode and encode, a CoreText text rasterizer, and audio muxed into the output. All nine shader entry points are ported to MSL, so annotations, the cursor and its trail, the 3D tilt zoom and the dual-Kawase blur all render there.
42+
- [x] **MP4 export on Linux** — wgpu/WGSL pipeline with software H.264 encode, MP4 mux and AAC audio.
43+
- [x] **Feature:** software fallback when no GPU encoder is available — [#18](../../issues/18). A CPU backend (software render + decode) is selected automatically and surfaced in the UI, and reaches the export encoder like any other backend. Direct3D 11 now fails legibly rather than silently degrading to WARP.
44+
45+
Still open on this axis:
46+
47+
- [ ] **Hardware encode on Linux** — the export path is correct but software-encoded, so it is slower than the Windows and macOS ones. The capture helper already uses a hardware H.264 encoder; the export pipeline does not.
48+
- [ ] **A discrete-GPU and Intel QSV measurement.** Every number in [rendering-performance.md](technical-documentation/engineering/rendering-performance.md) comes from one passive-iGPU laptop, deliberately chosen as the weak case. Nothing is measured on the hardware most users have.
4349

4450
## 🛠️ Stability & quality (what we're actually shipping)
4551
Pulled from real user bug reports on getopenscreen/openscreen. This is the queue for the next release window.
@@ -71,4 +77,4 @@ Anything not on this list yet? Open an issue and tag it `roadmap` — we'll tria
7177
- **2026-06-24** — initial draft. Stability items pulled from open issues / PRs on getopenscreen/openscreen. AI section presented as opt-in / off by default. Whisper entry updated to reflect existing caption feature.
7278
- **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing).
7379
- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76.
74-
- **2026-07-27** — reconciled the roadmap with the code. The AI Edition tier moved from "a direction, not a sprint plan" to shipped: on-device transcription, transcript-driven editing, captions as a derived layer with translation, the chat agent, and `.openscreen` projects are all in. Provider list corrected — ChatGPT and GitHub Copilot were removed in 1.8.0 and are now blocked on the vendors' sanctioned surfaces, and MiniMax was missing. New "Rendering & platform parity" tier: preview and MP4 export share one native D3D11 compositor, and porting it off Windows is now the biggest open item; #18 moved there since it's an encoder concern. Blur (#76) marked shipped — as an annotation type, not a region kind, so the old note pointing at `src/lib/exporter/videoExporter.ts` was doubly stale (that file was deleted with the web export pipeline). Copy/paste (#24) split: the shortcuts shipped, the right-click menu didn't. Docusaurus site marked shipped.
80+
- **2026-07-27** — reconciled the roadmap with the code. The AI Edition tier moved from "a direction, not a sprint plan" to shipped: on-device transcription, transcript-driven editing, captions as a derived layer with translation, the chat agent, and `.openscreen` projects are all in. Provider list corrected — ChatGPT and GitHub Copilot were removed in 1.8.0 and are now blocked on the vendors' sanctioned surfaces, and MiniMax was missing. New "Rendering & platform parity" tier: preview and MP4 export share one native D3D11 compositor, and porting it off Windows is now the biggest open item; #18 moved there since it's an encoder concern. Blur (#76) marked shipped — as an annotation type, not a region kind, so the old note pointing at `src/lib/exporter/videoExporter.ts` was doubly stale (that file was deleted with the web export pipeline). Copy/paste (#24) split: the shortcuts shipped, the right-click menu didn't. Docusaurus site marked shipped.- **2026-08-01** — the platform-parity tier was the stalest thing on this page: it still described the compositor as Direct3D 11 and listed MP4 export on macOS and Linux as unstarted, while v1.8.0-rc.5 was already publishing DMGs and Linux packages built on the Metal and WGSL backends. #18 (software encoder fallback) shipped with them, as an automatically-selected CPU backend rather than an encoder flag. Two real gaps replace them: Linux export is software-encoded, and every performance number on record still comes from one passive-iGPU laptop. Also corrected the framing that produced this drift — the tier was written as "porting it off Windows is the biggest open item", which stayed true in the text long after it stopped being true in the tree.

electron/ipc/handlers.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3562,11 +3562,25 @@ export function registerIpcHandlers(
35623562
// project through a per-INSTANCE queue (see its writeProject comment — this
35633563
// race destroyed two real project files), so a second instance means a second
35643564
// queue racing for the same path: temp+rename still keeps the file valid, but
3565-
// a save can land under a concurrent one and be silently lost. LlmConfigStore
3566-
// is hoisted for a duller reason: its constructor does two sync readFileSync
3567-
// plus a safeStorage decrypt, and it was running on every chat message.
3565+
// a save can land under a concurrent one and be silently lost.
35683566
const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects"));
3569-
const aiEditionLlmConfig = new LlmConfigStore(app.getPath("userData"));
3567+
3568+
// LlmConfigStore is single-instance for a duller reason — its constructor does
3569+
// two sync readFileSync plus a safeStorage decrypt, and it was running on every
3570+
// chat message. But it must also stay UNBUILT until something actually needs it:
3571+
// on macOS that decrypt is backed by a Keychain item, so constructing it at
3572+
// startup made every launch prompt for Keychain access, including for users who
3573+
// never open the AI layer at all. (The prompt repeats because an unsigned or
3574+
// ad-hoc-signed build has no stable code identity for the item's ACL to trust —
3575+
// signing is the other half of that fix, and is not this function's business.)
3576+
// Memoised, so the "one instance" guarantee above still holds.
3577+
let aiEditionLlmConfigInstance: LlmConfigStore | null = null;
3578+
const getAiEditionLlmConfig = (): LlmConfigStore => {
3579+
if (!aiEditionLlmConfigInstance) {
3580+
aiEditionLlmConfigInstance = new LlmConfigStore(app.getPath("userData"));
3581+
}
3582+
return aiEditionLlmConfigInstance;
3583+
};
35703584

35713585
registerNativeBridgeHandlers({
35723586
getPlatform: () => process.platform,
@@ -3602,9 +3616,9 @@ export function registerIpcHandlers(
36023616
}
36033617
},
36043618
getAiEditionDocuments: () => aiEditionDocuments,
3605-
getAiEditionLlmConfig: () => aiEditionLlmConfig,
3619+
getAiEditionLlmConfig,
36063620
runAiEditionChat: (projectId, sessionId, message, document, sink) =>
3607-
runChat(projectId, sessionId, message, aiEditionLlmConfig, document, sink, {
3621+
runChat(projectId, sessionId, message, getAiEditionLlmConfig(), document, sink, {
36083622
cursor: agentCursorTelemetryReader,
36093623
}),
36103624
undoAiEditionToolBatch: (_projectId, _sessionId) => ({
@@ -3614,7 +3628,7 @@ export function registerIpcHandlers(
36143628
rewindToMessage: (projectId, sessionId, messageId) =>
36153629
rewindToMessage(projectId, sessionId, messageId),
36163630
compactNow: (projectId, sessionId) =>
3617-
compactSessionNow(projectId, sessionId, aiEditionLlmConfig),
3631+
compactSessionNow(projectId, sessionId, getAiEditionLlmConfig()),
36183632
getContextUsage: getSessionContextUsage,
36193633
listAiEditionChatSessions: (projectId) => listSessions(projectId),
36203634
createAiEditionChatSession: (projectId, title) => createSession(projectId, title),

electron/ipc/nativeBridge.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
223223
const compositorViewService = new CompositorViewService();
224224
const aiEditionService = new AiEditionService({
225225
documents: context.getAiEditionDocuments(),
226-
llmConfig: context.getAiEditionLlmConfig(),
226+
// Passed uncalled on purpose — invoking it here would build the store (and
227+
// hit the macOS Keychain) while wiring the bridge at startup.
228+
llmConfig: context.getAiEditionLlmConfig,
227229
runChat: context.runAiEditionChat,
228230
undoLastToolBatch: context.undoAiEditionToolBatch,
229231
rewindToMessage: context.rewindToMessage,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { LlmConfigStore } from "../../ai-edition/llm-config-store";
3+
import { AiEditionService, type AiEditionServiceOptions } from "./aiEditionService";
4+
5+
/**
6+
* `LlmConfigStore`'s constructor does two sync readFileSync plus a `safeStorage`
7+
* decrypt. On macOS that decrypt is backed by a Keychain item, so building the
8+
* store during startup made every launch prompt for Keychain access — including
9+
* for the majority of users who never open the AI layer at all.
10+
*
11+
* The fix is that `AiEditionServiceOptions.llmConfig` is a factory the service
12+
* calls on first use, and `registerNativeBridgeHandlers` passes it uncalled.
13+
* That is a startup-timing property: reintroducing the eager form (a stray `()`
14+
* at the wiring site) breaks nothing that any other test observes, the app still
15+
* works, and the only symptom is a Keychain prompt on a machine the author may
16+
* not have. Hence a test that asserts on *when* the factory runs.
17+
*/
18+
19+
/** Enough of the store for the methods exercised here; unused members stay absent. */
20+
function storeStub(): LlmConfigStore {
21+
return {
22+
getConfig: () => null,
23+
getCredential: () => null,
24+
} as unknown as LlmConfigStore;
25+
}
26+
27+
function serviceWithCountingFactory(): { service: AiEditionService; builds: () => number } {
28+
let builds = 0;
29+
const store = storeStub();
30+
const options = {
31+
documents: {
32+
listProjects: async () => [],
33+
},
34+
llmConfig: () => {
35+
builds += 1;
36+
return store;
37+
},
38+
} as unknown as AiEditionServiceOptions;
39+
return { service: new AiEditionService(options), builds: () => builds };
40+
}
41+
42+
describe("AiEditionService — LLM store resolution is deferred", () => {
43+
it("does not build the store while the service is constructed", () => {
44+
const { builds } = serviceWithCountingFactory();
45+
expect(builds()).toBe(0);
46+
});
47+
48+
it("does not build the store for work that has nothing to do with the LLM", async () => {
49+
const { service, builds } = serviceWithCountingFactory();
50+
await service.listProjects();
51+
expect(builds()).toBe(0);
52+
});
53+
54+
it("builds it once on the first call that needs it, and holds it after", async () => {
55+
const { service, builds } = serviceWithCountingFactory();
56+
57+
// llmGetSnapshot reads the store once per provider definition, so this
58+
// also pins the memoisation: without it the factory ran nine times here.
59+
await service.llmGetSnapshot();
60+
expect(builds()).toBe(1);
61+
62+
await service.llmGetSnapshot();
63+
expect(builds()).toBe(1);
64+
});
65+
});

0 commit comments

Comments
 (0)