From e4570d90b53bee859ca86a96d3022deb4880c290 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 15:31:22 +0200 Subject: [PATCH 1/4] ci(macos): package the arch the job builds, and name DMGs after the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx electron-builder --mac --${matrix.arch} --dir` does not restrict the architecture: the `arch` list in electron-builder.json5's `mac.target` names both x64 and arm64, and the config wins over the CLI flag. Every job therefore produces both bundles — x64 in release//mac/, arm64 in release//mac-arm64/ — and the step that picked one did find "release/${VERSION}" -maxdepth 4 -name "*.app" | head -n1 which takes whichever directory comes first in readdir order. electron-builder walks the target list in the order it is declared, so `mac/` (x64) is created first and wins the race. The arm64 job could hand the x64 bundle to `hdiutil` and publish it as `Openscreen-Mac-arm64-.dmg`. Nothing downstream ever compared the name against the contents, so it would have shipped silently. v1.8.0-rc.5 happens to be correct — its arm64 DMG really is arm64 — so this is a latent defect, not an incident. Fixed by selecting the arch directory explicitly, and guarded by a `lipo -archs` assertion that fails the job when the bundle does not match `matrix.arch`. The guard is the part that matters: selection can drift again, an assertion cannot pass while wrong. The rename is the same defect seen from the user's side. `x64` reads to most people as "the normal 64-bit one" and `arm64` as the exotic variant, which is backwards on every Mac sold since 2020 — and choosing wrong is not a cosmetic mistake: an Intel bundle on Apple Silicon runs the compositor, the encoder and whisper.cpp under Rosetta 2, which is slow enough to make the app unusable. The DMGs are now named `Openscreen-macOS-Apple-Silicon-.dmg` and `Openscreen-macOS-Intel-.dmg`, matching what About This Mac shows. update-homebrew-cask.yml needed one change to follow. Its "Find macOS DMG assets" step already matched `apple[-_. ]?silicon` and `intel`, but the "Wait for release DMG assets" step ahead of it compared exact filenames, so after the rename it would have polled for its full 12-minute timeout and warned, on a release whose assets were present the whole time. Both steps now match on the same patterns and cannot disagree; old `-Mac-arm64-` / `-Mac-x64-` names still match, so past releases keep working. --- .github/workflows/build.yml | 40 ++++++++++++++++++++-- .github/workflows/update-homebrew-cask.yml | 21 ++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76ac9ef394..2a2c0a9e60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -224,18 +224,42 @@ jobs: VERSION="$(node -e "console.log(require('./package.json').version)")" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # `--${{ matrix.arch }}` above does NOT restrict the architecture: the + # `arch` list in electron-builder.json5's `mac.target` names both x64 and + # arm64 and the config wins, so BOTH bundles are produced in every job — + # x64 in release//mac/, arm64 in release//mac-arm64/. The old + # `find release/ ... | head -n1` took whichever came first in + # directory order (x64, in practice), so the arm64 job could package the + # x64 bundle into a DMG named `-arm64-`. Nothing downstream compared the + # name against the contents, so that would have published silently. - name: Find .app bundle id: find_app run: | VERSION="${{ steps.version.outputs.version }}" - APP_BUNDLE="$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)" + if [[ "${{ matrix.arch }}" == "arm64" ]]; then ARCH_DIR="mac-arm64"; else ARCH_DIR="mac"; fi + APP_BUNDLE="$(find "release/${VERSION}/${ARCH_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)" if [[ -z "$APP_BUNDLE" ]]; then - echo "::error::No .app bundle found in release/${VERSION}/" + echo "::error::No .app bundle found in release/${VERSION}/${ARCH_DIR}/" find "release/${VERSION}" -maxdepth 4 -print || true exit 1 fi echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" + # The guard for the above: refuse to build a DMG whose name would not + # match its contents. An Intel bundle on an Apple Silicon Mac runs under + # Rosetta 2 — compositor, encoder and whisper all translated — which is + # slow enough to be unusable, so a mislabelled DMG is a real user harm. + - name: Verify .app architecture matches the job + run: | + BIN="${{ steps.find_app.outputs.app_bundle }}/Contents/MacOS/Openscreen" + if [[ "${{ matrix.arch }}" == "arm64" ]]; then EXPECTED="arm64"; else EXPECTED="x86_64"; fi + ACTUAL="$(lipo -archs "$BIN")" + echo "job arch=${{ matrix.arch }} expected=${EXPECTED} actual=${ACTUAL}" + if [[ " ${ACTUAL} " != *" ${EXPECTED} "* ]]; then + echo "::error::The ${{ matrix.arch }} job produced a '${ACTUAL}' bundle — refusing to publish a mislabelled DMG" + exit 1 + fi + - name: Verify .app code signature if: steps.signing.outputs.enabled == 'true' run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" @@ -245,7 +269,17 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" ARCH="${{ matrix.arch }}" - DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" + # Name the DMG after the machine, not the instruction set. "x64" reads + # to most people as "the normal 64-bit one" and "arm64" as the exotic + # variant, which is exactly backwards on any Mac sold since 2020 — and + # picking the wrong one silently costs Rosetta 2. `Intel` and + # `Apple-Silicon` are what About This Mac shows the user. + case "$ARCH" in + arm64) ARCH_LABEL="Apple-Silicon" ;; + x64) ARCH_LABEL="Intel" ;; + *) ARCH_LABEL="$ARCH" ;; + esac + DMG_NAME="Openscreen-macOS-${ARCH_LABEL}-${VERSION}.dmg" RELEASE_DIR="release/${VERSION}" DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" STAGING="${RELEASE_DIR}/dmg-staging" diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 60661a3bb9..cc81ddcb34 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -48,15 +48,22 @@ jobs: TIMEOUT_MINUTES=12 POLL_INTERVAL=30 MAX_ATTEMPTS=$(( (TIMEOUT_MINUTES * 60) / POLL_INTERVAL )) - VERSION="${TAG#v}" - ARM_DMG="Openscreen-Mac-arm64-${VERSION}.dmg" - X64_DMG="Openscreen-Mac-x64-${VERSION}.dmg" + # Match on the arch marker, not on an exact filename. build.yml names + # the DMGs `Openscreen-macOS-Apple-Silicon-.dmg` and + # `-Intel-`; older releases used `-Mac-arm64-` / `-Mac-x64-`. An + # exact-name wait would poll for the full 12 minutes and warn, on a + # release whose assets were there the whole time. These are the same + # patterns the "Find macOS DMG assets" step below already matches on, + # so the two steps cannot disagree about what counts as present. for i in $(seq 1 $MAX_ATTEMPTS); do - if gh release view "$TAG" --repo "$REPO" --json assets --jq \ - --arg arm "$ARM_DMG" --arg x64 "$X64_DMG" \ - '[.assets[] | select(.name == $arm or .name == $x64)] | length' 2>/dev/null | grep -q '^2$'; then - echo "Both DMG assets present: $ARM_DMG and $X64_DMG" + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' 2>/dev/null || true) + DMGS=$(echo "$NAMES" | grep -iE '\.dmg$' || true) + ARM_FOUND=$(echo "$DMGS" | grep -icE '(arm64|apple[-_. ]?silicon)' || true) + X64_FOUND=$(echo "$DMGS" | grep -icE '(x64|x86[-_]?64|intel)' || true) + if [[ "$ARM_FOUND" -ge 1 && "$X64_FOUND" -ge 1 ]]; then + echo "Both DMG assets present:" + echo "$DMGS" exit 0 fi echo "Waiting for DMG assets... (attempt $i/$MAX_ATTEMPTS)" From 3bcfa2b055e554f16cfa8f36fd34c365a939232a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 15:31:22 +0200 Subject: [PATCH 2/4] docs(perf): the 79 fps row never shipped, so say so before it is quoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline table reads as a before/after and is not one. Its `WebCodecs in Chromium | 79 | removed — was the previous export path` row is the web pipeline *after* the Canvas2D compositor rebuild — an intermediate state of the 1.8.0 cycle that no release ever contained. "The previous export path" invites the reader to compare 126 against it and conclude ~1.6x. What users actually upgrade from is v1.7.0, which is the `webcodecs-legacy` arm: ~8 fps at M1, 9.8 fps under Gate G0. The tree settles it — `src/lib/exporter/frameRenderer.ts` has no `shadowCache` in v1.7.0 and carries it throughout by v1.8.0-rc.4, so the ~2x rebuild landed inside the 1.8.0 window, not before it. 1.8.0 therefore compounds two changes against what a user had, and the real delta is an order of magnitude rather than 1.6x. This was not hypothetical: the v1.8.0-rc.4 release notes were drafted with the 1.6x figure straight off this table before the tree was checked. The note also says not to quote a precise multiple across these runs, because the same arm on this machine has measured 44.0, 36.8, 32.3, 31.8, 22.2 and 11.9 fps across sessions — the magnitude survives that drift, an exact ratio does not. --- technical-documentation/engineering/rendering-performance.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index d3d442912f..af5a7189d6 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -18,11 +18,13 @@ On the reference machine, same fixture, sustained regime: | path | fps @ 1080p60, full effects | status | |---|---:|---| | **D3D11 (`crates/compositor/`)** | **~126** (median 125.9, spread 11.8 %) | **shipped** | -| WebCodecs in Chromium | 79 | removed — was the previous export path | +| WebCodecs in Chromium | 79 | removed — the web pipeline *after* the [Canvas2D rebuild](#the-fix-and-what-it-bought); never released | | Rust + wgpu / Vulkan | 48–68 | [rejected](#rust--wgpu-native-poc-poc-native), driver-blocked | That fps envelopes **the whole run** — demux, decode, composite, encode and mux — because the measured window is one `Instant::now()` before and one after everything. Nothing inside can falsify the clock. +> **This table is not the user-facing delta, and reading it as one understates the change by about an order of magnitude.** The 79 fps row is the web pipeline *after* the [Canvas2D compositor rebuild](#the-fix-and-what-it-bought) — an intermediate state of the 1.8.0 cycle that no release ever shipped. The last **released** web pipeline is v1.7.0's, which is the `webcodecs-legacy` arm: [~8 fps at M1](#m1--the-starting-pipeline), 9.8 fps under [Gate G0](#gate-g0--passed-2026-07-17). The tell is in the tree, not in the tags: `src/lib/exporter/frameRenderer.ts` carries no `shadowCache` at all in `v1.7.0` (1184 lines), and carries it throughout by `v1.8.0-rc.4` (1552 lines). So 1.8.0 compounds two changes against what a user actually had: the rebuild (~2×, byte-identical output) and then the native engine that replaced it. Quote the magnitude, never a precise multiple across these runs — [this machine does not support that arithmetic](#bench-methodology-of-the-deleted-harness). + The effect set is not a reduced one: animated layout, zooms, NV12→RGB BT.709, rounded corners and masks (SDF), drop shadows (SDF penumbra), background blur (dual-Kawase), per-velocity motion blur, custom cursor with click bounce. ### What bounds it From e509b78202fcb9c3323adff3ed6b538a6c1a739f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 16:08:46 +0200 Subject: [PATCH 3/4] fix(ai-edition): stop prompting for the macOS Keychain on every launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LlmConfigStore`'s constructor does two sync readFileSync plus a `safeStorage` decrypt. On macOS `safeStorage` is backed by a Keychain item, so building the store during `registerIpcHandlers` meant every single launch reached the Keychain — including for the majority of users who never open the AI layer and have no credentials stored at all. The store is now built on first use and memoised. Making the handlers-side binding lazy was not enough on its own: `registerNativeBridgeHandlers` resolved it eagerly with `llmConfig: context.getAiEditionLlmConfig()` while wiring the service, so the laziness had to be carried through to `AiEditionService`, whose option is now a factory rather than an instance. Every one of its thirteen uses already sat behind a method the renderer has to call first, so nothing else moved. The service memoises too — `llmGetSnapshot` alone reads the store once per provider definition, which would otherwise have called the factory nine times per snapshot. The single-instance guarantee the old comment argued for is preserved: the memoisation is what keeps a second store from racing the first, and the DocumentService rationale next to it (a per-instance save queue whose duplicate destroyed two real project files) is untouched and still eager. This does not fix the prompt repeating, only its appearing at startup. The item's ACL binds to a code signature, and an unsigned or ad-hoc-signed build has no stable identity for it to trust, so the prompt returns on every launch that reaches the store. Signing is the other half, and is not this function's business. Guarded by a test, because the failure mode is invisible: reintroducing the eager form breaks no behaviour, passes every other test, and shows up only as a Keychain prompt on a machine the author may not have. --- electron/ipc/handlers.ts | 28 ++++++-- electron/ipc/nativeBridge.ts | 4 +- .../aiEditionService.lazyLlmConfig.test.ts | 65 +++++++++++++++++++ .../services/aiEditionService.ts | 48 ++++++++++---- 4 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 77d2cac1bd..5e0c3af823 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3562,11 +3562,25 @@ export function registerIpcHandlers( // project through a per-INSTANCE queue (see its writeProject comment — this // race destroyed two real project files), so a second instance means a second // queue racing for the same path: temp+rename still keeps the file valid, but - // a save can land under a concurrent one and be silently lost. LlmConfigStore - // is hoisted for a duller reason: its constructor does two sync readFileSync - // plus a safeStorage decrypt, and it was running on every chat message. + // a save can land under a concurrent one and be silently lost. const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects")); - const aiEditionLlmConfig = new LlmConfigStore(app.getPath("userData")); + + // LlmConfigStore is single-instance for a duller reason — its constructor does + // two sync readFileSync plus a safeStorage decrypt, and it was running on every + // chat message. But it must also stay UNBUILT until something actually needs it: + // on macOS that decrypt is backed by a Keychain item, so constructing it at + // startup made every launch prompt for Keychain access, including for users who + // never open the AI layer at all. (The prompt repeats because an unsigned or + // ad-hoc-signed build has no stable code identity for the item's ACL to trust — + // signing is the other half of that fix, and is not this function's business.) + // Memoised, so the "one instance" guarantee above still holds. + let aiEditionLlmConfigInstance: LlmConfigStore | null = null; + const getAiEditionLlmConfig = (): LlmConfigStore => { + if (!aiEditionLlmConfigInstance) { + aiEditionLlmConfigInstance = new LlmConfigStore(app.getPath("userData")); + } + return aiEditionLlmConfigInstance; + }; registerNativeBridgeHandlers({ getPlatform: () => process.platform, @@ -3602,9 +3616,9 @@ export function registerIpcHandlers( } }, getAiEditionDocuments: () => aiEditionDocuments, - getAiEditionLlmConfig: () => aiEditionLlmConfig, + getAiEditionLlmConfig, runAiEditionChat: (projectId, sessionId, message, document, sink) => - runChat(projectId, sessionId, message, aiEditionLlmConfig, document, sink, { + runChat(projectId, sessionId, message, getAiEditionLlmConfig(), document, sink, { cursor: agentCursorTelemetryReader, }), undoAiEditionToolBatch: (_projectId, _sessionId) => ({ @@ -3614,7 +3628,7 @@ export function registerIpcHandlers( rewindToMessage: (projectId, sessionId, messageId) => rewindToMessage(projectId, sessionId, messageId), compactNow: (projectId, sessionId) => - compactSessionNow(projectId, sessionId, aiEditionLlmConfig), + compactSessionNow(projectId, sessionId, getAiEditionLlmConfig()), getContextUsage: getSessionContextUsage, listAiEditionChatSessions: (projectId) => listSessions(projectId), createAiEditionChatSession: (projectId, title) => createSession(projectId, title), diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index c99126b64e..47d66e2708 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -223,7 +223,9 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { const compositorViewService = new CompositorViewService(); const aiEditionService = new AiEditionService({ documents: context.getAiEditionDocuments(), - llmConfig: context.getAiEditionLlmConfig(), + // Passed uncalled on purpose — invoking it here would build the store (and + // hit the macOS Keychain) while wiring the bridge at startup. + llmConfig: context.getAiEditionLlmConfig, runChat: context.runAiEditionChat, undoLastToolBatch: context.undoAiEditionToolBatch, rewindToMessage: context.rewindToMessage, diff --git a/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts b/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts new file mode 100644 index 0000000000..0935f0db3a --- /dev/null +++ b/electron/native-bridge/services/aiEditionService.lazyLlmConfig.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { LlmConfigStore } from "../../ai-edition/llm-config-store"; +import { AiEditionService, type AiEditionServiceOptions } from "./aiEditionService"; + +/** + * `LlmConfigStore`'s constructor does two sync readFileSync plus a `safeStorage` + * decrypt. On macOS that decrypt is backed by a Keychain item, so building the + * store during startup made every launch prompt for Keychain access — including + * for the majority of users who never open the AI layer at all. + * + * The fix is that `AiEditionServiceOptions.llmConfig` is a factory the service + * calls on first use, and `registerNativeBridgeHandlers` passes it uncalled. + * That is a startup-timing property: reintroducing the eager form (a stray `()` + * at the wiring site) breaks nothing that any other test observes, the app still + * works, and the only symptom is a Keychain prompt on a machine the author may + * not have. Hence a test that asserts on *when* the factory runs. + */ + +/** Enough of the store for the methods exercised here; unused members stay absent. */ +function storeStub(): LlmConfigStore { + return { + getConfig: () => null, + getCredential: () => null, + } as unknown as LlmConfigStore; +} + +function serviceWithCountingFactory(): { service: AiEditionService; builds: () => number } { + let builds = 0; + const store = storeStub(); + const options = { + documents: { + listProjects: async () => [], + }, + llmConfig: () => { + builds += 1; + return store; + }, + } as unknown as AiEditionServiceOptions; + return { service: new AiEditionService(options), builds: () => builds }; +} + +describe("AiEditionService — LLM store resolution is deferred", () => { + it("does not build the store while the service is constructed", () => { + const { builds } = serviceWithCountingFactory(); + expect(builds()).toBe(0); + }); + + it("does not build the store for work that has nothing to do with the LLM", async () => { + const { service, builds } = serviceWithCountingFactory(); + await service.listProjects(); + expect(builds()).toBe(0); + }); + + it("builds it once on the first call that needs it, and holds it after", async () => { + const { service, builds } = serviceWithCountingFactory(); + + // llmGetSnapshot reads the store once per provider definition, so this + // also pins the memoisation: without it the factory ran nine times here. + await service.llmGetSnapshot(); + expect(builds()).toBe(1); + + await service.llmGetSnapshot(); + expect(builds()).toBe(1); + }); +}); diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 5d4d12caa1..0fbbccc9c8 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -34,7 +34,16 @@ import { PROVIDER_DEFINITIONS } from "../../ai-edition/provider-registry"; export interface AiEditionServiceOptions { documents: DocumentService; - llmConfig: LlmConfigStore; + /** + * A factory, not an instance: building `LlmConfigStore` does two sync + * readFileSync plus a `safeStorage` decrypt, and on macOS that decrypt is + * backed by a Keychain item — so resolving it while wiring the bridge made + * every app launch prompt for Keychain access, including for users who never + * open the AI layer. The caller memoises, so this still yields one instance. + * Nothing here may call it at construction time; every use sits behind a + * method the renderer has to invoke first. + */ + llmConfig: () => LlmConfigStore; runChat: ( projectId: string, sessionId: string, @@ -76,6 +85,19 @@ export interface AiEditionServiceOptions { export class AiEditionService { constructor(private readonly options: AiEditionServiceOptions) {} + private llmConfigInstance: LlmConfigStore | null = null; + + /** + * Resolves the store on first use, then holds it — `llmGetSnapshot` alone + * reads it once per provider definition. See `AiEditionServiceOptions.llmConfig`. + */ + private get llmConfig(): LlmConfigStore { + if (!this.llmConfigInstance) { + this.llmConfigInstance = this.options.llmConfig(); + } + return this.llmConfigInstance; + } + async listProjects(): Promise { return this.options.documents.listProjects(); } @@ -141,11 +163,11 @@ export class AiEditionService { } async llmGetSnapshot(): Promise { - const config = this.options.llmConfig.getConfig(); + const config = this.llmConfig.getConfig(); const credentialSummary: AiEditionLlmSnapshot["credentialSummary"] = []; const connectedProviders: string[] = []; for (const def of PROVIDER_DEFINITIONS) { - const resolved = this.options.llmConfig.getCredential(def.id, def.envKeys); + const resolved = this.llmConfig.getCredential(def.id, def.envKeys); const connected = Boolean(resolved); if (connected) connectedProviders.push(def.id); credentialSummary.push({ @@ -169,7 +191,7 @@ export class AiEditionService { async llmSetConfig(config: AiEditionLlmConfig): Promise { try { - await this.options.llmConfig.setConfig(config); + await this.llmConfig.setConfig(config); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -179,7 +201,7 @@ export class AiEditionService { async llmSetApiKey(providerId: string, apiKey: string): Promise { try { const entry: LlmCredential = { kind: "api-key", apiKey }; - await this.options.llmConfig.setCredential(providerId, entry); + await this.llmConfig.setCredential(providerId, entry); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -188,7 +210,7 @@ export class AiEditionService { async llmRemoveApiKey(providerId: string): Promise { try { - await this.options.llmConfig.removeCredential(providerId); + await this.llmConfig.removeCredential(providerId); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -196,10 +218,10 @@ export class AiEditionService { } async llmDisconnect(providerId: string): Promise { - await this.options.llmConfig.removeCredential(providerId); - const active = this.options.llmConfig.getConfig(); + await this.llmConfig.removeCredential(providerId); + const active = this.llmConfig.getConfig(); if (active?.provider === providerId) { - await this.options.llmConfig.setConfig({ + await this.llmConfig.setConfig({ provider: "", model: "", }); @@ -211,9 +233,9 @@ export class AiEditionService { try { const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); if (!def) return { models: [], error: `Unknown provider ${providerId}` }; - const cred = this.options.llmConfig.getCredential(providerId, def.envKeys); + const cred = this.llmConfig.getCredential(providerId, def.envKeys); if (!cred) return { models: [], error: "Not connected" }; - const config = this.options.llmConfig.getConfig(); + const config = this.llmConfig.getConfig(); const baseUrl = (config?.provider === providerId ? config.baseUrl : undefined) ?? def.baseUrl; if (providerId === "anthropic") { @@ -331,7 +353,7 @@ export class AiEditionService { targetLanguage: string; sourceLanguage?: string; }): Promise { - const config = this.options.llmConfig.getConfig(); + const config = this.llmConfig.getConfig(); if (!config) { return { success: false, @@ -340,7 +362,7 @@ export class AiEditionService { }; } const def = PROVIDER_DEFINITIONS.find((d) => d.id === config.provider); - const credential = def ? this.options.llmConfig.getCredential(def.id, def.envKeys) : null; + const credential = def ? this.llmConfig.getCredential(def.id, def.envKeys) : null; const result = await translateCaptionSegments({ segments: input.segments, targetLanguage: input.targetLanguage, From 3d19691cfb0284cfb4274c070966768295346112 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 16:08:46 +0200 Subject: [PATCH 4/4] docs(roadmap): the platform-parity tier described a Windows-only engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It still called the compositor "Direct3D 11", listed MP4 export on macOS and Linux as unstarted, and framed "porting it off Windows" as the biggest open item — 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 too, as an automatically-selected CPU backend rather than the encoder flag the entry anticipated. Marked all three shipped, and replaced them with the two gaps that are actually open: Linux export is software-encoded (the capture helper has a hardware H.264 encoder, the export pipeline does not), and every performance number on record still comes from one passive-iGPU laptop, so nothing is measured on the hardware most users have. --- ROADMAP.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27fee379f2..01907839dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,12 +34,18 @@ Still open on this axis: - [ ] **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. ## 🖥️ Rendering & platform parity -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. +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. -That engine is **Windows-only today**, which makes this the largest gap on the roadmap: +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: -- [ ] **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. -- [ ] **Feature:** software H.264 fallback when no GPU encoder is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. +- [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. +- [x] **MP4 export on Linux** — wgpu/WGSL pipeline with software H.264 encode, MP4 mux and AAC audio. +- [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. + +Still open on this axis: + +- [ ] **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. +- [ ] **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. ## 🛠️ Stability & quality (what we're actually shipping) 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 - **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. - **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing). - **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. -- **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. \ No newline at end of file +- **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.