1 Commits

Author SHA1 Message Date
huanld 69804c41c7 Release OpenScreen 1.4.1 2026-05-28 08:52:11 +07:00
20 changed files with 705 additions and 22 deletions
+8
View File
@@ -0,0 +1,8 @@
# Copy to .env.signing.local for a local signing machine. Do not commit real values.
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_TRUSTED_SIGNING_ENDPOINT=https://<region>.codesigning.azure.net/
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME=
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME=
AZURE_TRUSTED_SIGNING_PUBLISHER_NAME=
+1
View File
@@ -13,6 +13,7 @@ dist-electron
dist-ssr
*.local
.env
.env.signing.local
# Native helper build outputs
/electron/native/wgc-capture/build/
@@ -0,0 +1,84 @@
# Windows Private Trust Signing
OpenScreen supports Microsoft Trusted Signing private trust profiles for Windows
builds. Secrets and signing resource names are read from environment variables;
no certificate, client secret, or API key should be committed.
For a local signing machine, copy `.env.signing.example` to
`.env.signing.local` and fill in values there. `.env.signing.local` is ignored
by Git. Explicit shell environment variables override values in that local file.
## Required Azure Resource Variables
Set these values for the Trusted Signing account and certificate profile:
```powershell
$env:AZURE_TRUSTED_SIGNING_ENDPOINT = "https://<region>.codesigning.azure.net/"
$env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME = "<trusted-signing-account-name>"
$env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME = "<private-trust-profile-name>"
$env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME = "<certificate-common-name>"
```
`AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME` must point to a certificate
profile created with the `PrivateTrust` profile type.
## Required Azure Auth Variables
Electron Builder uses Azure environment credentials. Set the tenant and client:
```powershell
$env:AZURE_TENANT_ID = "<tenant-id>"
$env:AZURE_CLIENT_ID = "<app-registration-client-id>"
```
Then set one authentication mode. Service principal secret is the simplest for
local signing:
```powershell
$env:AZURE_CLIENT_SECRET = "<client-secret>"
```
Certificate auth is also supported:
```powershell
$env:AZURE_CLIENT_CERTIFICATE_PATH = "C:\secure\signing-auth.pfx"
$env:AZURE_CLIENT_CERTIFICATE_PASSWORD = "<pfx-password>"
```
## Sign Existing Installer
This signs the installer already built at
`release/<version>/Openscreen Setup <version>.exe`:
```powershell
npm run sign:win:private-trust
```
To sign a specific file:
```powershell
npm run sign:win:private-trust -- --file "D:\Code\OpenScreen\release\1.4.0\Openscreen Setup 1.4.0.exe"
```
## Build And Sign
This signs the packaged app executable, bundled OCR service executable, and NSIS
installer during the Windows build:
```powershell
npm run build:win:private-trust
```
The regular `npm run build:win` remains unsigned for local development builds.
## Verification
After signing:
```powershell
Get-AuthenticodeSignature "release\1.4.0\Openscreen Setup 1.4.0.exe" | Format-List
```
Private trust signatures are valid only on machines that trust the private trust
certificate chain/publisher. For public downloads that must be trusted on any
Windows machine, use a public trust certificate profile instead.
+73
View File
@@ -0,0 +1,73 @@
const fs = require("node:fs");
const path = require("node:path");
const JSON5 = require("json5");
function loadLocalSigningEnv() {
const envPath = path.join(__dirname, ".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 readBaseConfig() {
const configPath = path.join(__dirname, "electron-builder.json5");
return JSON5.parse(fs.readFileSync(configPath, "utf8"));
}
function requireEnv(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function requireAnyEnv(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) {
return value;
}
}
throw new Error(`Missing required environment variable: ${names.join(" or ")}`);
}
loadLocalSigningEnv();
const config = readBaseConfig();
config.win = {
...config.win,
signAndEditExecutable: true,
azureSignOptions: {
publisherName: requireAnyEnv([
"AZURE_TRUSTED_SIGNING_PUBLISHER_NAME",
"OPENSCREEN_SIGNING_PUBLISHER_NAME",
]),
endpoint: requireEnv("AZURE_TRUSTED_SIGNING_ENDPOINT"),
certificateProfileName: requireEnv("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"),
codeSigningAccountName: requireEnv("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"),
fileDigest: process.env.AZURE_TRUSTED_SIGNING_FILE_DIGEST?.trim() || "SHA256",
timestampRfc3161:
process.env.AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161?.trim() ||
"http://timestamp.acs.microsoft.com",
timestampDigest: process.env.AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST?.trim() || "SHA256",
},
};
delete config.win.signExts;
module.exports = config;
+5
View File
@@ -48,6 +48,11 @@ interface Window {
event: import("../src/guide/contracts").GuideEvent;
}>
>;
capturePointerMarker: () => Promise<
import("../src/guide/contracts").GuideIpcResult<
import("../src/guide/contracts").CaptureGuidePointerMarkerResult
>
>;
finalizeEvents: (
input: import("../src/guide/contracts").FinalizeGuideEventsInput,
) => Promise<
+22 -4
View File
@@ -18,15 +18,25 @@ import type {
import type { DeepSeekSettingsStore } from "./ai/deepseekSettingsStore";
import { GuideStore, GuideStoreError } from "./guideStore";
export interface GuideIpcLifecycle {
onSessionStarted?: (session: GuideSession) => void;
onSessionEnded?: (recordingId: unknown) => void;
}
export function registerGuideIpcHandlers(
ipcMain: IpcMain,
store: GuideStore,
aiSettingsStore?: DeepSeekSettingsStore,
lifecycle: GuideIpcLifecycle = {},
): void {
ipcMain.handle(
"guide:start-session",
async (_, recordingId): Promise<GuideIpcResult<GuideSession>> => {
return await toGuideResult(() => store.startSession(recordingId));
const result = await toGuideResult(() => store.startSession(recordingId));
if (result.success) {
lifecycle.onSessionStarted?.(result.data);
}
return result;
},
);
@@ -50,7 +60,11 @@ export function registerGuideIpcHandlers(
ipcMain.handle(
"guide:finalize-events",
async (_, input: FinalizeGuideEventsInput): Promise<GuideIpcResult<GuideSession>> => {
return await toGuideResult(() => store.finalizeEvents(input));
const result = await toGuideResult(() => store.finalizeEvents(input));
if (result.success) {
lifecycle.onSessionEnded?.(input.recordingId);
}
return result;
},
);
@@ -110,10 +124,14 @@ export function registerGuideIpcHandlers(
ipcMain.handle(
"guide:discard-session",
async (_, input: DiscardGuideSessionInput): Promise<GuideIpcResult<{ discarded: true }>> => {
return await toGuideResult(async () => {
const result = await toGuideResult(async () => {
await store.discardSession(input);
return { discarded: true };
return { discarded: true as const };
});
if (result.success) {
lifecycle.onSessionEnded?.(input.recordingId);
}
return result;
},
);
}
+8
View File
@@ -42,9 +42,17 @@ describe("GuideStore", () => {
kind: "hotkey",
timeMs: 500,
label: "First",
normalizedX: 0.25,
normalizedY: 0.75,
});
expect(result.event.kind).toBe("hotkey");
expect(result.event).toMatchObject({
x: 0.25,
y: 0.75,
normalizedX: 0.25,
normalizedY: 0.75,
});
expect(result.session.events.map((event) => event.timeMs)).toEqual([500, 2000]);
expect(result.session.events[0]?.source).toBe("guide-hotkey");
expect(result.session.events[1]?.source).toBe("review-ui");
+26
View File
@@ -127,6 +127,7 @@ export class GuideStore {
kind: input.kind,
source: input.kind === "hotkey" ? "guide-hotkey" : "review-ui",
timeMs: Math.max(0, input.timeMs),
...normalizeMarkerPoint(input),
label: normalizeOptionalString(input.label),
screenshotOffsetMs: 500,
createdAt: new Date().toISOString(),
@@ -813,6 +814,31 @@ function normalizeOptionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeMarkerPoint(
input: AddGuideMarkerInput,
): Pick<GuideEvent, "x" | "y" | "normalizedX" | "normalizedY"> {
const normalizedX = normalizeOptionalNormalizedNumber(input.normalizedX ?? input.x);
const normalizedY = normalizeOptionalNormalizedNumber(input.normalizedY ?? input.y);
if (normalizedX === undefined || normalizedY === undefined) {
return {};
}
return {
x: normalizedX,
y: normalizedY,
normalizedX,
normalizedY,
};
}
function normalizeOptionalNormalizedNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.min(1, Math.max(0, value));
}
function normalizePositiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.round(value)
+204 -5
View File
@@ -11,6 +11,7 @@ import {
BrowserWindow,
desktopCapturer,
dialog,
globalShortcut,
ipcMain,
screen,
shell,
@@ -428,6 +429,19 @@ let nativeMacCursorRecordingStartMs = 0;
let nativeMacPauseStartedAtMs: number | null = null;
let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = [];
let nativeMacIsPaused = false;
const GUIDE_MARKER_HOTKEY = "Control+F12";
const GUIDE_MARKER_HOTKEY_LABEL = "Ctrl+F12";
type GuideHotkeyBounds = { x: number; y: number; width: number; height: number };
type GuideHotkeyRecordingState = {
recordingId: number;
startedAtMs: number;
accumulatedPausedMs: number;
pausedAtMs: number | null;
bounds: GuideHotkeyBounds;
};
let activeGuideHotkeyRecording: GuideHotkeyRecordingState | null = null;
let activeGuideHotkeySessionId: number | null = null;
let guideMarkerHotkeyRegistered = false;
function normalizeCursorSample(sample: unknown): CursorRecordingSample | null {
if (!sample || typeof sample !== "object") {
@@ -585,6 +599,160 @@ function getSelectedSourceBounds() {
return (sourceDisplay ?? screen.getDisplayNearestPoint(cursor)).bounds;
}
function normalizeGuideHotkeyRecordingId(recordingId: unknown): number | null {
if (typeof recordingId === "number" && Number.isFinite(recordingId)) {
return Math.trunc(recordingId);
}
if (typeof recordingId === "string" && recordingId.trim()) {
const numeric = Number(recordingId);
return Number.isFinite(numeric) ? Math.trunc(numeric) : null;
}
return null;
}
function sanitizeGuideHotkeyBounds(bounds: GuideHotkeyBounds): GuideHotkeyBounds {
return {
x: Number.isFinite(bounds.x) ? bounds.x : 0,
y: Number.isFinite(bounds.y) ? bounds.y : 0,
width: Number.isFinite(bounds.width) && bounds.width > 0 ? bounds.width : 1,
height: Number.isFinite(bounds.height) && bounds.height > 0 ? bounds.height : 1,
};
}
function startGuideHotkeyRecording(
recordingIdInput: unknown,
bounds: GuideHotkeyBounds = getSelectedSourceBounds(),
) {
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
if (recordingId === null) {
return;
}
activeGuideHotkeyRecording = {
recordingId,
startedAtMs: Date.now(),
accumulatedPausedMs: 0,
pausedAtMs: null,
bounds: sanitizeGuideHotkeyBounds(bounds),
};
}
function clearGuideHotkeyRecording() {
activeGuideHotkeyRecording = null;
activeGuideHotkeySessionId = null;
}
function activateGuideHotkeySession(recordingIdInput: unknown) {
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
if (recordingId !== null) {
activeGuideHotkeySessionId = recordingId;
}
}
function deactivateGuideHotkeySession(recordingIdInput: unknown) {
const recordingId = normalizeGuideHotkeyRecordingId(recordingIdInput);
if (recordingId === null || activeGuideHotkeySessionId === recordingId) {
activeGuideHotkeySessionId = null;
}
}
function pauseGuideHotkeyRecording() {
if (activeGuideHotkeyRecording && activeGuideHotkeyRecording.pausedAtMs === null) {
activeGuideHotkeyRecording.pausedAtMs = Date.now();
}
}
function resumeGuideHotkeyRecording() {
if (!activeGuideHotkeyRecording || activeGuideHotkeyRecording.pausedAtMs === null) {
return;
}
activeGuideHotkeyRecording.accumulatedPausedMs += Math.max(
0,
Date.now() - activeGuideHotkeyRecording.pausedAtMs,
);
activeGuideHotkeyRecording.pausedAtMs = null;
}
function getGuideHotkeyRecordingTimeMs(recording: GuideHotkeyRecordingState): number {
const now = recording.pausedAtMs ?? Date.now();
return Math.max(0, now - recording.startedAtMs - recording.accumulatedPausedMs);
}
function getGuideHotkeyPoint(boundsInput: GuideHotkeyBounds) {
const bounds = sanitizeGuideHotkeyBounds(boundsInput);
const cursor = screen.getCursorScreenPoint();
return {
normalizedX: clampGuideHotkey01((cursor.x - bounds.x) / bounds.width),
normalizedY: clampGuideHotkey01((cursor.y - bounds.y) / bounds.height),
rawX: cursor.x,
rawY: cursor.y,
bounds,
};
}
function clampGuideHotkey01(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(1, Math.max(0, value));
}
async function captureGuideHotkeyMarker(guideStore: GuideStore) {
const recording = activeGuideHotkeyRecording;
if (!recording || activeGuideHotkeySessionId !== recording.recordingId) {
return { captured: false };
}
const point = getGuideHotkeyPoint(recording.bounds);
try {
const result = await guideStore.addMarker({
recordingId: recording.recordingId,
kind: "hotkey",
timeMs: getGuideHotkeyRecordingTimeMs(recording),
x: point.normalizedX,
y: point.normalizedY,
normalizedX: point.normalizedX,
normalizedY: point.normalizedY,
label: `${GUIDE_MARKER_HOTKEY_LABEL} marker`,
});
console.info("[guide-hotkey] marker captured", {
recordingId: recording.recordingId,
timeMs: result.event.timeMs,
normalizedX: result.event.normalizedX,
normalizedY: result.event.normalizedY,
rawX: point.rawX,
rawY: point.rawY,
bounds: point.bounds,
});
return { captured: true, ...result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[guide-hotkey] failed to capture marker:", message);
return { captured: false, error: message };
}
}
function registerGuideMarkerHotkey(guideStore: GuideStore) {
if (guideMarkerHotkeyRegistered) {
return;
}
guideMarkerHotkeyRegistered = globalShortcut.register(GUIDE_MARKER_HOTKEY, () => {
void captureGuideHotkeyMarker(guideStore);
});
if (!guideMarkerHotkeyRegistered) {
console.warn(`[guide-hotkey] failed to register ${GUIDE_MARKER_HOTKEY_LABEL}`);
return;
}
app.once("will-quit", () => {
globalShortcut.unregister(GUIDE_MARKER_HOTKEY);
guideMarkerHotkeyRegistered = false;
});
}
function getSelectedSourceId() {
return typeof selectedSource?.id === "string" ? selectedSource.id : null;
}
@@ -1666,6 +1834,7 @@ export function registerIpcHandlers(
});
const source = selectedSource || { name: "Screen" };
startGuideHotkeyRecording(recordingId, bounds);
if (onRecordingStateChange) {
onRecordingStateChange(true, source.name);
}
@@ -1689,6 +1858,7 @@ export function registerIpcHandlers(
nativeWindowsPauseStartedAtMs = null;
nativeWindowsPauseRanges = [];
nativeWindowsIsPaused = false;
clearGuideHotkeyRecording();
await stopCursorRecording();
return { success: false, error: String(error) };
}
@@ -1811,6 +1981,7 @@ export function registerIpcHandlers(
: 0;
const source = selectedSource || { name: "Screen" };
startGuideHotkeyRecording(recordingId, bounds);
if (onRecordingStateChange) {
onRecordingStateChange(true, source.name);
}
@@ -1833,6 +2004,7 @@ export function registerIpcHandlers(
nativeMacPauseStartedAtMs = null;
nativeMacPauseRanges = [];
nativeMacIsPaused = false;
clearGuideHotkeyRecording();
await stopCursorRecording();
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
@@ -1858,6 +2030,7 @@ export function registerIpcHandlers(
proc.stdin.write("pause\n");
nativeMacIsPaused = true;
nativeMacPauseStartedAtMs = Date.now();
pauseGuideHotkeyRecording();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1884,6 +2057,7 @@ export function registerIpcHandlers(
proc.stdin.write("resume\n");
completeNativeMacCursorPauseRange();
nativeMacIsPaused = false;
resumeGuideHotkeyRecording();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1906,6 +2080,7 @@ export function registerIpcHandlers(
proc.stdin.write("pause\n");
nativeWindowsIsPaused = true;
nativeWindowsPauseStartedAtMs = Date.now();
pauseGuideHotkeyRecording();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1928,6 +2103,7 @@ export function registerIpcHandlers(
proc.stdin.write("resume\n");
completeNativeWindowsCursorPauseRange();
nativeWindowsIsPaused = false;
resumeGuideHotkeyRecording();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -2017,6 +2193,7 @@ export function registerIpcHandlers(
nativeWindowsPauseStartedAtMs = null;
nativeWindowsPauseRanges = [];
nativeWindowsIsPaused = false;
clearGuideHotkeyRecording();
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(false, source.name);
@@ -2102,6 +2279,7 @@ export function registerIpcHandlers(
nativeMacPauseStartedAtMs = null;
nativeMacPauseRanges = [];
nativeMacIsPaused = false;
clearGuideHotkeyRecording();
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(false, source.name);
@@ -2178,11 +2356,27 @@ export function registerIpcHandlers(
const guideAiSettingsStore = new DeepSeekSettingsStore(
path.join(app.getPath("userData"), "guide-ai-settings.json"),
);
registerGuideIpcHandlers(
ipcMain,
new GuideStore(RECORDINGS_DIR, { deepSeekConfigProvider: guideAiSettingsStore }),
guideAiSettingsStore,
);
const guideStore = new GuideStore(RECORDINGS_DIR, {
deepSeekConfigProvider: guideAiSettingsStore,
});
registerGuideMarkerHotkey(guideStore);
registerGuideIpcHandlers(ipcMain, guideStore, guideAiSettingsStore, {
onSessionStarted: (session) => activateGuideHotkeySession(session.recordingId),
onSessionEnded: (recordingId) => deactivateGuideHotkeySession(recordingId),
});
ipcMain.handle("guide:capture-pointer-marker", async () => {
const result = await captureGuideHotkeyMarker(guideStore);
if (result.error) {
return {
success: false,
code: "guide-internal-error",
error: result.error,
retryable: true,
};
}
return { success: true, data: result };
});
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
@@ -2315,6 +2509,11 @@ export function registerIpcHandlers(
} else {
await stopCursorRecording();
}
if (recording) {
startGuideHotkeyRecording(recordingId, getSelectedSourceBounds());
} else {
clearGuideHotkeyRecording();
}
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
+6
View File
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
AddGuideMarkerInput,
CaptureGuidePointerMarkerResult,
DiscardGuideSessionInput,
ExportGuideInput,
FinalizeGuideEventsInput,
@@ -37,6 +38,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
addMarker: (input: AddGuideMarkerInput) => {
return ipcRenderer.invoke("guide:add-marker", input);
},
capturePointerMarker: () => {
return ipcRenderer.invoke("guide:capture-pointer-marker") as Promise<
import("../src/guide/contracts").GuideIpcResult<CaptureGuidePointerMarkerResult>
>;
},
finalizeEvents: (input: FinalizeGuideEventsInput) => {
return ipcRenderer.invoke("guide:finalize-events", input);
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openscreen",
"version": "1.4.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.4.0",
"version": "1.4.1",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
@@ -25,6 +25,8 @@
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"build:ocr:win": "node scripts/build-windows-ocr-service.mjs",
"build:win": "npm run build:native:win && npm run build:ocr:win && tsc && vite build && electron-builder --win --config electron-builder.json5 --config.npmRebuild=false",
"build:win:private-trust": "npm run build:native:win && npm run build:ocr:win && tsc && vite build && electron-builder --win --config electron-builder.private-trust.cjs --config.npmRebuild=false",
"sign:win:private-trust": "node scripts/sign-windows-private-trust.mjs",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config electron-builder.json5 --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
+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);
}
+42
View File
@@ -143,6 +143,7 @@ export function LaunchWindow() {
top: 12,
maxHeight: 240,
});
const guideCtrlMarkerArmedRef = useRef(false);
const {
devices: micDevices,
@@ -247,6 +248,47 @@ export function LaunchWindow() {
};
}, [isLanguageMenuOpen]);
useEffect(() => {
if (!recording || !guideModeEnabled) {
guideCtrlMarkerArmedRef.current = false;
return;
}
const isCtrlKey = (event: KeyboardEvent) =>
event.key === "Control" || event.code === "ControlLeft" || event.code === "ControlRight";
const handleKeyDown = (event: KeyboardEvent) => {
if (!isCtrlKey(event) || event.repeat || guideCtrlMarkerArmedRef.current) {
return;
}
guideCtrlMarkerArmedRef.current = true;
event.preventDefault();
event.stopPropagation();
addGuideMarker();
};
const releaseCtrlMarker = (event?: KeyboardEvent) => {
if (event && !isCtrlKey(event)) {
return;
}
guideCtrlMarkerArmedRef.current = false;
};
const handleWindowBlur = () => {
guideCtrlMarkerArmedRef.current = false;
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", releaseCtrlMarker, { capture: true });
window.addEventListener("blur", handleWindowBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", releaseCtrlMarker, { capture: true });
window.removeEventListener("blur", handleWindowBlur);
};
}, [addGuideMarker, guideModeEnabled, recording]);
useEffect(() => {
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
+10
View File
@@ -115,11 +115,21 @@ export interface GuideSession {
updatedAt: string;
}
export interface CaptureGuidePointerMarkerResult {
captured: boolean;
session?: GuideSession;
event?: GuideEvent;
}
export interface AddGuideMarkerInput {
recordingId: GuideRecordingIdInput;
timeMs: number;
kind: "hotkey" | "manual";
label?: string;
x?: number;
y?: number;
normalizedX?: number;
normalizedY?: number;
}
export interface FinalizeGuideEventsInput {
+19
View File
@@ -90,6 +90,25 @@ describe("buildGuideStepCandidates", () => {
});
});
it("treats hotkey markers with coordinates like clicks", () => {
const session = createSession();
session.events[0] = {
...session.events[0],
kind: "hotkey",
source: "guide-hotkey",
normalizedX: 0.5,
normalizedY: 0.5,
};
const candidates = buildGuideStepCandidates(session);
expect(candidates[0]).toMatchObject({
action: "click",
targetText: "Save",
targetRole: "button",
});
});
it("prefers a nearby line phrase over a single OCR word", () => {
const session = createSession();
session.events[0] = {
+1 -1
View File
@@ -233,7 +233,7 @@ function pointInsideExpandedBox(
}
function inferAction(event: GuideEvent): GuideAction {
if (event.kind === "click") {
if (event.kind === "click" || (event.kind === "hotkey" && getEventPoint(event))) {
return "click";
}
return "manual";
+16 -7
View File
@@ -209,18 +209,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
void window.electronAPI.guide
.addMarker({
void (async () => {
if (window.electronAPI?.guide.capturePointerMarker) {
const captureResult = await window.electronAPI.guide.capturePointerMarker();
if (captureResult.success && captureResult.data.captured) {
return;
}
if (!captureResult.success) {
console.warn("Failed to capture guide pointer marker:", captureResult.error);
}
}
const result = await window.electronAPI.guide.addMarker({
recordingId: activeRecordingId,
kind: "manual",
timeMs: getRecordingDurationMs(),
label: "Manual marker",
})
.then((result) => {
if (!result.success) {
console.warn("Failed to add guide marker:", result.error);
}
});
if (!result.success) {
console.warn("Failed to add guide marker:", result.error);
}
})();
}, [getRecordingDurationMs, recording]);
const selectMimeType = () => {
+1 -1
View File
@@ -31,7 +31,7 @@
"guide": {
"enableGuideMode": "Enable guide mode",
"disableGuideMode": "Disable guide mode",
"addMarker": "Add guide marker"
"addMarker": "Capture guide marker (Ctrl or Ctrl+F12)"
},
"sourceSelector": {
"loading": "Loading sources...",
+1 -1
View File
@@ -47,6 +47,6 @@
"guide": {
"enableGuideMode": "Bật chế độ tạo hướng dẫn",
"disableGuideMode": "Tắt chế độ tạo hướng dẫn",
"addMarker": "Thêm mốc hướng dẫn"
"addMarker": "Chụp mốc hướng dẫn (Ctrl hoặc Ctrl+F12)"
}
}