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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,40 @@ jobs:
env:
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}

# The two steps below are what `npm run build:mac` does and this job did not.
# Windows gets them for free because its job just runs `npm run build:win`,
# which chains fetch:ffmpeg + build:native:compositor; macOS spells its steps
# out (it needs `--dir` plus a hand-rolled DMG and signing) and drifted. The
# result was a .app with no compositor addon — preview and export dead in the
# installed app, silently. `scripts/before-pack.cjs` now refuses to package
# that, so this is also what keeps the job from failing at the pack step.
- name: Cache LGPL ffmpeg tree
uses: actions/cache@v4
with:
# fetch-ffmpeg-macos.mjs BUILDS ffmpeg from source (~5 min): BtbN ships no
# macOS target and every circulating macOS build is GPL, which would
# relicense this MIT app. The script pins the release and checksums it, so
# keying on the script itself busts the cache when the pin moves.
path: crates/thirdparty
key: ffmpeg-macos-${{ matrix.arch }}-${{ hashFiles('scripts/fetch-ffmpeg-macos.mjs') }}

- name: Vendor LGPL ffmpeg
run: npm run fetch:ffmpeg:mac

- name: Cache cargo + compositor build tree
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
crates/target
key: cargo-macos-${{ matrix.arch }}-${{ hashFiles('crates/Cargo.lock') }}
restore-keys: |
cargo-macos-${{ matrix.arch }}-

- name: Build Metal compositor addon
run: npm run build:native:compositor:mac

- name: Package .app bundle
run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never
env:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi
**The HUD widget** (recording controller)

