helper fix

This commit is contained in:
Siddharth
2026-06-06 09:17:24 -07:00
parent c539e9ba26
commit 5f71979e76
3 changed files with 188 additions and 43 deletions
+32
View File
@@ -29,6 +29,14 @@ jobs:
- name: Install dependencies
run: npm ci
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
# (and to avoid 429s when the platform matrix builds hit it at once).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
- name: Build Windows app
run: npm run build:win
env:
@@ -78,6 +86,14 @@ jobs:
env:
npm_config_build_from_source: "false"
# ─── Cache caption assets ─────────────────────────────────
# Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
# ─── Import Code Signing Certificate ──────────────────────
# This is the KEY step that makes CI signing work.
# We create a temporary keychain, import the .p12 cert into it,
@@ -120,6 +136,14 @@ jobs:
- name: Build Vite + Electron
run: npx tsc && npx vite build
# ─── Build native macOS helpers ───────────────────────────
# The package step below calls electron-builder directly (not `npm run build:mac`),
# so the Swift screencapturekit + cursor helpers must be compiled here first or the
# packaged app ships without them (no recording, "cursor helper couldn't be found").
# Builds universal binaries into electron/native/bin/darwin-{arm64,x64}.
- name: Build native macOS helpers
run: npm run build:native:mac
# ─── Package with electron-builder ────────────────────────
# electron-builder handles deep codesigning the .app bundle
# "notarize: false" in electron-builder.json5 prevents it from
@@ -245,6 +269,14 @@ jobs:
- name: Install pacman build dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
# Cache the downloaded caption model so we don't re-fetch from HuggingFace every run
# (and to avoid 429s when the platform matrix builds hit it at once).
- name: Cache caption assets
uses: actions/cache@v4
with:
path: caption-assets
key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }}
- name: Build Linux app
run: npm run build:linux
env:
+104 -39
View File
@@ -18,14 +18,20 @@ const cursorHelperName = "openscreen-macos-cursor-helper";
const packageDir = path.join(root, "electron", "native", "screencapturekit");
const buildDir = path.join(packageDir, "build");
const swiftBuildDir = path.join(buildDir, "swiftpm");
const builtHelperPath = path.join(swiftBuildDir, "release", helperName);
const localHelperPath = path.join(buildDir, helperName);
const builtCursorHelperPath = path.join(swiftBuildDir, "release", cursorHelperName);
const localCursorHelperPath = path.join(buildDir, cursorHelperName);
const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
const distributableDir = path.join(root, "electron", "native", "bin", archTag);
const distributablePath = path.join(distributableDir, helperName);
const distributableCursorHelperPath = path.join(distributableDir, cursorHelperName);
// Build a universal (arm64 + x86_64) binary by default so both the arm64 and x64 DMGs ship a helper
// that runs natively. Override with OPENSCREEN_MAC_HELPER_ARCHS (comma-separated) for a faster
// single-arch local build.
const archs = (process.env.OPENSCREEN_MAC_HELPER_ARCHS ?? "arm64,x86_64")
.split(",")
.map((a) => a.trim())
.filter(Boolean);
const archToTag = (arch) => (arch === "x86_64" || arch === "x64" ? "darwin-x64" : "darwin-arm64");
// A universal binary runs on both arches, so when building both, place it in each dir the runtime
// might read (it resolves electron/native/bin/<darwin-arm64|darwin-x64> by the running app's arch).
const targetTags = archs.length > 1 ? ["darwin-arm64", "darwin-x64"] : [archToTag(archs[0])];
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
cwd: root,
@@ -50,42 +56,101 @@ if (xcodebuildVersion.status !== 0) {
process.exit(1);
}
const result = spawnSync(
"swift",
["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir],
{
cwd: root,
stdio: "inherit",
},
);
if (result.error) {
console.error(`Failed to start Swift build: ${result.error.message}`);
process.exit(1);
// Locate a built binary by name under a build dir (SwiftPM's exact output path varies by version).
function findArtifact(dir, name) {
const stack = [dir];
const matches = [];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile() && entry.name === name) matches.push(full);
}
}
matches.sort((a, b) => {
const ra = /release/i.test(a) ? 0 : 1;
const rb = /release/i.test(b) ? 0 : 1;
if (ra !== rb) return ra - rb;
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
});
return matches[0] ?? null;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
// Build each arch in its own SwiftPM invocation. A single --arch uses SwiftPM's lenient build path
// that tolerates the helper's `@main` inside a main.swift file; combining arches in one invocation
// (--arch a --arch b) switches to the stricter "apple" build system that rejects it. We lipo the
// slices together afterwards.
const slicesByName = { [helperName]: [], [cursorHelperName]: [] };
for (const arch of archs) {
const archBuildDir = path.join(swiftBuildDir, arch);
const result = spawnSync(
"swift",
[
"build",
"-c",
"release",
"--arch",
arch,
"--package-path",
packageDir,
"--build-path",
archBuildDir,
],
{
cwd: root,
stdio: "inherit",
},
);
if (result.error) {
console.error(`Failed to start Swift build (${arch}): ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
for (const name of [helperName, cursorHelperName]) {
const artifact = findArtifact(archBuildDir, name);
if (!artifact) {
console.error(`Swift build (${arch}) completed but artifact was not found: ${name}`);
process.exit(1);
}
slicesByName[name].push(artifact);
}
}
fs.mkdirSync(buildDir, { recursive: true });
fs.mkdirSync(distributableDir, { recursive: true });
for (const artifactPath of [builtHelperPath, builtCursorHelperPath]) {
if (!fs.existsSync(artifactPath)) {
console.error(`Swift build completed but expected artifact was not found: ${artifactPath}`);
process.exit(1);
}
}
fs.copyFileSync(builtHelperPath, localHelperPath);
fs.copyFileSync(builtHelperPath, distributablePath);
fs.copyFileSync(builtCursorHelperPath, localCursorHelperPath);
fs.copyFileSync(builtCursorHelperPath, distributableCursorHelperPath);
fs.chmodSync(localHelperPath, 0o755);
fs.chmodSync(distributablePath, 0o755);
fs.chmodSync(localCursorHelperPath, 0o755);
fs.chmodSync(distributableCursorHelperPath, 0o755);
const targetDirs = targetTags.map((tag) => path.join(root, "electron", "native", "bin", tag));
for (const dir of targetDirs) fs.mkdirSync(dir, { recursive: true });
console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`);
console.log(`Copied redistributable helper: ${distributablePath}`);
console.log(`Built macOS cursor helper: ${localCursorHelperPath}`);
console.log(`Copied redistributable cursor helper: ${distributableCursorHelperPath}`);
for (const [name, localPath] of [
[helperName, localHelperPath],
[cursorHelperName, localCursorHelperPath],
]) {
const slices = slicesByName[name];
let source = slices[0];
// Stitch the per-arch slices into one universal (fat) binary so the same file runs on both arches.
if (slices.length > 1) {
const universal = path.join(buildDir, `${name}.universal`);
const lipo = spawnSync("lipo", ["-create", ...slices, "-output", universal], {
cwd: root,
stdio: "inherit",
});
if (lipo.status !== 0) {
console.error(`lipo failed to combine ${name} (${archs.join(", ")})`);
process.exit(lipo.status ?? 1);
}
source = universal;
}
for (const dest of [localPath, ...targetDirs.map((dir) => path.join(dir, name))]) {
fs.copyFileSync(source, dest);
fs.chmodSync(dest, 0o755);
}
console.log(`Built ${name} (${archs.join(", ")}) → ${targetTags.join(", ")}`);
}
+52 -4
View File
@@ -48,16 +48,64 @@ async function exists(filePath) {
}
}
const MAX_ATTEMPTS = 6;
// HuggingFace rate-limits (429) when the parallel CI matrix builds all hit it at once; also retry the
// usual transient server errors.
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function backoffMs(attempt, retryAfter) {
// Honor Retry-After when the server sends it (seconds or an HTTP date).
if (retryAfter) {
const secs = Number(retryAfter);
if (Number.isFinite(secs)) return Math.min(60_000, secs * 1000);
const at = Date.parse(retryAfter);
if (!Number.isNaN(at)) return Math.min(60_000, Math.max(0, at - Date.now()));
}
// Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, capped at 60s.
return Math.min(60_000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000);
}
async function fetchWithRetry(url) {
let lastErr;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await fetch(url, { headers: { "user-agent": "openscreen-build" } });
if (res.ok && res.body) return res;
if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_ATTEMPTS) {
const wait = backoffMs(attempt, res.headers.get("retry-after"));
console.log(
` … HTTP ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`,
);
await sleep(wait);
continue;
}
throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`);
} catch (err) {
lastErr = err;
const isHttp = err instanceof Error && err.message.startsWith("Failed to download");
if (isHttp || attempt >= MAX_ATTEMPTS) throw err;
// Network/DNS error: back off and retry.
const wait = backoffMs(attempt, null);
console.log(
`${err.message}, retry ${attempt}/${MAX_ATTEMPTS - 1} in ${Math.round(wait / 1000)}s`,
);
await sleep(wait);
}
}
throw lastErr;
}
async function download(url, dest) {
if (await exists(dest)) {
console.log(` ✓ cached ${path.relative(OUT, dest)}`);
return;
}
await mkdir(path.dirname(dest), { recursive: true });
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`);
}
const res = await fetchWithRetry(url);
const tmp = `${dest}.partial`;
await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
const { rename } = await import("node:fs/promises");