Publish OpenScreen 1.4.11 update assets
This commit is contained in:
@@ -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}`);
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
Executable
+216
@@ -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 ""
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user