From 69804c41c7d279136a4c78d05d80ce5c2cb758a4 Mon Sep 17 00:00:00 2001 From: huanld Date: Thu, 28 May 2026 08:52:11 +0700 Subject: [PATCH] Release OpenScreen 1.4.1 --- .env.signing.example | 8 + .gitignore | 1 + .../windows-private-trust-signing.md | 84 +++++++ electron-builder.private-trust.cjs | 73 ++++++ electron/electron-env.d.ts | 5 + electron/guide/guideIpc.ts | 26 ++- electron/guide/guideStore.test.ts | 8 + electron/guide/guideStore.ts | 26 +++ electron/ipc/handlers.ts | 209 +++++++++++++++++- electron/preload.ts | 6 + package-lock.json | 4 +- package.json | 4 +- scripts/sign-windows-private-trust.mjs | 173 +++++++++++++++ src/components/launch/LaunchWindow.tsx | 42 ++++ src/guide/contracts.ts | 10 + src/guide/targetMapper.test.ts | 19 ++ src/guide/targetMapper.ts | 2 +- src/hooks/useScreenRecorder.ts | 23 +- src/i18n/locales/en/launch.json | 2 +- src/i18n/locales/vi/launch.json | 2 +- 20 files changed, 705 insertions(+), 22 deletions(-) create mode 100644 .env.signing.example create mode 100644 docs/engineering/windows-private-trust-signing.md create mode 100644 electron-builder.private-trust.cjs create mode 100644 scripts/sign-windows-private-trust.mjs diff --git a/.env.signing.example b/.env.signing.example new file mode 100644 index 0000000..321fcca --- /dev/null +++ b/.env.signing.example @@ -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://.codesigning.azure.net/ +AZURE_TRUSTED_SIGNING_ACCOUNT_NAME= +AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME= +AZURE_TRUSTED_SIGNING_PUBLISHER_NAME= diff --git a/.gitignore b/.gitignore index c2237ed..8827b47 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-electron dist-ssr *.local .env +.env.signing.local # Native helper build outputs /electron/native/wgc-capture/build/ diff --git a/docs/engineering/windows-private-trust-signing.md b/docs/engineering/windows-private-trust-signing.md new file mode 100644 index 0000000..01d5c24 --- /dev/null +++ b/docs/engineering/windows-private-trust-signing.md @@ -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://.codesigning.azure.net/" +$env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME = "" +$env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME = "" +$env:AZURE_TRUSTED_SIGNING_PUBLISHER_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 = "" +$env:AZURE_CLIENT_ID = "" +``` + +Then set one authentication mode. Service principal secret is the simplest for +local signing: + +```powershell +$env:AZURE_CLIENT_SECRET = "" +``` + +Certificate auth is also supported: + +```powershell +$env:AZURE_CLIENT_CERTIFICATE_PATH = "C:\secure\signing-auth.pfx" +$env:AZURE_CLIENT_CERTIFICATE_PASSWORD = "" +``` + +## Sign Existing Installer + +This signs the installer already built at +`release//Openscreen Setup .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. diff --git a/electron-builder.private-trust.cjs b/electron-builder.private-trust.cjs new file mode 100644 index 0000000..defce17 --- /dev/null +++ b/electron-builder.private-trust.cjs @@ -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; diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a75be6b..19e878c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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< diff --git a/electron/guide/guideIpc.ts b/electron/guide/guideIpc.ts index 9367277..d49a4e3 100644 --- a/electron/guide/guideIpc.ts +++ b/electron/guide/guideIpc.ts @@ -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> => { - 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> => { - 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> => { - 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; }, ); } diff --git a/electron/guide/guideStore.test.ts b/electron/guide/guideStore.test.ts index 4909573..27272f9 100644 --- a/electron/guide/guideStore.test.ts +++ b/electron/guide/guideStore.test.ts @@ -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"); diff --git a/electron/guide/guideStore.ts b/electron/guide/guideStore.ts index c361a1b..39946c7 100644 --- a/electron/guide/guideStore.ts +++ b/electron/guide/guideStore.ts @@ -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 { + 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) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d559b7a..8643b6e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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) { diff --git a/electron/preload.ts b/electron/preload.ts index f7d675c..cd72611 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 + >; + }, finalizeEvents: (input: FinalizeGuideEventsInput) => { return ipcRenderer.invoke("guide:finalize-events", input); }, diff --git a/package-lock.json b/package-lock.json index 50ecc9d..5f0a3d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a1c07e7..801bd53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/sign-windows-private-trust.mjs b/scripts/sign-windows-private-trust.mjs new file mode 100644 index 0000000..21b22ae --- /dev/null +++ b/scripts/sign-windows-private-trust.mjs @@ -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 ]", + "", + "Defaults to release//Openscreen Setup .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); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index caf133d..9d8b8c3 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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; diff --git a/src/guide/contracts.ts b/src/guide/contracts.ts index ade7b1a..6f162cb 100644 --- a/src/guide/contracts.ts +++ b/src/guide/contracts.ts @@ -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 { diff --git a/src/guide/targetMapper.test.ts b/src/guide/targetMapper.test.ts index ce7b3d3..f89ad9d 100644 --- a/src/guide/targetMapper.test.ts +++ b/src/guide/targetMapper.test.ts @@ -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] = { diff --git a/src/guide/targetMapper.ts b/src/guide/targetMapper.ts index 0d7e3dc..837b283 100644 --- a/src/guide/targetMapper.ts +++ b/src/guide/targetMapper.ts @@ -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"; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8948a0c..b45566a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -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 = () => { diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index dc97d63..70f574e 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -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...", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 7daa56c..0972340 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -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)" } }