Publish OpenScreen 1.4.11 update assets

This commit is contained in:
huanld
2026-06-05 16:32:22 +07:00
commit 1efb945ab1
448 changed files with 80631 additions and 0 deletions
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
if (process.platform !== "darwin") {
console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS.");
process.exit(0);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const helperName = "openscreen-screencapturekit-helper";
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);
const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], {
cwd: root,
encoding: "utf8",
});
if (xcodebuildVersion.status !== 0) {
const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim();
console.error(
[
"Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.",
"",
message,
"",
"Install Xcode from the App Store or Apple Developer downloads, then run:",
" sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer",
" sudo xcodebuild -license accept",
"",
"Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.",
].join("\n"),
);
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);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
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);
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}`);
+163
View File
@@ -0,0 +1,163 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const OCR_DIR = path.join(ROOT, "tools", "ocr");
const VENV_DIR = path.join(ROOT, ".venv-ocr-build");
const VENV_PYTHON = path.join(VENV_DIR, "Scripts", "python.exe");
const DIST_DIR = path.join(OCR_DIR, "dist");
const WORK_DIR = path.join(OCR_DIR, "build");
const MODEL_CACHE_DIR = path.join(OCR_DIR, "models", "paddlex");
const ENTRYPOINT = path.join(OCR_DIR, "openscreen_ocr_service_entry.py");
const OUTPUT_DIR = path.join(DIST_DIR, "openscreen-ocr-service");
const OUTPUT_EXE = path.join(OUTPUT_DIR, "openscreen-ocr-service.exe");
const REQUIRED_MODEL_NAMES = ["PP-OCRv5_mobile_det", "latin_PP-OCRv5_mobile_rec"];
if (process.platform !== "win32") {
console.log("Skipping Windows OCR service build on non-Windows host.");
process.exit(0);
}
function run(command, args, options = {}) {
console.log(`> ${command} ${args.join(" ")}`);
execFileSync(command, args, {
cwd: ROOT,
stdio: "inherit",
...options,
});
}
function ensureVenv() {
if (fs.existsSync(VENV_PYTHON)) {
return;
}
run(process.env.PYTHON ?? "python", ["-m", "venv", VENV_DIR]);
}
function installDependencies() {
run(VENV_PYTHON, ["-m", "pip", "install", "--upgrade", "pip"]);
run(VENV_PYTHON, ["-m", "pip", "install", "-r", path.join(OCR_DIR, "requirements.txt")]);
run(VENV_PYTHON, ["-m", "pip", "install", "pyinstaller>=6.0"]);
}
function prepareModelCache() {
const officialModelsDir = path.join(MODEL_CACHE_DIR, "official_models");
const hasRequiredModels = REQUIRED_MODEL_NAMES.every((modelName) =>
fs.existsSync(path.join(officialModelsDir, modelName)),
);
if (hasRequiredModels) {
return;
}
fs.mkdirSync(officialModelsDir, { recursive: true });
run(
VENV_PYTHON,
[
"-c",
[
"import sys",
`sys.path.insert(0, ${JSON.stringify(OCR_DIR)})`,
"from paddle_ocr_service import _create_engine",
"_create_engine('latin')",
].join("; "),
],
{
env: {
...process.env,
PADDLE_PDX_CACHE_HOME: MODEL_CACHE_DIR,
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK: "True",
PADDLE_PDX_ENABLE_MKLDNN_BYDEFAULT: "False",
PADDLEOCR_DEVICE: "cpu",
PADDLEOCR_ENABLE_MKLDNN: "0",
PADDLEOCR_LANG: "latin",
PADDLEOCR_USE_MOBILE: "1",
PYTHONUTF8: "1",
},
},
);
}
function buildService() {
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
fs.mkdirSync(DIST_DIR, { recursive: true });
fs.mkdirSync(WORK_DIR, { recursive: true });
run(VENV_PYTHON, [
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--onedir",
"--name",
"openscreen-ocr-service",
"--distpath",
DIST_DIR,
"--workpath",
WORK_DIR,
"--specpath",
WORK_DIR,
"--paths",
OCR_DIR,
"--collect-all",
"paddleocr",
"--collect-all",
"paddle",
"--collect-all",
"paddlex",
"--collect-all",
"cv2",
"--collect-all",
"shapely",
"--collect-all",
"pyclipper",
"--collect-all",
"pypdfium2",
"--collect-all",
"bidi",
"--copy-metadata",
"paddleocr",
"--copy-metadata",
"paddlex",
"--copy-metadata",
"paddlepaddle",
"--copy-metadata",
"opencv-contrib-python",
"--copy-metadata",
"shapely",
"--copy-metadata",
"pyclipper",
"--copy-metadata",
"pypdfium2",
"--copy-metadata",
"python-bidi",
"--hidden-import",
"uvicorn.logging",
"--hidden-import",
"uvicorn.loops",
"--hidden-import",
"uvicorn.loops.auto",
"--hidden-import",
"uvicorn.protocols",
"--hidden-import",
"uvicorn.protocols.http",
"--hidden-import",
"uvicorn.protocols.http.auto",
"--hidden-import",
"uvicorn.lifespan",
"--hidden-import",
"uvicorn.lifespan.on",
ENTRYPOINT,
]);
if (!fs.existsSync(OUTPUT_EXE)) {
throw new Error(`OCR service build did not produce ${OUTPUT_EXE}`);
}
console.log(`Built OCR service: ${OUTPUT_EXE}`);
}
ensureVenv();
installDependencies();
prepareModelCache();
buildService();
+159
View File
@@ -0,0 +1,159 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture");
const BUILD_DIR = path.join(SOURCE_DIR, "build");
const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs");
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64");
const CMAKE = process.env.CMAKE_EXE ?? "cmake";
function findVcVarsAll() {
const explicit = process.env.VCVARSALL;
if (explicit && fs.existsSync(explicit)) {
return explicit;
}
const roots = [
process.env.VSINSTALLDIR,
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Community",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional",
"C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise",
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
"C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\Community",
];
for (const root of roots.filter(Boolean)) {
const candidate = path.join(root, "VC", "Auxiliary", "Build", "vcvarsall.bat");
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function findWindowsSdkUmLibDir() {
const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib";
if (!fs.existsSync(sdkLibRoot)) {
return null;
}
return fs
.readdirSync(sdkLibRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64"))
.filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib")))
.sort()
.at(-1);
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT,
stdio: "inherit",
windowsHide: true,
...options,
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
}
});
});
}
async function runInVsEnv(command) {
const vcvarsAll = findVcVarsAll();
if (!vcvarsAll) {
throw new Error(
"Could not find Visual Studio vcvarsall.bat. Install Visual Studio Build Tools with C++.",
);
}
const sdkUmLibDir = findWindowsSdkUmLibDir();
const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`);
fs.writeFileSync(
cmdPath,
[
"@echo off",
`call "${vcvarsAll}" x64`,
"if errorlevel 1 exit /b %errorlevel%",
`if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`,
`for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`,
"if errorlevel 1 exit /b %errorlevel%",
`set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`,
command,
"exit /b %errorlevel%",
"",
].join("\r\n"),
);
try {
await run("cmd.exe", ["/d", "/c", cmdPath]);
} finally {
fs.rmSync(cmdPath, { force: true });
}
}
if (process.platform !== "win32") {
console.log("Skipping WGC helper build: Windows-only.");
process.exit(0);
}
fs.mkdirSync(BUILD_DIR, { recursive: true });
await runInVsEnv(
`"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`,
);
await runInVsEnv(`"${CMAKE}" --build "${BUILD_DIR}" --config Release`);
const outputPath = path.join(BUILD_DIR, "wgc-capture.exe");
if (!fs.existsSync(outputPath)) {
throw new Error(`WGC helper build completed but ${outputPath} was not found.`);
}
const cursorSamplerOutputPath = path.join(BUILD_DIR, "cursor-sampler.exe");
if (!fs.existsSync(cursorSamplerOutputPath)) {
throw new Error(`WGC helper build completed but ${cursorSamplerOutputPath} was not found.`);
}
const guideHotkeyListenerOutputPath = path.join(BUILD_DIR, "guide-hotkey-listener.exe");
if (!fs.existsSync(guideHotkeyListenerOutputPath)) {
throw new Error(`WGC helper build completed but ${guideHotkeyListenerOutputPath} was not found.`);
}
const ocrServiceWrapperOutputPath = path.join(BUILD_DIR, "openscreen-ocr-service-wrapper.exe");
if (!fs.existsSync(ocrServiceWrapperOutputPath)) {
throw new Error(`WGC helper build completed but ${ocrServiceWrapperOutputPath} was not found.`);
}
fs.mkdirSync(BIN_DIR, { recursive: true });
const distributablePath = path.join(BIN_DIR, "wgc-capture.exe");
fs.copyFileSync(outputPath, distributablePath);
const cursorSamplerDistributablePath = path.join(BIN_DIR, "cursor-sampler.exe");
fs.copyFileSync(cursorSamplerOutputPath, cursorSamplerDistributablePath);
const guideHotkeyListenerDistributablePath = path.join(BIN_DIR, "guide-hotkey-listener.exe");
fs.copyFileSync(guideHotkeyListenerOutputPath, guideHotkeyListenerDistributablePath);
const ocrServiceWrapperDistributablePath = path.join(BIN_DIR, "openscreen-ocr-service-wrapper.exe");
fs.copyFileSync(ocrServiceWrapperOutputPath, ocrServiceWrapperDistributablePath);
console.log(`Built ${outputPath}`);
console.log(`Copied ${distributablePath}`);
console.log(`Built ${cursorSamplerOutputPath}`);
console.log(`Copied ${cursorSamplerDistributablePath}`);
console.log(`Built ${guideHotkeyListenerOutputPath}`);
console.log(`Copied ${guideHotkeyListenerDistributablePath}`);
console.log(`Built ${ocrServiceWrapperOutputPath}`);
console.log(`Copied ${ocrServiceWrapperDistributablePath}`);
+216
View File
@@ -0,0 +1,216 @@
#!/bin/bash
#
# OpenScreen macOS Build Script
# Produces: release/<version>/OpenScreen-Mac-<arch>-<version>.dmg
#
# Usage: chmod +x scripts/build_macos.sh && ./scripts/build_macos.sh
#
set -euo pipefail
# ── Load .env ─────────────────────────────────────────────────────────
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="${PROJECT_ROOT}/.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
else
echo "ERROR: .env file not found at ${ENV_FILE}"
echo "Create one with APP_NAME, SIGN_IDENTITY, NOTARY_PROFILE, etc."
exit 1
fi
# ── Config ────────────────────────────────────────────────────────────
VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version")
RELEASE_DIR="${PROJECT_ROOT}/release/${VERSION}"
ENTITLEMENTS="${PROJECT_ROOT}/macos.entitlements"
ARCHS=("arm64" "x64")
# ── Colors ────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
print_step() { echo -e "\n${CYAN}${BOLD}$1${NC}"; }
print_ok() { echo -e "${GREEN}$1${NC}"; }
print_warn() { echo -e "${YELLOW}$1${NC}"; }
print_err() { echo -e "${RED}$1${NC}"; }
# ── Preflight ─────────────────────────────────────────────────────────
echo -e "\n${BOLD}╔══════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${APP_NAME} macOS Build Script v${VERSION}${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}"
print_step "Checking prerequisites..."
if [[ "$(uname)" != "Darwin" ]]; then
print_err "This script must be run on macOS."
exit 1
fi
print_ok "Running on macOS ($(uname -m))"
if ! command -v node &> /dev/null; then
print_err "Node.js not found. Please install Node.js first."
exit 1
fi
print_ok "Node.js found: $(node -v)"
if ! command -v npm &> /dev/null; then
print_err "npm not found."
exit 1
fi
print_ok "npm found: $(npm -v)"
# Check signing identity
if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then
print_err "Signing identity not found: ${SIGN_IDENTITY}"
print_err "Run 'security find-identity -v -p codesigning' to see available identities."
exit 1
fi
print_ok "Signing identity found: ${SIGN_IDENTITY}"
# Check notary profile
if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &> /dev/null; then
print_err "Notary profile '${NOTARY_PROFILE}' not found in keychain."
print_err "Run: xcrun notarytool store-credentials \"${NOTARY_PROFILE}\" --apple-id \"${APPLE_ID}\" --team-id \"${TEAM_ID}\""
exit 1
fi
print_ok "Notary profile found: ${NOTARY_PROFILE}"
# Check entitlements
if [ ! -f "$ENTITLEMENTS" ]; then
print_err "Entitlements file not found: ${ENTITLEMENTS}"
exit 1
fi
print_ok "Entitlements file found"
# ── Clean ─────────────────────────────────────────────────────────────
cd "$PROJECT_ROOT"
print_step "Cleaning previous build artifacts..."
rm -rf dist dist-electron "${RELEASE_DIR}"
print_ok "Clean complete"
# ── Install Dependencies ─────────────────────────────────────────────
print_step "Installing dependencies..."
npm ci
print_ok "Dependencies installed"
# ── Build Vite + Electron ────────────────────────────────────────────
print_step "Building Vite + Electron... (this may take a minute)"
npx tsc && npx vite build
print_ok "Vite + Electron build complete"
# ── Package, Sign, Notarize per Architecture ─────────────────────────
for ARCH in "${ARCHS[@]}"; do
echo ""
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD} Building for: ${ARCH}${NC}"
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# ── Package with electron-builder ─────────────────────────────
print_step "[${ARCH}] Packaging with electron-builder..."
# Build .app only (--dir), electron-builder handles codesigning
# with hardenedRuntime + entitlements from electron-builder.json5
CSC_NAME="$CSC_NAME" npx electron-builder --mac --${ARCH} --dir
# Find the .app bundle
APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | grep -i "${ARCH}\|mac" | head -n1)
if [ -z "$APP_BUNDLE" ]; then
# Fallback: find any .app in the output
APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)
fi
if [ -z "$APP_BUNDLE" ]; then
print_err "[${ARCH}] Could not find .app bundle in ${RELEASE_DIR}"
exit 1
fi
print_ok "[${ARCH}] App bundle: $(basename "$APP_BUNDLE")"
# ── Verify codesign on .app ───────────────────────────────────
print_step "[${ARCH}] Verifying .app code signature..."
codesign --verify --deep --strict "$APP_BUNDLE" 2>&1 || print_warn "[${ARCH}] Deep verify had warnings (may be expected pre-notarization)"
print_ok "[${ARCH}] .app signature verified"
# ── Create DMG ────────────────────────────────────────────────
DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
DMG_STAGING="${RELEASE_DIR}/dmg-staging-${ARCH}"
print_step "[${ARCH}] Creating DMG..."
rm -f "$DMG_OUTPUT"
rm -rf "$DMG_STAGING"
# Stage: app + Applications shortcut for drag-to-install
mkdir -p "$DMG_STAGING"
cp -R "$APP_BUNDLE" "$DMG_STAGING/"
ln -s /Applications "$DMG_STAGING/Applications"
hdiutil create \
-srcfolder "$DMG_STAGING" \
-volname "${APP_NAME}" \
-fs HFS+ \
-fsargs "-c c=64,a=16,e=16" \
-format UDBZ \
"$DMG_OUTPUT"
print_ok "[${ARCH}] DMG created: ${DMG_NAME}"
rm -rf "$DMG_STAGING"
# ── Sign DMG ──────────────────────────────────────────────────
print_step "[${ARCH}] Signing DMG..."
codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_OUTPUT"
print_ok "[${ARCH}] DMG signed"
# ── Notarize DMG ──────────────────────────────────────────────
print_step "[${ARCH}] Notarizing DMG with Apple... (this may take several minutes)"
xcrun notarytool submit "$DMG_OUTPUT" \
--keychain-profile "$NOTARY_PROFILE" \
--wait
print_ok "[${ARCH}] DMG notarized"
# ── Staple ────────────────────────────────────────────────────
print_step "[${ARCH}] Stapling notarization ticket..."
xcrun stapler staple "$DMG_OUTPUT"
print_ok "[${ARCH}] Ticket stapled"
# ── Validate ──────────────────────────────────────────────────
print_step "[${ARCH}] Validating stapled DMG..."
xcrun stapler validate "$DMG_OUTPUT"
print_ok "[${ARCH}] Validation passed"
done
# ── Clean up unpacked dirs (keep only DMGs) ───────────────────────────
print_step "Cleaning up intermediate directories..."
find "${RELEASE_DIR}" -maxdepth 1 -type d ! -name "$(basename "$RELEASE_DIR")" -exec rm -rf {} + 2>/dev/null || true
print_ok "Cleanup complete"
# ── Done ──────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} Build & Notarization Complete!${NC}"
echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
echo ""
for ARCH in "${ARCHS[@]}"; do
DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
DMG_PATH="${RELEASE_DIR}/${DMG_NAME}"
if [ -f "$DMG_PATH" ]; then
DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1)
echo -e " 📦 ${BOLD}${ARCH}:${NC} ${DMG_PATH}"
echo -e " 📏 ${BOLD}Size:${NC} ${DMG_SIZE}"
echo ""
fi
done
echo -e " ${GREEN}All DMGs are fully signed, notarized, and stapled!${NC}"
echo -e " ${GREEN}Ready for distribution outside the Mac App Store.${NC}"
echo ""
+258
View File
@@ -0,0 +1,258 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, _electron as electron } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const MAIN_JS = path.join(ROOT, "dist-electron", "main.js");
const TEST_VIDEO = path.join(ROOT, "tests", "fixtures", "sample.webm");
const OUTPUT_DIR =
process.env.OPENSCREEN_PREVIEW_OUTPUT_DIR ??
path.join(os.tmpdir(), `openscreen-real-preview-${Date.now()}`);
const FRAME_COUNT = Number(process.env.OPENSCREEN_PREVIEW_FRAME_COUNT ?? 90);
const FPS = Number(process.env.OPENSCREEN_PREVIEW_FPS ?? 30);
function findLatestCursorRecordingData() {
const explicit = process.env.CURSOR_RECORDING_DATA_PATH;
if (explicit) {
if (!fs.existsSync(explicit)) {
throw new Error(`CURSOR_RECORDING_DATA_PATH does not exist: ${explicit}`);
}
return explicit;
}
const tempDir = os.tmpdir();
const candidates = fs
.readdirSync(tempDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openscreen-cursor-native-"))
.map((entry) => path.join(tempDir, entry.name, "cursor-recording-data.json"))
.filter((candidate) => fs.existsSync(candidate))
.map((candidate) => ({ path: candidate, mtimeMs: fs.statSync(candidate).mtimeMs }))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (!candidates[0]) {
throw new Error(
"No cursor-recording-data.json found. Run npm run test:cursor-native:win first.",
);
}
return candidates[0].path;
}
function findPlaywrightChromiumExecutable(defaultPath) {
if (fs.existsSync(defaultPath)) {
return defaultPath;
}
const baseDir = path.join(process.env.LOCALAPPDATA ?? "", "ms-playwright");
if (!baseDir || !fs.existsSync(baseDir)) {
return defaultPath;
}
const candidates = fs
.readdirSync(baseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("chromium-"))
.map((entry) => path.join(baseDir, entry.name, "chrome-win64", "chrome.exe"))
.filter((candidate) => fs.existsSync(candidate))
.sort()
.reverse();
return candidates[0] ?? defaultPath;
}
function ensureBuildExists() {
if (!fs.existsSync(MAIN_JS)) {
throw new Error(`Missing ${MAIN_JS}. Run npm run build-vite first.`);
}
if (!fs.existsSync(path.join(ROOT, "dist", "index.html"))) {
throw new Error(`Missing renderer build. Run npm run build-vite first.`);
}
}
function runNpmBuildViteIfRequested() {
if (process.env.OPENSCREEN_PREVIEW_SKIP_BUILD === "true") {
ensureBuildExists();
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const child = spawn("cmd.exe", ["/d", "/s", "/c", "npm run build-vite"], {
cwd: ROOT,
stdio: "inherit",
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`npm run build-vite failed with code ${code}`));
});
});
}
async function encodeFramesToWebm(framePaths, outputPath) {
const frameData = framePaths.map((framePath) => ({
src: `data:image/png;base64,${fs.readFileSync(framePath).toString("base64")}`,
}));
const html = `<!doctype html>
<html>
<body>
<canvas id="canvas" width="1280" height="800"></canvas>
<script>
const frames = ${JSON.stringify(frameData)};
const fps = ${FPS};
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
function load(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.src = src;
});
}
window.__encode = async function() {
const images = [];
for (const frame of frames) images.push(await load(frame.src));
canvas.width = images[0].naturalWidth;
canvas.height = images[0].naturalHeight;
const stream = canvas.captureStream(fps);
const recorder = new MediaRecorder(stream, { mimeType: "video/webm;codecs=vp9" });
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
const done = new Promise((resolve) => {
recorder.onstop = resolve;
});
recorder.start();
for (const image of images) {
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
await new Promise((resolve) => setTimeout(resolve, 1000 / fps));
}
recorder.stop();
await done;
const blob = new Blob(chunks, { type: "video/webm" });
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
};
</script>
</body>
</html>`;
const browser = await chromium.launch({
executablePath: findPlaywrightChromiumExecutable(chromium.executablePath()),
headless: true,
});
try {
const page = await browser.newPage();
await page.setContent(html);
const base64 = await page.evaluate(() => window.__encode());
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
} finally {
await browser.close();
}
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const cursorRecordingDataPath = findLatestCursorRecordingData();
const fixtureVideoPath = path.join(OUTPUT_DIR, "openscreen-preview-fixture.webm");
const outputVideoPath = path.join(OUTPUT_DIR, "openscreen-preview.webm");
fs.copyFileSync(TEST_VIDEO, fixtureVideoPath);
fs.copyFileSync(cursorRecordingDataPath, `${fixtureVideoPath}.cursor.json`);
await runNpmBuildViteIfRequested();
const app = await electron.launch({
args: [MAIN_JS, "--no-sandbox", "--enable-unsafe-swiftshader"],
env: {
...process.env,
HEADLESS: "false",
},
});
app.process().stdout?.on("data", (data) => process.stdout.write(`[electron] ${data}`));
app.process().stderr?.on("data", (data) => process.stderr.write(`[electron] ${data}`));
const framesDir = path.join(OUTPUT_DIR, "frames");
fs.mkdirSync(framesDir, { recursive: true });
try {
const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded");
await hudWindow.evaluate(async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
await window.electronAPI.getCurrentRecordingSession();
await window.electronAPI.getCurrentVideoPath();
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error("Timed out waiting for OpenScreen IPC handlers.");
});
try {
await hudWindow.evaluate(async (videoPath) => {
await window.electronAPI.setCurrentVideoPath(videoPath);
await window.electronAPI.switchToEditor();
}, fixtureVideoPath);
} catch {
// switchToEditor closes the HUD page before the evaluate promise can always resolve.
}
const editorWindow = await app.waitForEvent("window", {
predicate: (window) => window.url().includes("windowType=editor"),
timeout: 30_000,
});
await editorWindow.waitForLoadState("domcontentloaded");
await editorWindow.waitForSelector("video", { state: "attached", timeout: 30_000 });
await editorWindow.waitForSelector("canvas", { state: "attached", timeout: 30_000 });
await editorWindow.setViewportSize({ width: 1280, height: 800 });
await editorWindow.evaluate(async () => {
await document.fonts.ready;
for (const video of [...document.querySelectorAll("video")]) {
video.muted = true;
video.currentTime = 0;
video.dispatchEvent(new Event("timeupdate"));
}
});
await editorWindow.waitForTimeout(1000);
const framePaths = [];
for (let index = 0; index < FRAME_COUNT; index += 1) {
const timeSec = index / FPS;
await editorWindow.evaluate((time) => {
for (const video of [...document.querySelectorAll("video")]) {
video.currentTime = Math.min(time, Math.max(0, video.duration || time));
video.dispatchEvent(new Event("timeupdate"));
}
}, timeSec);
await editorWindow.waitForTimeout(40);
const framePath = path.join(framesDir, `frame-${String(index).padStart(4, "0")}.png`);
await editorWindow.screenshot({ path: framePath });
framePaths.push(framePath);
}
await encodeFramesToWebm(framePaths, outputVideoPath);
const report = {
outputDir: OUTPUT_DIR,
sourceCursorRecordingDataPath: cursorRecordingDataPath,
fixtureVideoPath,
outputVideoPath,
frameCount: framePaths.length,
fps: FPS,
};
fs.writeFileSync(path.join(OUTPUT_DIR, "report.json"), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
} finally {
await app.close();
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares all locale folders (except en) against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
function getKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
let hasErrors = false;
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
const namespaces = fs
.readdirSync(baseDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
const compareLocales = fs
.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((locale) => locale !== BASE_LOCALE)
.sort((a, b) => a.localeCompare(b));
for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);
for (const locale of compareLocales) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(localePath)) {
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
hasErrors = true;
continue;
}
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
const localeKeys = getKeys(localeData);
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
if (missing.length > 0) {
console.error(`MISSING in ${locale}/${namespace}.json:`);
for (const key of missing) {
console.error(` - ${key}`);
}
hasErrors = true;
}
if (extra.length > 0) {
console.error(`EXTRA in ${locale}/${namespace}.json:`);
for (const key of extra) {
console.error(` + ${key}`);
}
hasErrors = true;
}
}
}
if (hasErrors) {
console.error("\ni18n check FAILED — translation files are out of sync.");
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const CLICK_ANIMATION_MS = 260;
function usage() {
console.error(
"Usage: node scripts/inspect-native-cursor-click-bounce.mjs <video-or-cursor-json-path> [--bounce=5]",
);
process.exit(1);
}
function getCursorJsonPath(inputPath) {
if (!inputPath) {
usage();
}
const resolved = path.resolve(inputPath);
if (resolved.endsWith(".cursor.json")) {
return resolved;
}
return `${resolved}.cursor.json`;
}
function getBounceValue() {
const arg = process.argv.find((value) => value.startsWith("--bounce="));
const parsed = Number(arg?.slice("--bounce=".length) ?? 5);
return Number.isFinite(parsed) ? Math.min(5, Math.max(0, parsed)) : 5;
}
function clickBounceProgress(samples, timeMs) {
for (let index = samples.length - 1; index >= 0; index -= 1) {
const sample = samples[index];
if (sample.timeMs > timeMs) {
continue;
}
const ageMs = timeMs - sample.timeMs;
if (ageMs > CLICK_ANIMATION_MS) {
return 0;
}
if (sample.interactionType === "click") {
return 1 - ageMs / CLICK_ANIMATION_MS;
}
}
return 0;
}
function clickBounceScale(clickBounce, progress) {
if (progress <= 0 || clickBounce <= 0) {
return 1;
}
const intensity = Math.min(5, Math.max(0, clickBounce)) / 5;
const elapsed = 1 - Math.min(1, Math.max(0, progress));
if (elapsed < 0.38) {
const pressProgress = Math.sin((elapsed / 0.38) * Math.PI);
return 1 - pressProgress * intensity * 0.24;
}
const reboundProgress = Math.sin(((elapsed - 0.38) / 0.62) * Math.PI);
return 1 + reboundProgress * intensity * 0.16;
}
const cursorJsonPath = getCursorJsonPath(process.argv[2]);
const clickBounce = getBounceValue();
const parsed = JSON.parse(fs.readFileSync(cursorJsonPath, "utf8"));
const samples = (Array.isArray(parsed) ? parsed : (parsed.samples ?? [])).sort(
(a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0),
);
const clicks = samples.filter((sample) => sample.interactionType === "click");
const windows = clicks.slice(0, 8).map((click) => {
const times = [0, 33, 66, 100, 133, 166, 200, 233, 260].map(
(offsetMs) => click.timeMs + offsetMs,
);
return {
clickTimeMs: click.timeMs,
cursorType: click.cursorType ?? null,
assetId: click.assetId ?? null,
scales: times.map((timeMs) => ({
timeMs,
progress: Number(clickBounceProgress(samples, timeMs).toFixed(3)),
scale: Number(clickBounceScale(clickBounce, clickBounceProgress(samples, timeMs)).toFixed(3)),
})),
};
});
const report = {
cursorJsonPath,
provider: parsed.provider ?? (Array.isArray(parsed) ? "legacy-array" : null),
sampleCount: samples.length,
assetCount: Array.isArray(parsed.assets) ? parsed.assets.length : 0,
clickCount: clicks.length,
interactionCounts: samples.reduce((counts, sample) => {
const key = sample.interactionType ?? "missing";
counts[key] = (counts[key] ?? 0) + 1;
return counts;
}, {}),
clickBounce,
windows,
};
console.log(JSON.stringify(report, null, 2));
if (clicks.length === 0) {
process.exitCode = 2;
}
+173
View File
@@ -0,0 +1,173 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const rootDir = process.cwd();
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
function loadLocalSigningEnv() {
const envPath = path.join(rootDir, ".env.signing.local");
if (!fs.existsSync(envPath)) {
return;
}
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match || process.env[match[1]]) {
continue;
}
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
}
}
function usage() {
return [
"Usage:",
" node scripts/sign-windows-private-trust.mjs [--file <path>]",
"",
"Defaults to release/<version>/Openscreen Setup <version>.exe",
].join("\n");
}
function parseArgs(argv) {
const args = { file: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}
if (arg === "--file") {
args.file = argv[i + 1];
i += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}\n${usage()}`);
}
return args;
}
function requireEnv(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function hasAnyAuthMode() {
const hasClientSecret = Boolean(process.env.AZURE_CLIENT_SECRET?.trim());
const hasClientCertificate = Boolean(process.env.AZURE_CLIENT_CERTIFICATE_PATH?.trim());
const hasUsernamePassword = Boolean(
process.env.AZURE_USERNAME?.trim() && process.env.AZURE_PASSWORD?.trim(),
);
return hasClientSecret || hasClientCertificate || hasUsernamePassword;
}
function psQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function runPowerShell(command) {
return new Promise((resolve, reject) => {
const candidates = ["pwsh.exe", "powershell.exe"];
const tryCandidate = (index, lastError) => {
if (index >= candidates.length) {
reject(lastError ?? new Error("Unable to find PowerShell"));
return;
}
const child = spawn(
candidates[index],
["-NoProfile", "-NonInteractive", "-Command", command],
{
stdio: "inherit",
windowsHide: true,
},
);
child.on("error", (error) => tryCandidate(index + 1, error));
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${candidates[index]} exited with code ${code}`));
});
};
tryCandidate(0);
});
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const defaultInstaller = path.join(
rootDir,
"release",
packageJson.version,
`Openscreen Setup ${packageJson.version}.exe`,
);
const fileToSign = path.resolve(rootDir, args.file ?? defaultInstaller);
if (!fs.existsSync(fileToSign)) {
throw new Error(`Installer not found: ${fileToSign}`);
}
requireEnv("AZURE_TENANT_ID");
requireEnv("AZURE_CLIENT_ID");
if (!hasAnyAuthMode()) {
throw new Error(
"Missing Azure auth mode. Set AZURE_CLIENT_SECRET, or AZURE_CLIENT_CERTIFICATE_PATH, or AZURE_USERNAME/AZURE_PASSWORD.",
);
}
const endpoint = requireEnv("AZURE_TRUSTED_SIGNING_ENDPOINT");
const accountName = requireEnv("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME");
const profileName = requireEnv("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME");
const timestampUrl =
process.env.AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161?.trim() ||
"http://timestamp.acs.microsoft.com";
const installCommand = [
"Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser",
"Install-Module -Name TrustedSigning -MinimumVersion 0.5.0 -Force -Repository PSGallery -Scope CurrentUser",
].join("; ");
const signCommand = [
"Invoke-TrustedSigning",
`-Endpoint ${psQuote(endpoint)}`,
`-CertificateProfileName ${psQuote(profileName)}`,
`-CodeSigningAccountName ${psQuote(accountName)}`,
`-TimestampRfc3161 ${psQuote(timestampUrl)}`,
"-TimestampDigest SHA256",
"-FileDigest SHA256",
`-Files ${psQuote(fileToSign)}`,
].join(" ");
const verifyCommand = [
"$signature = Get-AuthenticodeSignature -FilePath",
psQuote(fileToSign),
"; $signature | Format-List Status,StatusMessage,SignerCertificate,TimeStamperCertificate",
].join(" ");
console.log(`Signing ${fileToSign}`);
await runPowerShell(installCommand);
await runPowerShell(signCommand);
await runPowerShell(verifyCommand);
}
loadLocalSigningEnv();
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const HELPER_PATH =
process.env.OPENSCREEN_WGC_CAPTURE_EXE ??
path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe");
const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000);
const WITH_SYSTEM_AUDIO =
process.env.OPENSCREEN_WGC_TEST_SYSTEM_AUDIO === "true" ||
process.argv.includes("--system-audio");
const WITH_MICROPHONE =
process.env.OPENSCREEN_WGC_TEST_MICROPHONE === "true" ||
process.argv.includes("--microphone") ||
process.argv.includes("--mic");
const WITH_WINDOW =
process.env.OPENSCREEN_WGC_TEST_WINDOW === "true" || process.argv.includes("--window");
const WITH_WEBCAM =
process.env.OPENSCREEN_WGC_TEST_WEBCAM === "true" || process.argv.includes("--webcam");
const CAPTURE_CURSOR =
process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" ||
process.argv.includes("--capture-cursor");
function runHelper(config) {
return new Promise((resolve, reject) => {
const child = spawn(HELPER_PATH, [JSON.stringify(config)], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let stopTimer = null;
const scheduleStop = () => {
if (stopTimer) {
return;
}
stopTimer = setTimeout(() => {
child.stdin.write("stop\n");
}, DURATION_MS);
};
const fallbackTimer = setTimeout(scheduleStop, 15_000);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
if (stdout.includes('"recording-started"') || stdout.includes("Recording started")) {
scheduleStop();
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("exit", (code) => {
clearTimeout(fallbackTimer);
if (stopTimer) {
clearTimeout(stopTimer);
}
resolve({ code, stdout, stderr });
});
});
}
function startFixtureWindow() {
return new Promise((resolve, reject) => {
const child = spawn("mspaint.exe", [], {
stdio: ["ignore", "ignore", "ignore"],
windowsHide: false,
});
const poll = setInterval(() => {
const lookup = spawnSync(
"powershell",
[
"-NoProfile",
"-Command",
`(Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue).MainWindowHandle`,
],
{ encoding: "utf8", windowsHide: true },
);
const handle = lookup.stdout
.trim()
.split(/\r?\n/)
.find((line) => /^\d+$/.test(line.trim()));
if (handle && handle !== "0") {
clearInterval(poll);
clearTimeout(timer);
resolve({ child, sourceId: `window:${handle.trim()}:0` });
}
}, 250);
const timer = setTimeout(() => {
clearInterval(poll);
child.kill();
reject(new Error("Timed out waiting for fixture window handle"));
}, 10_000);
child.once("error", (error) => {
clearInterval(poll);
clearTimeout(timer);
reject(error);
});
});
}
function normalizeDeviceName(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function scoreDeviceName(candidateName, candidateId, requestedName) {
const candidate = normalizeDeviceName(candidateName ?? "");
const id = normalizeDeviceName(candidateId ?? "");
const requested = normalizeDeviceName(requestedName ?? "");
if (!requested) return 0;
if (candidate === requested) return 1000;
if (candidate.includes(requested) || requested.includes(candidate)) return 900;
if (id.includes(requested) || requested.includes(id)) return 800;
return requested
.split(/\s+/)
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
.reduce((score, word) => {
if (candidate.includes(word)) return score + 100;
if (id.includes(word)) return score + 50;
return score;
}, 0);
}
function resolveDirectShowWebcamClsid(requestedName) {
if (!requestedName) return "";
const query = spawnSync(
"reg.exe",
["query", "HKCR\\CLSID\\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\\Instance", "/s"],
{ encoding: "utf8", windowsHide: true },
);
if (query.status !== 0) return "";
const entries = [];
let current = {};
for (const rawLine of query.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (/^HKEY_/i.test(line)) {
if (current.friendlyName || current.clsid) entries.push(current);
current = {};
continue;
}
const match = line.match(/^(\S+)\s+REG_SZ\s+(.+)$/);
if (!match) continue;
if (match[1] === "FriendlyName") current.friendlyName = match[2].trim();
if (match[1] === "CLSID") current.clsid = match[2].trim();
}
if (current.friendlyName || current.clsid) entries.push(current);
let best = null;
for (const entry of entries) {
if (!entry.clsid) continue;
const score = scoreDeviceName(entry.friendlyName, entry.clsid, requestedName);
if (!best || score > best.score) {
best = { ...entry, score };
}
}
return best && best.score > 0 ? best.clsid : "";
}
function probeStreams(outputPath) {
const ffprobe = spawnSync(
"ffprobe",
["-v", "error", "-show_streams", "-of", "json", outputPath],
{ encoding: "utf8", windowsHide: true },
);
if (ffprobe.status !== 0) {
throw new Error(`ffprobe failed: ${ffprobe.stderr || ffprobe.stdout}`);
}
return JSON.parse(ffprobe.stdout).streams ?? [];
}
function measureFirstFrameLuma(outputPath) {
const ffmpeg = spawnSync(
"ffmpeg",
[
"-v",
"error",
"-i",
outputPath,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"gray",
"pipe:1",
],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
);
if (ffmpeg.status !== 0) {
throw new Error(`ffmpeg frame extraction failed: ${ffmpeg.stderr?.toString() ?? ""}`);
}
const data = ffmpeg.stdout;
if (!data || data.length === 0) {
throw new Error(`ffmpeg did not return frame data for ${outputPath}`);
}
let sum = 0;
let max = 0;
for (const value of data) {
sum += value;
if (value > max) {
max = value;
}
}
return { average: sum / data.length, max };
}
if (process.platform !== "win32") {
console.log("Skipping WGC helper smoke test: Windows-only.");
process.exit(0);
}
if (!fs.existsSync(HELPER_PATH)) {
throw new Error(`WGC helper not found at ${HELPER_PATH}. Run npm run build:native:win first.`);
}
const outputPath = path.join(
os.tmpdir(),
`openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`,
);
const webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null;
const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null;
const config = {
schemaVersion: 2,
recordingId: Date.now(),
outputPath,
sourceType: fixtureWindow ? "window" : "display",
sourceId: fixtureWindow ? fixtureWindow.sourceId : "screen:0:0",
displayId: 0,
fps: 30,
videoWidth: 1280,
videoHeight: 720,
displayX: 0,
displayY: 0,
displayW: 1920,
displayH: 1080,
hasDisplayBounds: true,
captureSystemAudio: WITH_SYSTEM_AUDIO,
captureMic: WITH_MICROPHONE,
captureCursor: CAPTURE_CURSOR,
microphoneDeviceId: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_ID ?? "default",
microphoneDeviceName: process.env.OPENSCREEN_WGC_TEST_MICROPHONE_DEVICE_NAME ?? "",
microphoneGain: 1.4,
webcamEnabled: WITH_WEBCAM,
webcamDeviceId: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_ID ?? "",
webcamDeviceName: process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
webcamDirectShowClsid: resolveDirectShowWebcamClsid(
process.env.OPENSCREEN_WGC_TEST_WEBCAM_DEVICE_NAME ?? "",
),
webcamWidth: 640,
webcamHeight: 360,
webcamFps: 30,
outputs: {
screenPath: outputPath,
...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}),
},
};
let result;
try {
result = await runHelper(config);
} finally {
if (fixtureWindow) {
fixtureWindow.child.kill();
}
}
if (result.code !== 0) {
if (
WITH_WEBCAM &&
/No native Windows webcam devices were found|Failed to initialize native webcam/.test(
result.stderr,
)
) {
console.log("Skipping WGC webcam smoke test: no native Windows webcam device is available.");
process.exit(0);
}
throw new Error(`WGC helper exited with ${result.code}\n${result.stdout}\n${result.stderr}`);
}
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
throw new Error(`WGC helper did not produce a video at ${outputPath}`);
}
if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) {
throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`);
}
const streams = probeStreams(outputPath);
const webcamStreams =
webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : [];
const hasVideo = streams.some((stream) => stream.codec_type === "video");
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
const webcamFormatLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes('"event":"webcam-format"'));
const webcamFormat = webcamFormatLine ? JSON.parse(webcamFormatLine) : null;
const audioFormatLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes('"event":"audio-format"'));
const audioFormat = audioFormatLine ? JSON.parse(audioFormatLine) : null;
const cursorCaptureLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes('"event":"cursor-capture"'));
const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : null;
const nativeWebcamDiagnostics = result.stderr
.split(/\r?\n/)
.filter((line) => line.includes("Native webcam candidate"));
const nativeMicrophoneDiagnostics = result.stderr
.split(/\r?\n/)
.filter(
(line) =>
line.includes("Native microphone candidate") ||
line.includes("Selected native microphone endpoint"),
);
if (!hasVideo) {
throw new Error(`WGC helper output has no video stream: ${outputPath}`);
}
if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) {
throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`);
}
if (
(CAPTURE_CURSOR && !cursorCapture) ||
(cursorCapture &&
(cursorCapture.requested !== CAPTURE_CURSOR || cursorCapture.applied !== CAPTURE_CURSOR))
) {
throw new Error(
`WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`,
);
}
if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) {
throw new Error(`WGC helper output has no audio stream: ${outputPath}`);
}
const frameLuma = measureFirstFrameLuma(outputPath);
if (frameLuma.average < 1 && frameLuma.max < 5) {
throw new Error(
`WGC helper output first frame is black: ${outputPath}\n${result.stdout}\n${result.stderr}`,
);
}
console.log(
JSON.stringify(
{
success: true,
outputPath,
webcamOutputPath,
bytes: fs.statSync(outputPath).size,
webcamBytes:
webcamOutputPath && fs.existsSync(webcamOutputPath)
? fs.statSync(webcamOutputPath).size
: undefined,
streams: streams.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
duration: stream.duration,
})),
webcamStreams: webcamStreams.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
width: stream.width,
height: stream.height,
duration: stream.duration,
})),
cursorCapture,
selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName,
selectedWebcamDeviceName: webcamFormat?.deviceName,
nativeMicrophoneDiagnostics,
nativeWebcamDiagnostics,
firstFrameLuma: frameLuma,
},
null,
2,
),
);