Release OpenScreen 1.4.1
This commit is contained in:
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
Vendored
+5
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}, [getRecordingDurationMs, recording]);
|
||||
|
||||
const selectMimeType = () => {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user