- **It is invisible in screenshots by default.** The HUD (and the Notes window) call `setContentProtection(true)` so the recording controls never end up baked into a recording — the same `SetWindowDisplayAffinity` that WGC honours also hides them from *your* screenshots. The window is there, and clicks land, but you are aiming blind at a rectangle you cannot see. Set **`OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`** in the app's environment to turn it off for a session; every skipped window logs a warning. Unset it before recording anything real, or the HUD ends up in the video.
- **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window; the trade-off is that the HUD can appear in recordings on that OS until the ScreenCaptureKit helper excludes our own windows via `SCContentFilter(excludingWindows:)`, which it currently passes as `[]`. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables it to re-test against a future Electron.
- The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project.
- Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop.
- Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record").
Expand Down
43 changes: 38 additions & 5 deletions electron/native/whisper-stt/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,30 @@ std::string detect_active_backend() {
for (size_t i = 0; i < n; ++i) {
ggml_backend_dev_t dev = ggml_backend_dev_get(i);
if (!dev) continue;
// Only compute devices are candidates. This drops ggml-cpu (type CPU)
// and ggml-blas (type ACCEL — Accelerate on macOS), neither of which is
// the GPU offload this field is meant to report. IGPU is accepted
// because that is how an integrated adapter enumerates.
// `auto`: ggml_backend_dev_type names both a C enum and the accessor
// function, so spelling the type out needs the `enum` tag to disambiguate.
const auto type = ggml_backend_dev_type(dev);
if (type != GGML_BACKEND_DEVICE_TYPE_GPU && type != GGML_BACKEND_DEVICE_TYPE_IGPU) {
continue;
}
const char* name = ggml_backend_dev_name(dev);
if (!name) continue;
std::string s = name;
// ggml-cpu shows up as "CPU" — skip it on the first pass, only return
// it as a last resort.
if (s == "CPU" || s == "cpu") continue;
// These are ggml *device* names, which carry a device index:
// "Vulkan0", "CUDA0", "MTL0". Matching the bare backend title is why
// Metal never matched — ggml-metal's device name is built from
// GGML_METAL_NAME ("MTL"), and the string "Metal" appears nowhere in
// it, so every Apple Silicon run reported `whispercpp-cpu` while the
// Metal backend was in fact bound. `backend` is documented as the
// source of truth for which device ran, so this was the one number a
// consumer could not get right on macOS.
if (s.find("Vulkan") != std::string::npos) return "whispercpp-vulkan";
if (s.find("CUDA") != std::string::npos) return "whispercpp-cuda";
if (s.find("MTL") != std::string::npos) return "whispercpp-metal";
if (s.find("Metal") != std::string::npos) return "whispercpp-metal";
}
// Last resort: the CPU device, or a hard "cpu" if ggml has nothing
Expand Down Expand Up @@ -464,9 +480,26 @@ int main(int argc, char** argv) {
}

// ---- Emit verbose_json (CT2-compatible shape + backend + timing) ----
//
// Report the language whisper actually decoded with, not the one the
// request asked for. `language` is the raw request parameter, and the
// renderer sends "auto" on every call (there is no language selector in
// the UI yet), so echoing it back meant `detected_language` never
// carried a language at all: the media stage's "detected language" line
// rendered the literal string "auto", and AxcutTranscript.language was
// set to it via transcribe.ts. whisper_full_lang_id() returns the id
// whisper resolved — the detected one under "auto", and the forced one
// otherwise, which is correct for both paths.
std::string resolved_language = language;
const int lang_id = whisper_full_lang_id(ctx);
if (lang_id >= 0) {
if (const char* lang_str = whisper_lang_str(lang_id)) {
resolved_language = lang_str;
}
}
nlohmann::json reply;
reply["language"] = language;
reply["detected_language"] = language;
reply["language"] = resolved_language;
reply["detected_language"] = resolved_language;
reply["backend"] = active_backend;
reply["timing"] = {
{"elapsed_s", elapsed_s},
Expand Down
52 changes: 52 additions & 0 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,49 @@ const HEADLESS = process.env["HEADLESS"] === "true";
// while it is set WILL contain the HUD.
const CONTENT_PROTECTION_DISABLED = process.env["OPENSCREEN_DISABLE_CONTENT_PROTECTION"] === "1";

// Forces protection back on where it is auto-disabled below, so the macOS
// regression can be re-tested against a future Electron without editing code.
const CONTENT_PROTECTION_FORCED = process.env["OPENSCREEN_FORCE_CONTENT_PROTECTION"] === "1";

/**
* macOS 26 (Darwin 25) never displays a window that has had
* `setContentProtection(true)` applied to it.
*
* Not "excludes it from captures" — which is the documented behaviour and the
* whole point — but never paints it for the user either. Confirmed on Darwin
* 25.5 / Electron 41.2.1 with the HUD: tray icon present, renderer alive and
* painting (React mounted, no console errors), `ready-to-show` fired, `show()`
* called, window at valid on-screen coordinates on the main display — and
* nothing on screen. `showMainWindow()` from the tray and the global shortcut
* were equally inert, because the window was already "visible" as far as
* Electron was concerned. Unsetting protection makes it appear instantly; the
* ordering of protect-vs-show makes no difference, so it is the call itself.
*
* `setContentProtection` maps to `NSWindow.sharingType = NSWindowSharingNone`,
* a path Electron has repeatedly churned on (electron/electron#45990, and
* PR #46886 reverting a macOS content-protection refactor).
*
* Gated on the Darwin major version rather than `darwin` wholesale: the
* breakage is only *confirmed* on 25.x, and silently dropping protection on
* older macOS — where it may well work — would be a privacy regression made on
* no evidence.
*
* NOTE: this leaves the HUD capturable on macOS 26. Apple already made that
* partly true regardless — ScreenCaptureKit ignores `sharingType`, so any
* SCK-based recorder (including *ours*, see
* `electron/native/screencapturekit/`) captures these windows anyway. The
* durable fix is to exclude our own windows via `SCContentFilter`'s
* `excludingWindows:`, which that helper currently passes as `[]`.
*/
const CONTENT_PROTECTION_BREAKS_DISPLAY = (() => {
if (process.platform !== "darwin") return false;
// getSystemVersion() reports the *macOS* version on darwin, not the Darwin
// kernel version: measured "26.5.0" here where `os.release()` is "25.5.0".
// So the affected major is 26, and the check must not be fed os.release().
const macOSMajor = Number.parseInt(process.getSystemVersion().split(".")[0] ?? "", 10);
return Number.isFinite(macOSMajor) && macOSMajor >= 26;
})();

function applyContentProtection(win: BrowserWindow, label: string) {
if (CONTENT_PROTECTION_DISABLED) {
console.warn(
Expand All @@ -28,6 +71,15 @@ function applyContentProtection(win: BrowserWindow, label: string) {
);
return;
}
if (CONTENT_PROTECTION_BREAKS_DISPLAY && !CONTENT_PROTECTION_FORCED) {
console.warn(
`[content-protection] OFF for the ${label} window — macOS ` +
`${process.getSystemVersion()} never displays a content-protected window, so ` +
"enabling it would make this window permanently invisible. It may therefore appear " +
"in screen captures. Set OPENSCREEN_FORCE_CONTENT_PROTECTION=1 to re-test.",
);
return;
}
win.setContentProtection(true);
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"build:whisper-binaries": "bash scripts/build-whisper-stt.sh",
"test:whisper-stt": "node scripts/test-whisper-stt.mjs",
"test": "vitest --run",
"test:watch": "vitest",
"test:cursor-native:win": "node scripts/test-windows-native-cursor.mjs",
Expand Down
137 changes: 126 additions & 11 deletions scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,99 @@ const SOURCE_PATHS = [
const FIX =
"Rebuild it with:\n\n npm run build:native:compositor\n\nor use `npm run build:win`, which does that for you.";

const FIX_MAC =
"Rebuild it with:\n\n npm run build:native:compositor:mac\n\nor use `npm run build:mac`, which does that for you.";

/**
* Everything that has to be inside `electron/native/bin/darwin-<arch>/` for the .app to
* work, keyed by what breaks when it is absent.
*
* This list exists because the macOS deliverable had no guard at all: `beforePack`
* returned early on any non-win32 platform, so a mac package built without the compositor
* addon shipped silently — the preview and the export come up dead, with nothing in any
* log to say why. That is precisely the failure mode the staleness check below was written
* to prevent, and the platform guard was letting it through on the other OS.
*
* `mac.extraResources` ships this directory wholesale (`filter: ["darwin-*​/*"]`), so
* "present here" is the same thing as "present in the installed app".
*/
const MAC_REQUIRED = [
{
match: (name) => name === "compositor_view.node",
what: "the Metal compositor addon",
breaks: "the preview and every export render nothing",
fix: FIX_MAC,
},
{
match: (name) => /^libav(codec|format|util)\.\d+\.dylib$/.test(name),
what: "the LGPL ffmpeg dylibs the compositor links",
breaks: "the compositor addon cannot be loaded at all (dyld error at require())",
fix: FIX_MAC,
atLeast: 3,
},
{
match: (name) => name === "whisper-stt-server",
what: "the whisper.cpp STT helper",
breaks: "transcription and captions fail with a developer error shown to end users",
fix: "Build it with:\n\n npm run build:whisper-binaries\n\nor stage CI's with `bash scripts/stage-whisper-stt.sh darwin-<arch>`.",
},
{
match: (name) => /^libggml.*\.dylib$/.test(name),
what: "the ggml backend dylibs the STT helper links",
breaks: "whisper-stt-server dies in dyld before main(), so STT times out with no diagnostic",
fix: "Build it with:\n\n npm run build:whisper-binaries",
atLeast: 1,
},
{
match: (name) => name === "openscreen-screencapturekit-helper",
what: "the ScreenCaptureKit capture helper",
breaks: "native screen capture is unavailable",
fix: "Build it with:\n\n npm run build:native:mac",
},
];

/** electron-builder passes `context.arch` as a numeric enum; map it to our directory tag. */
function archTagFor(context) {
const BY_INDEX = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
const name = BY_INDEX[context?.arch];
return name && name !== "universal" ? name : process.arch;
}

function checkMacNativePayload(context) {
const tag = `darwin-${archTagFor(context)}`;
const dir = path.join(ROOT, "electron", "native", "bin", tag);
if (!fs.existsSync(dir)) {
throw new Error(
`Refusing to package: ${path.relative(ROOT, dir)} does not exist, so the .app would ` +
"ship with no native modules at all.\n\n" +
`${FIX_MAC}\n\nThe STT helper and the capture helper are separate builds — see\n` +
"technical-documentation/engineering/build-and-packaging.md.",
);
}

const present = fs.readdirSync(dir);
const missing = MAC_REQUIRED.filter(
(req) => present.filter((name) => req.match(name)).length < (req.atLeast ?? 1),
);
if (missing.length === 0) {
return;
}

const detail = missing
.map(
(req) =>
` - ${req.what}\n without it: ${req.breaks}\n ${req.fix.replace(/\n+/g, " ")}`,
)
.join("\n");
throw new Error(
`Refusing to package an incomplete macOS payload.\n\n` +
` looked in: ${path.relative(ROOT, dir)}\n\n` +
`Missing:\n${detail}\n\n` +
"Every one of these fails silently or as an unactionable timeout in the installed\n" +
"app, which is why this is a hard error at pack time rather than a warning.",
);
}

/** Newest mtime under `target` (file or directory), or 0 if it does not exist. */
function newestMtimeMs(target) {
let stat;
Expand All @@ -56,14 +149,14 @@ function newestMtimeMs(target) {
return newest;
}

function checkCompositorAddonFreshness() {
if (!fs.existsSync(ADDON)) {
function checkCompositorAddonFreshness(addon = ADDON, fix = FIX, label = "D3D11") {
if (!fs.existsSync(addon)) {
throw new Error(
`Refusing to package: the D3D11 compositor addon is missing.\n\n expected: ${ADDON}\n\n${FIX}`,
`Refusing to package: the ${label} compositor addon is missing.\n\n expected: ${addon}\n\n${fix}`,
);
}

const addonMs = fs.statSync(ADDON).mtimeMs;
const addonMs = fs.statSync(addon).mtimeMs;
const stale = SOURCE_PATHS.map((source) => ({ source, ms: newestMtimeMs(source) })).filter(
(entry) => entry.ms > addonMs,
);
Expand All @@ -73,29 +166,51 @@ function checkCompositorAddonFreshness() {

const newest = stale.reduce((a, b) => (a.ms > b.ms ? a : b));
throw new Error(
"Refusing to package a stale D3D11 compositor addon.\n\n" +
`Refusing to package a stale ${label} compositor addon.\n\n` +
` addon: ${path.relative(ROOT, addon)}\n` +
` addon built: ${new Date(addonMs).toISOString()}\n` +
` newer source: ${path.relative(ROOT, newest.source)} (${new Date(newest.ms).toISOString()})\n\n` +
"Packaging this would silently ship an addon that ignores newer scene fields\n" +
"(they are #[serde(default)], so it falls back instead of erroring).\n\n" +
FIX,
fix,
);
}

exports.default = async function beforePack(context) {
// The compositor addon is Windows-only; skip when packing any other target.
const platform = context?.electronPlatformName ?? process.platform;
if (platform !== "win32") {
if (platform === "win32") {
checkCompositorAddonFreshness();
return;
}
checkCompositorAddonFreshness();
if (platform === "darwin") {
// The addon that actually ships is the arch-tagged copy under
// electron/native/bin/ (mac.extraResources), not the dev copy this hook used to
// be the sole guardian of — so that is the one whose freshness matters.
const tag = `darwin-${archTagFor(context)}`;
const shipped = path.join(ROOT, "electron", "native", "bin", tag, "compositor_view.node");
checkMacNativePayload(context);
checkCompositorAddonFreshness(shipped, FIX_MAC, "Metal");
return;
}
// Linux ships no native addon of its own; nothing to assert.
};

// Runnable on its own for debugging: `node scripts/before-pack.cjs`
if (require.main === module) {
try {
checkCompositorAddonFreshness();
console.log("compositor addon is up to date with its Rust sources.");
if (process.platform === "darwin") {
checkMacNativePayload({ arch: undefined });
const tag = `darwin-${process.arch}`;
checkCompositorAddonFreshness(
path.join(ROOT, "electron", "native", "bin", tag, "compositor_view.node"),
FIX_MAC,
"Metal",
);
console.log(`macOS native payload complete in electron/native/bin/${tag}, addon up to date.`);
} else {
checkCompositorAddonFreshness();
console.log("compositor addon is up to date with its Rust sources.");
}
} catch (err) {
console.error(err.message);
process.exit(1);
Expand Down
Loading
Loading