diff --git a/docs/configuration.md b/docs/configuration.md index 922f39e..2c137bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,6 +69,8 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +Windows 未配置 `notify` 时会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。 + 通知脚本执行时,会通过环境变量注入以下上下文信息: | 环境变量 | 说明 | @@ -76,6 +78,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `DURATION` | 会话耗时,单位秒(整数) | | `STATUS` | 会话状态:`"completed"` 或 `"failed"` | | `FAIL_REASON` | 失败原因(仅失败时设置) | +| `QUESTION` | 最后一条用户问题的文本内容 | | `BODY` | 最后一条 AI 助手回复的文本内容 | | `TITLE` | 会话标题(对应 resume 列表中的标题) | diff --git a/docs/configuration_en.md b/docs/configuration_en.md index f53fb11..16451f4 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -69,6 +69,8 @@ When thinking mode is enabled, controls the depth of the model’s reasoning: Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). +On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip. + The following context is injected as environment variables when the notify script runs: | Variable | Description | @@ -76,6 +78,7 @@ The following context is injected as environment variables when the notify scrip | `DURATION` | Session duration in seconds (integer) | | `STATUS` | Session status: `"completed"` or `"failed"` | | `FAIL_REASON` | Failure reason (only set on failure) | +| `QUESTION` | The text content of the last user question | | `BODY` | The text content of the last AI assistant reply | | `TITLE` | Session title (matches the resume list title) | @@ -193,4 +196,4 @@ Applied in the following priority order (lower-numbered overridden by higher-num 2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` 3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` 4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/docs/notify.md b/docs/notify.md index d73eef4..7a58823 100644 --- a/docs/notify.md +++ b/docs/notify.md @@ -6,6 +6,8 @@ 在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 +在 Windows 上,如果未配置 `notify`,Deep Code 会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。 + ## 注入的环境变量 | 环境变量 | 说明 | @@ -13,6 +15,7 @@ | `DURATION` | 会话耗时,单位秒(整数) | | `STATUS` | 会话状态:`"completed"` 或 `"failed"` | | `FAIL_REASON` | 失败原因(仅失败时设置) | +| `QUESTION` | 最后一条用户问题的文本内容 | | `BODY` | 最后一条 AI 助手回复的文本内容 | | `TITLE` | 会话标题(对应 resume 列表中的标题) | diff --git a/docs/notify_en.md b/docs/notify_en.md index b949161..9ba7e3d 100644 --- a/docs/notify_en.md +++ b/docs/notify_en.md @@ -6,6 +6,8 @@ When the AI assistant finishes a round of tasks, Deep Code can automatically exe Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. +On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip. + ## Injected Environment Variables | Variable | Description | @@ -13,6 +15,7 @@ Configure the `notify` field in `settings.json` with the full path to an executa | `DURATION` | Session duration in seconds (integer) | | `STATUS` | Session status: `"completed"` or `"failed"` | | `FAIL_REASON` | Failure reason (only set on failure) | +| `QUESTION` | The text content of the last user question | | `BODY` | The text content of the last AI assistant reply | | `TITLE` | Session title (matches the resume list title) | diff --git a/src/common/notify.ts b/src/common/notify.ts index d1b541b..edca600 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -1,4 +1,7 @@ -import { spawn, type SpawnOptions } from "child_process"; +import { spawn, spawnSync, type SpawnOptions } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; type NotifyChildProcess = { once(event: "error", listener: (error: NodeJS.ErrnoException) => void): NotifyChildProcess; @@ -19,6 +22,7 @@ export function formatDurationSeconds(durationMs: number): string { export type NotifyContext = { status?: string; failReason?: string; + question?: string; body?: string; title?: string; }; @@ -34,6 +38,7 @@ export function buildNotifyEnv( }; delete env.STATUS; delete env.FAIL_REASON; + delete env.QUESTION; delete env.BODY; delete env.TITLE; @@ -43,6 +48,9 @@ export function buildNotifyEnv( if (context.failReason) { env.FAIL_REASON = context.failReason; } + if (context.question) { + env.QUESTION = context.question; + } if (context.body) { env.BODY = context.body; } @@ -96,3 +104,120 @@ export function launchNotifyScript( // Ignore notification failures. } } + +/** + * Resolve the bundled built-in notification script shipped with the CLI. + * The esbuild ESM bundle does not inject `__dirname`, so we replicate the + * same fallback pattern used by `getExtensionRoot`. + */ +export function resolveBuiltinNotifyPath(): string | null { + if (process.platform !== "win32") { + return null; + } + try { + const moduleDir = + typeof __dirname !== "undefined" + ? path.resolve(__dirname) + : path.resolve(path.dirname(fileURLToPath(import.meta.url))); + const candidates = [ + path.resolve(moduleDir, "..", "templates", "tools", "deepcode-notify.ps1"), + path.resolve(moduleDir, "..", "..", "templates", "tools", "deepcode-notify.ps1"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; + } catch { + return null; + } +} + +export function captureForegroundWindowHwnd(): string | undefined { + if (process.platform !== "win32") { + return undefined; + } + + const script = [ + "$code = @'", + "using System;", + "using System.Runtime.InteropServices;", + "public static class DCForeground {", + ' [DllImport("user32.dll")]', + " public static extern IntPtr GetForegroundWindow();", + "}", + "'@", + "Add-Type -TypeDefinition $code", + "[DCForeground]::GetForegroundWindow().ToInt64()", + ].join("\n"); + + try { + const result = spawnSync("powershell.exe", ["-ExecutionPolicy", "Bypass", "-NoProfile", "-Command", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 700, + windowsHide: true, + }); + if (result.status !== 0) { + return undefined; + } + const hwnd = result.stdout.trim(); + return /^[1-9]\d*$/.test(hwnd) ? hwnd : undefined; + } catch { + return undefined; + } +} + +function getBuiltinNotifyEnv( + durationMs: number, + configuredEnv: Record, + context: NotifyContext, + workingDirectory?: string +): NodeJS.ProcessEnv { + const env = buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context); + env.DEEPCODE_NOTIFY_PROCESS_PID ??= String(process.pid); + env.DEEPCODE_NOTIFY_PARENT_PID ??= String(process.ppid); + + const debugEnabled = env.DEEPCODE_NOTIFY_DEBUG === "1" || env.DEEPCODE_NOTIFY_DEBUG === "true"; + if (debugEnabled && !env.DEEPCODE_NOTIFY_DEBUG_LOG) { + env.DEEPCODE_NOTIFY_DEBUG_LOG = path.join(workingDirectory ?? process.cwd(), ".deepcode", "notify.log"); + } + + return env; +} + +/** + * Launch the built-in Windows notification (PowerShell BalloonTip with + * click-to-focus behaviour). Has no effect on non-Windows platforms. + * + * This is intentionally separate from `launchNotifyScript` so that callers + * can decide whether to prefer a user-configured external script or the + * built-in one. + */ +export function launchBuiltinNotify( + durationMs: number, + workingDirectory?: string, + spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, + configuredEnv: Record = {}, + context: NotifyContext = {} +): void { + const scriptPath = resolveBuiltinNotifyPath(); + if (!scriptPath) { + return; + } + + const options = { + cwd: workingDirectory, + detached: false, + env: getBuiltinNotifyEnv(durationMs, configuredEnv, context, workingDirectory), + stdio: "ignore" as const, + }; + + try { + const child = spawnProcess( + "powershell.exe", + ["-ExecutionPolicy", "Bypass", "-NoProfile", "-File", scriptPath], + options + ); + child.once("error", () => undefined); + child.unref(); + } catch { + // Ignore notification failures. + } +} diff --git a/src/session.ts b/src/session.ts index f12b91f..a3dd74c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -5,7 +5,7 @@ import * as crypto from "crypto"; import matter from "gray-matter"; import ejs from "ejs"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; -import { launchNotifyScript } from "./common/notify"; +import { captureForegroundWindowHwnd, launchBuiltinNotify, launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities"; import { readTextFileWithMetadata } from "./common/file-utils"; @@ -1199,6 +1199,8 @@ ${skillMd} const startedAt = Date.now(); const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); + const builtinNotifyWindowHwnd = process.platform === "win32" && !notify ? captureForegroundWindowHwnd() : undefined; + const notifyEnv = builtinNotifyWindowHwnd ? { ...env, DEEPCODE_NOTIFY_WINDOW_HWND: builtinNotifyWindowHwnd } : env; const now = new Date().toISOString(); rebuildSessionStateFromHistory(sessionId, this.listSessionMessages(sessionId)); @@ -1217,7 +1219,7 @@ ${skillMd} ), false ); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); return; } @@ -1229,7 +1231,7 @@ ${skillMd} failReason: "interrupted", updateTime: now, })); - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); return; } @@ -1434,7 +1436,7 @@ ${skillMd} if (this.sessionControllers.get(sessionId) === sessionController) { this.sessionControllers.delete(sessionId); } - this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env); + this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv); } } @@ -2423,32 +2425,48 @@ ${skillMd} startedAt: number, configuredEnv: Record = {} ): void { - if (!notifyCommand) { - return; - } - const session = this.getSession(sessionId); if (!session || (session.status !== "completed" && session.status !== "failed")) { return; } - // Find the last assistant message body for the BODY env variable. + // Find the latest user question and assistant answer for the desktop tip. + let question: string | undefined; let body: string | undefined; const messages = this.listSessionMessages(sessionId); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg && msg.role === "assistant" && msg.content) { + if (!msg) { + continue; + } + if (!question && msg.role === "user" && msg.content?.trim()) { + question = msg.content; + } + if (!body && msg.role === "assistant" && msg.content?.trim()) { body = msg.content; + } + if (question && body) { break; } } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, { + const context = { status: session.status, failReason: session.failReason ?? undefined, + question, body, title: session.summary ?? undefined, - }); + }; + + if (notifyCommand) { + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context); + return; + } + + // Windows: fall back to the built-in toast notification with click-to-focus. + if (process.platform === "win32") { + launchBuiltinNotify(Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context); + } } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index b02642b..95cf3b2 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -981,6 +981,7 @@ test( const records = await waitForNotifyRecords(notifyOutput, 1); assert.equal(records[0]?.STATUS, "completed"); assert.equal(records[0]?.FAIL_REASON, null); + assert.equal(records[0]?.QUESTION, "notify success"); assert.equal(records[0]?.BODY, "final answer"); assert.equal(records[0]?.TITLE, "notify success"); assert.match(String(records[0]?.DURATION), /^\d+$/); @@ -1015,6 +1016,7 @@ test( const failedRecord = records[1]; assert.equal(failedRecord?.STATUS, "failed"); assert.equal(failedRecord?.FAIL_REASON, "second request failed"); + assert.equal(failedRecord?.QUESTION, "second prompt"); assert.equal(failedRecord?.BODY, "first answer"); assert.notEqual(failedRecord?.BODY, "stale-body"); assert.equal(failedRecord?.TITLE, "notify failure"); @@ -3306,7 +3308,7 @@ function createNotifyRecorderScript(dir: string): string { scriptPath, `#!/usr/bin/env node const fs = require("fs"); -const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"]; +const keys = ["DURATION", "STATUS", "FAIL_REASON", "QUESTION", "BODY", "TITLE"]; const record = {}; for (const key of keys) { record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null; diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 9e18dc1..ea4ae3e 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { buildNotifyEnv, formatDurationSeconds, + launchBuiltinNotify, launchNotifyScript, + resolveBuiltinNotifyPath, type NotifyContext, type NotifySpawn, } from "../common/notify"; @@ -433,14 +435,16 @@ test("buildNotifyEnv injects DURATION without context", () => { assert.equal(env.DURATION, "2"); assert.equal(env.STATUS, undefined); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); -test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { +test("buildNotifyEnv injects STATUS, FAIL_REASON, QUESTION, BODY, and TITLE from context", () => { const context: NotifyContext = { status: "failed", failReason: "API key not found", + question: "Can you fix the login bug?", body: "Hello, this is the last assistant message.", title: "Fix login bug", }; @@ -449,6 +453,7 @@ test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", assert.equal(env.DURATION, "5"); assert.equal(env.STATUS, "failed"); assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.QUESTION, "Can you fix the login bug?"); assert.equal(env.BODY, "Hello, this is the last assistant message."); assert.equal(env.TITLE, "Fix login bug"); }); @@ -460,6 +465,7 @@ test("buildNotifyEnv omits optional context fields when not provided", () => { HOME: "/tmp/home", STATUS: "stale-status", FAIL_REASON: "stale-failure", + QUESTION: "stale-question", BODY: "stale-body", TITLE: "stale-title", }, @@ -467,6 +473,7 @@ test("buildNotifyEnv omits optional context fields when not provided", () => { ); assert.equal(env.STATUS, "completed"); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); @@ -478,22 +485,26 @@ test("buildNotifyEnv ignores empty strings in context", () => { { status: "", failReason: "", + question: "", body: "", title: "", } ); assert.equal(env.STATUS, undefined); assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.QUESTION, undefined); assert.equal(env.BODY, undefined); assert.equal(env.TITLE, undefined); }); -test("buildNotifyEnv preserves special characters in body and title", () => { +test("buildNotifyEnv preserves special characters in question, body, and title", () => { const context: NotifyContext = { + question: "Question?\nWith tabs\tand quotes", body: 'Line 1\nLine 2\tindented "quoted"', title: "Fix: login & signup (urgent)", }; const env = buildNotifyEnv(1000, {}, context); + assert.equal(env.QUESTION, "Question?\nWith tabs\tand quotes"); assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"'); assert.equal(env.TITLE, "Fix: login & signup (urgent)"); }); @@ -526,6 +537,7 @@ test( const context: NotifyContext = { status: "completed", + question: "Can you finish the task?", body: "Task finished successfully.", title: "Fix login bug", }; @@ -540,6 +552,7 @@ test( assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); assert.equal(calls[0]?.options.env?.STATUS, "completed"); assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined); + assert.equal(calls[0]?.options.env?.QUESTION, "Can you finish the task?"); assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); assert.equal(calls[1]?.command, "/bin/sh"); @@ -547,7 +560,82 @@ test( assert.equal(calls[1]?.options.cwd, "/tmp/project"); assert.equal(calls[1]?.options.env?.DURATION, "2"); assert.equal(calls[1]?.options.env?.STATUS, "completed"); + assert.equal(calls[1]?.options.env?.QUESTION, "Can you finish the task?"); assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully."); assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); } ); + +test( + "resolveBuiltinNotifyPath returns the bundled Windows notification script", + { skip: process.platform !== "win32" }, + () => { + const notifyPath = resolveBuiltinNotifyPath(); + assert.ok(notifyPath); + assert.match(notifyPath.replace(/\\/g, "/"), /templates\/tools\/deepcode-notify\.ps1$/); + } +); + +test( + "launchBuiltinNotify starts PowerShell with context and DeepCode process ids", + { skip: process.platform !== "win32" }, + () => { + const calls: Array<{ + command: string; + args: string[]; + options: { cwd?: string | URL; env?: NodeJS.ProcessEnv; detached?: boolean; stdio?: unknown }; + }> = []; + + const spawnProcess: NotifySpawn = (command, args, options) => { + calls.push({ + command, + args, + options: { + cwd: options.cwd, + detached: options.detached, + env: options.env, + stdio: options.stdio, + }, + }); + + return { + once() { + return this; + }, + unref() { + return undefined; + }, + }; + }; + + launchBuiltinNotify( + 2750, + "C:/tmp/project", + spawnProcess, + { WEBHOOK: "configured", DEEPCODE_NOTIFY_WINDOW_HWND: "1234" }, + { + status: "completed", + question: "Can you finish the task?", + body: "Task finished successfully.", + title: "Fix login bug", + } + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.command, "powershell.exe"); + assert.deepEqual(calls[0]?.args.slice(0, 4), ["-ExecutionPolicy", "Bypass", "-NoProfile", "-File"]); + assert.match(calls[0]?.args[4]?.replace(/\\/g, "/") ?? "", /templates\/tools\/deepcode-notify\.ps1$/); + assert.equal(calls[0]?.options.cwd, "C:/tmp/project"); + assert.equal(calls[0]?.options.detached, false); + assert.equal(calls[0]?.options.stdio, "ignore"); + assert.equal(calls[0]?.options.env?.DURATION, "2"); + assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[0]?.options.env?.STATUS, "completed"); + assert.equal(calls[0]?.options.env?.QUESTION, "Can you finish the task?"); + assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_PROCESS_PID, String(process.pid)); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_PARENT_PID, String(process.ppid)); + assert.equal(calls[0]?.options.env?.DEEPCODE_NOTIFY_WINDOW_HWND, "1234"); + } +); diff --git a/templates/tools/deepcode-icon.png b/templates/tools/deepcode-icon.png new file mode 100644 index 0000000..5092a8d Binary files /dev/null and b/templates/tools/deepcode-icon.png differ diff --git a/templates/tools/deepcode-notify.ps1 b/templates/tools/deepcode-notify.ps1 new file mode 100644 index 0000000..d34f1e8 --- /dev/null +++ b/templates/tools/deepcode-notify.ps1 @@ -0,0 +1,1039 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + DeepCode CLI built-in Windows notification script. + Shows a clickable desktop tip when a task completes or fails. + Click the notification to focus the originating terminal window. + +.DESCRIPTION + Invoked automatically by DeepCode CLI on Windows when the `notify` + setting is unset. Test-only switches are available for the smoke test + script in this directory; the CLI calls this script without arguments. + + Environment variables passed by the CLI: + STATUS - "completed" | "failed" | "interrupted" + TITLE - Session summary / task title + QUESTION - Last user prompt text + BODY - Last assistant message body + DURATION - Task wall-clock duration in seconds + DEEPCODE_NOTIFY_PARENT_PID - Parent process id used to locate terminal + DEEPCODE_NOTIFY_PROCESS_PID - DeepCode process id + DEEPCODE_NOTIFY_DEBUG_LOG - Optional path for script error logging +#> + +param( + [switch]$ValidateOnly, + [switch]$SelfTest, + [int64]$TestWindowHwnd = 0, + [int]$TimeoutSeconds = 35, + [int]$AutoClickAfterMilliseconds = 0, + [string]$ReadyPath, + [string]$ResultPath +) + +$ErrorActionPreference = "Stop" + +function Write-NotifyDebug { + param([string]$Message) + + $logPath = $env:DEEPCODE_NOTIFY_DEBUG_LOG + if (-not $logPath) { return } + + try { + $dir = Split-Path -Parent $logPath + if ($dir) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + "[$timestamp] $Message" | Out-File -FilePath $logPath -Append -Encoding UTF8 + } catch { } +} + +function Write-TestResult { + param([hashtable]$Result) + + $json = $Result | ConvertTo-Json -Compress -Depth 6 + if ($ResultPath) { + $dir = Split-Path -Parent $ResultPath + if ($dir) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $json | Out-File -FilePath $ResultPath -Encoding UTF8 + } else { + Write-Output $json + } +} + +function Convert-ToWindowHandle { + param([object]$Value) + + if ($null -eq $Value) { return [IntPtr]::Zero } + $text = "$Value".Trim() + if (-not $text) { return [IntPtr]::Zero } + + $raw = [int64]0 + if ([int64]::TryParse($text, [ref]$raw) -and $raw -ne 0) { + return [IntPtr]::new($raw) + } + return [IntPtr]::Zero +} + +try { + # --------------------------------------------------------------------------- + # P/Invoke: console capture + window restore + activation + taskbar flash. + # --------------------------------------------------------------------------- + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Threading; + +namespace DC { + [StructLayout(LayoutKind.Sequential)] + public struct FLASHWINFO { + public uint cbSize; + public IntPtr hwnd; + public uint dwFlags; + public uint uCount; + public uint dwTimeout; + } + + public static class DeepCodeNotify { + private const int SW_SHOWNORMAL = 1; + private const int SW_MINIMIZE = 6; + private const int SW_RESTORE = 9; + private const uint WM_SYSCOMMAND = 0x0112; + private static readonly IntPtr SC_RESTORE = new IntPtr(0xF120); + private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + private static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint KEYEVENTF_KEYUP = 0x0002; + private const byte VK_MENU = 0x12; + private const uint FLASH_UNTIL_FOREGROUND = 0x03 | 0x0C; + private static string lastActivationSummary = ""; + + [DllImport("kernel32.dll")] + public static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AttachConsole(uint dwProcessId); + + [DllImport("kernel32.dll")] + public static extern bool FreeConsole(); + + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); + + [DllImport("user32.dll")] + public static extern bool FlashWindowEx(ref FLASHWINFO pwfi); + + [DllImport("user32.dll")] + public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + + [DllImport("user32.dll")] + public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentThreadId(); + + public static string GetLastActivationSummary() { + return lastActivationSummary ?? ""; + } + + public static int GetWindowProcessId(IntPtr hwnd) { + uint processId; + GetWindowThreadProcessId(hwnd, out processId); + return (int)processId; + } + + public static IntPtr GetConsoleWindowForProcess(uint processId) { + FreeConsole(); + if (!AttachConsole(processId)) { + return IntPtr.Zero; + } + + IntPtr hwnd = GetConsoleWindow(); + FreeConsole(); + return hwnd; + } + + public static bool MinimizeWindow(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + ShowWindow(hwnd, SW_MINIMIZE); + Thread.Sleep(250); + return IsIconic(hwnd); + } + + public static bool RestoreWindow(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + + SendMessage(hwnd, WM_SYSCOMMAND, SC_RESTORE, IntPtr.Zero); + ShowWindowAsync(hwnd, SW_RESTORE); + ShowWindow(hwnd, SW_RESTORE); + Thread.Sleep(200); + + if (IsIconic(hwnd)) { + ShowWindow(hwnd, SW_SHOWNORMAL); + Thread.Sleep(200); + } + + return !IsIconic(hwnd); + } + + public static bool ActivateWindow(IntPtr hwnd) { + if (!RestoreWindow(hwnd)) { + lastActivationSummary = "restore=false"; + return false; + } + + bool top1 = SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + bool top2 = SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + bool bringTop = BringWindowToTop(hwnd); + + // The classic Alt-key nudge relaxes Windows foreground-lock rules. + keybd_event(VK_MENU, 0, 0, UIntPtr.Zero); + Thread.Sleep(30); + keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + Thread.Sleep(60); + + uint targetPid; + uint targetThread = GetWindowThreadProcessId(hwnd, out targetPid); + IntPtr foregroundBefore = GetForegroundWindow(); + uint foregroundPid; + uint foregroundThread = GetWindowThreadProcessId(foregroundBefore, out foregroundPid); + uint currentThread = GetCurrentThreadId(); + + bool attachCurrentTarget = false; + bool attachCurrentForeground = false; + bool attachTargetForeground = false; + if (targetThread != 0) { + attachCurrentTarget = AttachThreadInput(currentThread, targetThread, true); + } + if (foregroundThread != 0) { + attachCurrentForeground = AttachThreadInput(currentThread, foregroundThread, true); + } + if (targetThread != 0 && foregroundThread != 0 && targetThread != foregroundThread) { + attachTargetForeground = AttachThreadInput(targetThread, foregroundThread, true); + } + + bool setForeground = SetForegroundWindow(hwnd); + int setForegroundError = Marshal.GetLastWin32Error(); + BringWindowToTop(hwnd); + + if (targetThread != 0 && foregroundThread != 0 && targetThread != foregroundThread) { + AttachThreadInput(targetThread, foregroundThread, false); + } + if (foregroundThread != 0) { + AttachThreadInput(currentThread, foregroundThread, false); + } + if (targetThread != 0) { + AttachThreadInput(currentThread, targetThread, false); + } + + Thread.Sleep(100); + + if (GetForegroundWindow() == hwnd) { + lastActivationSummary = + "restore=true topmost=" + top1 + + " notopmost=" + top2 + + " bringTop=" + bringTop + + " attachCurrentTarget=" + attachCurrentTarget + + " attachCurrentForeground=" + attachCurrentForeground + + " attachTargetForeground=" + attachTargetForeground + + " setForeground=" + setForeground + + " setForegroundError=" + setForegroundError + + " switch=false foreground=true"; + return true; + } + + SwitchToThisWindow(hwnd, true); + Thread.Sleep(100); + bool foregroundAfterSwitch = GetForegroundWindow() == hwnd; + lastActivationSummary = + "restore=true topmost=" + top1 + + " notopmost=" + top2 + + " bringTop=" + bringTop + + " attachCurrentTarget=" + attachCurrentTarget + + " attachCurrentForeground=" + attachCurrentForeground + + " attachTargetForeground=" + attachTargetForeground + + " setForeground=" + setForeground + + " setForegroundError=" + setForegroundError + + " switch=true foreground=" + foregroundAfterSwitch; + return foregroundAfterSwitch; + } + + public static bool FlashTaskbar(IntPtr hwnd) { + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) { + return false; + } + + FLASHWINFO info = new FLASHWINFO(); + info.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO)); + info.hwnd = hwnd; + info.dwFlags = FLASH_UNTIL_FOREGROUND; + info.uCount = 0; + info.dwTimeout = 0; + return FlashWindowEx(ref info); + } + } +} +'@ + + function Test-TerminalProcessName { + param([string]$ProcessName) + + $name = "$ProcessName".ToLowerInvariant() + return $name -in @( + "windowsterminal", + "wt", + "conhost", + "openconsole", + "cmd", + "powershell", + "pwsh" + ) + } + + function Test-UsableWindowProcessName { + param([string]$ProcessName) + + $name = "$ProcessName".ToLowerInvariant() + return $name -notin @( + "explorer", + "shellexperiencehost", + "searchhost", + "startmenuexperiencehost", + "systemsettings" + ) + } + + function Test-UsableWindowHandle { + param([IntPtr]$WindowHwnd) + + if ($WindowHwnd -eq [IntPtr]::Zero -or -not [DC.DeepCodeNotify]::IsWindow($WindowHwnd)) { + return $false + } + + try { + $windowPid = [DC.DeepCodeNotify]::GetWindowProcessId($WindowHwnd) + if ($windowPid -le 0) { return $false } + $proc = Get-Process -Id $windowPid -ErrorAction Stop + Write-NotifyDebug "window candidate hwnd=$($WindowHwnd.ToInt64()) pid=$windowPid name=$($proc.ProcessName) title='$($proc.MainWindowTitle)'" + return Test-UsableWindowProcessName $proc.ProcessName + } catch { + Write-NotifyDebug "window candidate hwnd=$($WindowHwnd.ToInt64()) lookup failed: $($_.Exception.Message)" + return $false + } + } + + function Find-ConsoleWindowHandle { + param([int]$StartProcessId) + + $current = $StartProcessId + $seen = @{} + Write-NotifyDebug "Find-ConsoleWindowHandle startPid=$StartProcessId" + while ($current -gt 0 -and -not $seen.ContainsKey($current)) { + $seen[$current] = $true + + try { + $consoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindowForProcess([uint32]$current) + Write-NotifyDebug "AttachConsole pid=$current hwnd=$($consoleHwnd.ToInt64())" + if ($consoleHwnd -ne [IntPtr]::Zero -and [DC.DeepCodeNotify]::IsWindow($consoleHwnd)) { + return $consoleHwnd + } + } catch { + Write-NotifyDebug "AttachConsole pid=$current failed: $($_.Exception.Message)" + } + + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop + if (-not $cim -or -not $cim.ParentProcessId) { break } + $current = [int]$cim.ParentProcessId + } catch { + break + } + } + + return [IntPtr]::Zero + } + + function Find-AncestorWindowHandle { + param([int]$StartProcessId) + + $current = $StartProcessId + $seen = @{} + $fallbackHwnd = [IntPtr]::Zero + Write-NotifyDebug "Find-AncestorWindowHandle startPid=$StartProcessId" + while ($current -gt 0 -and -not $seen.ContainsKey($current)) { + $seen[$current] = $true + + try { + $proc = Get-Process -Id $current -ErrorAction Stop + Write-NotifyDebug "process pid=$current name=$($proc.ProcessName) hwnd=$($proc.MainWindowHandle) title='$($proc.MainWindowTitle)'" + if ((Test-TerminalProcessName $proc.ProcessName) -and $proc.MainWindowHandle -and $proc.MainWindowHandle -ne 0) { + return [IntPtr]::new([int64]$proc.MainWindowHandle) + } + if ( + $fallbackHwnd -eq [IntPtr]::Zero -and + (Test-UsableWindowProcessName $proc.ProcessName) -and + $proc.MainWindowHandle -and + $proc.MainWindowHandle -ne 0 + ) { + $fallbackHwnd = [IntPtr]::new([int64]$proc.MainWindowHandle) + Write-NotifyDebug "ancestor fallback candidate pid=$current hwnd=$($fallbackHwnd.ToInt64()) name=$($proc.ProcessName)" + } + } catch { } + + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop + if (-not $cim -or -not $cim.ParentProcessId) { break } + Write-NotifyDebug "process pid=$current parentPid=$($cim.ParentProcessId)" + $current = [int]$cim.ParentProcessId + } catch { + Write-NotifyDebug "process pid=$current parent lookup failed: $($_.Exception.Message)" + break + } + } + + return $fallbackHwnd + } + + function Resolve-TargetWindowHandle { + $explicitHwnd = Convert-ToWindowHandle $TestWindowHwnd + if ($explicitHwnd -ne [IntPtr]::Zero) { return $explicitHwnd } + + $envHwnd = Convert-ToWindowHandle $env:DEEPCODE_NOTIFY_WINDOW_HWND + if ($envHwnd -ne [IntPtr]::Zero) { return $envHwnd } + + foreach ($pidValue in @($env:DEEPCODE_NOTIFY_PARENT_PID, $env:DEEPCODE_NOTIFY_PROCESS_PID)) { + $pidText = "$pidValue".Trim() + if (-not $pidText) { continue } + + $pidNumber = 0 + if ([int]::TryParse($pidText, [ref]$pidNumber)) { + $consoleHwnd = Find-ConsoleWindowHandle $pidNumber + if ($consoleHwnd -ne [IntPtr]::Zero) { + Write-NotifyDebug "resolved via console attach pid=$pidNumber hwnd=$($consoleHwnd.ToInt64())" + return $consoleHwnd + } + + $hwnd = Find-AncestorWindowHandle $pidNumber + if ($hwnd -ne [IntPtr]::Zero) { + Write-NotifyDebug "resolved via ancestor terminal pid=$pidNumber hwnd=$($hwnd.ToInt64())" + return $hwnd + } + } + } + + $foregroundHwnd = [DC.DeepCodeNotify]::GetForegroundWindow() + if (Test-UsableWindowHandle $foregroundHwnd) { + Write-NotifyDebug "resolved via startup foreground hwnd=$($foregroundHwnd.ToInt64())" + return $foregroundHwnd + } + + $ownConsoleHwnd = [DC.DeepCodeNotify]::GetConsoleWindow() + Write-NotifyDebug "fallback own GetConsoleWindow hwnd=$($ownConsoleHwnd.ToInt64())" + return $ownConsoleHwnd + } + + function Invoke-ActivateTargetWindow { + param( + [IntPtr]$WindowHwnd, + [int]$Attempts = 1 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt += 1) { + $activated = [DC.DeepCodeNotify]::ActivateWindow($WindowHwnd) + Write-NotifyDebug "ActivateWindow attempt=$attempt activated=$activated summary='$([DC.DeepCodeNotify]::GetLastActivationSummary())'" + if ($activated) { return $true } + Start-Sleep -Milliseconds 150 + } + + try { + Add-Type -AssemblyName Microsoft.VisualBasic + $targetPid = [DC.DeepCodeNotify]::GetWindowProcessId($WindowHwnd) + if ($targetPid -gt 0) { + $appActivated = [Microsoft.VisualBasic.Interaction]::AppActivate([int]$targetPid) + Write-NotifyDebug "AppActivate pid=$targetPid result=$appActivated" + Start-Sleep -Milliseconds 250 + if ([DC.DeepCodeNotify]::GetForegroundWindow() -eq $WindowHwnd) { + return $true + } + } + } catch { + Write-NotifyDebug "AppActivate failed: $($_.Exception.Message)" + } + + return $false + } + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -WarningAction SilentlyContinue -TypeDefinition @' +namespace DC { + public class DeepCodeToastForm : System.Windows.Forms.Form { + private const int WM_MOUSEACTIVATE = 0x0021; + private const int CS_DROPSHADOW = 0x00020000; + private static readonly System.IntPtr MA_ACTIVATE = new System.IntPtr(1); + + public event System.EventHandler ToastClickRequested; + + protected override System.Windows.Forms.CreateParams CreateParams { + get { + System.Windows.Forms.CreateParams cp = base.CreateParams; + cp.ClassStyle |= CS_DROPSHADOW; + return cp; + } + } + + protected override void WndProc(ref System.Windows.Forms.Message m) { + if (m.Msg == WM_MOUSEACTIVATE) { + m.Result = MA_ACTIVATE; + if (!IsCloseButtonUnderCursor()) { + System.EventHandler handler = ToastClickRequested; + if (handler != null) { + handler(this, System.EventArgs.Empty); + } + } + return; + } + base.WndProc(ref m); + } + + private bool IsCloseButtonUnderCursor() { + System.Drawing.Point clientPoint = PointToClient(System.Windows.Forms.Cursor.Position); + System.Windows.Forms.Control child = GetChildAtPoint(clientPoint); + return child != null && child.Name == "DeepCodeToastClose"; + } + } +} +'@ + + $targetHwnd = Resolve-TargetWindowHandle + $hasTargetWindow = $targetHwnd -ne [IntPtr]::Zero -and [DC.DeepCodeNotify]::IsWindow($targetHwnd) + Write-NotifyDebug "resolved targetHwnd=$($targetHwnd.ToInt64()) hasTargetWindow=$hasTargetWindow parentPid=$env:DEEPCODE_NOTIFY_PARENT_PID processPid=$env:DEEPCODE_NOTIFY_PROCESS_PID" + + if ($ValidateOnly) { + Write-TestResult @{ + ok = $true + mode = "validate" + targetHwnd = if ($hasTargetWindow) { $targetHwnd.ToInt64() } else { 0 } + hasTargetWindow = $hasTargetWindow + formsLoaded = $true + } + exit 0 + } + + if ($SelfTest) { + if (-not $hasTargetWindow) { + throw "SelfTest requires a valid target window handle." + } + + $minimized = [DC.DeepCodeNotify]::MinimizeWindow($targetHwnd) + $restored = [DC.DeepCodeNotify]::RestoreWindow($targetHwnd) + $foreground = Invoke-ActivateTargetWindow $targetHwnd + $flashed = [DC.DeepCodeNotify]::FlashTaskbar($targetHwnd) + $ok = $minimized -and $restored -and -not [DC.DeepCodeNotify]::IsIconic($targetHwnd) + + Write-TestResult @{ + ok = $ok + mode = "selftest" + targetHwnd = $targetHwnd.ToInt64() + minimized = $minimized + restored = $restored + foreground = $foreground + flashed = $flashed + activationSummary = [DC.DeepCodeNotify]::GetLastActivationSummary() + iconicAfterRestore = [DC.DeepCodeNotify]::IsIconic($targetHwnd) + } + + if (-not $ok) { exit 1 } + exit 0 + } + + # --------------------------------------------------------------------------- + # Read context and build notification text. + # --------------------------------------------------------------------------- + function Normalize-NotifySnippet { + param( + [string]$Text, + [int]$MaxChars, + [int]$MaxLines + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return "" + } + + $lines = @() + foreach ($line in ("$Text" -split "\r?\n")) { + $normalized = (($line -replace "\s+", " ").Trim()) + if ($normalized) { + $lines += $normalized + } + if ($lines.Count -ge $MaxLines) { + break + } + } + + if ($lines.Count -eq 0) { + return "" + } + + $snippet = $lines -join " " + if ($snippet.Length -gt $MaxChars) { + $take = [Math]::Max(0, $MaxChars - 3) + return $snippet.Substring(0, $take).TrimEnd() + "..." + } + + return $snippet + } + + function New-RoundedRectanglePath { + param( + [System.Drawing.Rectangle]$Rectangle, + [int]$Radius + ) + + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + if ($Radius -le 0) { + $path.AddRectangle($Rectangle) + return $path + } + + $diameter = [Math]::Max(1, $Radius * 2) + $path.AddArc($Rectangle.Left, $Rectangle.Top, $diameter, $diameter, 180, 90) + $path.AddArc($Rectangle.Right - $diameter, $Rectangle.Top, $diameter, $diameter, 270, 90) + $path.AddArc($Rectangle.Right - $diameter, $Rectangle.Bottom - $diameter, $diameter, $diameter, 0, 90) + $path.AddArc($Rectangle.Left, $Rectangle.Bottom - $diameter, $diameter, $diameter, 90, 90) + $path.CloseFigure() + return $path + } + + function Invoke-ToastShownSound { + if ($ResultPath) { return } + + try { + [System.Media.SystemSounds]::Asterisk.Play() + } catch { + Write-NotifyDebug "toast sound failed: $($_.Exception.Message)" + } + } + + function Set-ToastVisualScale { + param( + [System.Windows.Forms.Form]$ToastForm, + [int]$BaseLeft, + [int]$BaseTop, + [int]$BaseWidth, + [int]$BaseHeight, + [double]$Scale, + [double]$Opacity + ) + + try { + $width = [Math]::Max(1, [int][Math]::Round($BaseWidth * $Scale)) + $height = [Math]::Max(1, [int][Math]::Round($BaseHeight * $Scale)) + $left = $BaseLeft + [int][Math]::Round(($BaseWidth - $width) / 2) + $top = $BaseTop + [int][Math]::Round(($BaseHeight - $height) / 2) + + $ToastForm.SetBounds($left, $top, $width, $height) + $ToastForm.Opacity = [Math]::Min(1, [Math]::Max(0, $Opacity)) + + $path = New-RoundedRectanglePath (New-Object System.Drawing.Rectangle(0, 0, $ToastForm.Width, $ToastForm.Height)) 10 + $oldRegion = $ToastForm.Region + $ToastForm.Region = New-Object System.Drawing.Region($path) + if ($oldRegion) { $oldRegion.Dispose() } + $path.Dispose() + + $ToastForm.Refresh() + [System.Windows.Forms.Application]::DoEvents() + } catch { + Write-NotifyDebug "toast animation frame failed: $($_.Exception.Message)" + } + } + + function Invoke-ToastClickAnimation { + param([System.Windows.Forms.Form]$ToastForm) + + $baseLeft = $ToastForm.Left + $baseTop = $ToastForm.Top + $baseWidth = $ToastForm.Width + $baseHeight = $ToastForm.Height + + foreach ($frame in @( + @{ Scale = 0.96; Opacity = 0.96; Delay = 35 }, + @{ Scale = 1.02; Opacity = 1.0; Delay = 45 }, + @{ Scale = 0.94; Opacity = 0.65; Delay = 35 }, + @{ Scale = 0.90; Opacity = 0.15; Delay = 25 } + )) { + Set-ToastVisualScale $ToastForm $baseLeft $baseTop $baseWidth $baseHeight $frame.Scale $frame.Opacity + Start-Sleep -Milliseconds $frame.Delay + } + } + + $Status = $env:STATUS + $Title = $env:TITLE + $Question = $env:QUESTION + $Body = $env:BODY + $Duration = $env:DURATION + + $statusLabel = switch ($Status) { + "failed" { "Failed" } + "interrupted" { "Interrupted" } + default { "Completed" } + } + + $titleText = "deepcode" + $questionText = Normalize-NotifySnippet $Question 128 2 + if (-not $questionText) { + $questionText = Normalize-NotifySnippet $Title 128 2 + } + if (-not $questionText) { + $questionText = "Task finished" + } + + $answerText = Normalize-NotifySnippet $Body 210 3 + if (-not $answerText -and $env:FAIL_REASON) { + $answerText = Normalize-NotifySnippet $env:FAIL_REASON 210 2 + } + if (-not $answerText) { + $answerText = if ($Duration) { "$statusLabel in ${Duration}s" } else { $statusLabel } + } + + # --------------------------------------------------------------------------- + # Clickable desktop tip window. + # --------------------------------------------------------------------------- + $script:notifyClicked = $false + $script:notifyActivated = $false + $script:notifyFlashed = $false + $script:finalForeground = $false + $script:handlingClick = $false + + $backgroundColor = [System.Drawing.Color]::FromArgb(248, 248, 250) + $brandColor = [System.Drawing.Color]::FromArgb(38, 39, 43) + $questionColor = [System.Drawing.Color]::FromArgb(31, 31, 35) + $answerColor = [System.Drawing.Color]::FromArgb(101, 104, 111) + $chromeColor = [System.Drawing.Color]::FromArgb(77, 80, 86) + $chromeHoverColor = [System.Drawing.Color]::FromArgb(230, 231, 235) + + $form = New-Object DC.DeepCodeToastForm + $form.Text = $titleText + $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None + $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual + $form.ShowInTaskbar = $false + $form.TopMost = $true + $form.ClientSize = New-Object System.Drawing.Size(382, 144) + $form.BackColor = $backgroundColor + $form.Cursor = [System.Windows.Forms.Cursors]::Hand + + $roundedRegionPath = New-RoundedRectanglePath (New-Object System.Drawing.Rectangle(0, 0, $form.Width, $form.Height)) 10 + $form.Region = New-Object System.Drawing.Region($roundedRegionPath) + $roundedRegionPath.Dispose() + + $form.Add_Paint({ + param($sender, $eventArgs) + + try { + $graphics = $eventArgs.Graphics + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $rect = New-Object System.Drawing.Rectangle(0, 0, ($sender.Width - 1), ($sender.Height - 1)) + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $diameter = 20 + $path.AddArc($rect.Left, $rect.Top, $diameter, $diameter, 180, 90) + $path.AddArc($rect.Right - $diameter, $rect.Top, $diameter, $diameter, 270, 90) + $path.AddArc($rect.Right - $diameter, $rect.Bottom - $diameter, $diameter, $diameter, 0, 90) + $path.AddArc($rect.Left, $rect.Bottom - $diameter, $diameter, $diameter, 90, 90) + $path.CloseFigure() + $pen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(197, 199, 204), 1) + $graphics.DrawPath($pen, $path) + $pen.Dispose() + $path.Dispose() + } catch { + Write-NotifyDebug "toast paint failed: $($_.Exception.Message)" + } + }) + + $workArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea + $form.Left = [Math]::Max($workArea.Left, $workArea.Right - $form.Width - 18) + $form.Top = [Math]::Max($workArea.Top, $workArea.Bottom - $form.Height - 18) + + $iconImage = $null + $iconPath = Join-Path $PSScriptRoot "deepcode-icon.png" + if (Test-Path -LiteralPath $iconPath) { + try { + $iconImage = [System.Drawing.Image]::FromFile($iconPath) + } catch { + Write-NotifyDebug "icon load failed: $($_.Exception.Message)" + } + } + + $iconBox = New-Object System.Windows.Forms.PictureBox + $iconBox.Left = 18 + $iconBox.Top = 15 + $iconBox.Width = 20 + $iconBox.Height = 20 + $iconBox.BackColor = $backgroundColor + $iconBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom + $iconBox.Cursor = [System.Windows.Forms.Cursors]::Hand + if ($iconImage) { + $iconBox.Image = $iconImage + } + + $titleLabel = New-Object System.Windows.Forms.Label + $titleLabel.AutoSize = $false + $titleLabel.Left = 46 + $titleLabel.Top = 12 + $titleLabel.Width = 205 + $titleLabel.Height = 26 + $titleLabel.BackColor = $backgroundColor + $titleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 9.5, [System.Drawing.FontStyle]::Regular) + $titleLabel.ForeColor = $brandColor + $titleLabel.Text = $titleText + $titleLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $menuLabel = New-Object System.Windows.Forms.Label + $menuLabel.AutoSize = $false + $menuLabel.Left = $form.ClientSize.Width - 76 + $menuLabel.Top = 9 + $menuLabel.Width = 34 + $menuLabel.Height = 28 + $menuLabel.BackColor = $backgroundColor + $menuLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) + $menuLabel.ForeColor = $chromeColor + $menuLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $menuLabel.Text = "..." + $menuLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $closeLabel = New-Object System.Windows.Forms.Label + $closeLabel.Name = "DeepCodeToastClose" + $closeLabel.AutoSize = $false + $closeLabel.Left = $form.ClientSize.Width - 40 + $closeLabel.Top = 9 + $closeLabel.Width = 28 + $closeLabel.Height = 28 + $closeLabel.BackColor = $backgroundColor + $closeLabel.Font = New-Object System.Drawing.Font("Segoe UI", 13, [System.Drawing.FontStyle]::Regular) + $closeLabel.ForeColor = $chromeColor + $closeLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $closeLabel.Text = [string][char]0x00D7 + $closeLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $closeOnly = { + if ($script:handlingClick) { return } + $script:handlingClick = $true + try { $timeoutTimer.Stop() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop() } catch { } + } + $form.Close() + } + + $questionLabel = New-Object System.Windows.Forms.Label + $questionLabel.AutoSize = $false + $questionLabel.Left = 24 + $questionLabel.Top = 50 + $questionLabel.Width = $form.ClientSize.Width - 48 + $questionLabel.Height = 25 + $questionLabel.AutoEllipsis = $true + $questionLabel.BackColor = $backgroundColor + $questionLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) + $questionLabel.ForeColor = $questionColor + $questionLabel.Text = $questionText + $questionLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $answerLabel = New-Object System.Windows.Forms.Label + $answerLabel.AutoSize = $false + $answerLabel.Left = 24 + $answerLabel.Top = 77 + $answerLabel.Width = $form.ClientSize.Width - 48 + $answerLabel.Height = 52 + $answerLabel.AutoEllipsis = $true + $answerLabel.BackColor = $backgroundColor + $answerLabel.Font = New-Object System.Drawing.Font("Segoe UI", 10.5, [System.Drawing.FontStyle]::Regular) + $answerLabel.ForeColor = $answerColor + $answerLabel.Text = $answerText + $answerLabel.Cursor = [System.Windows.Forms.Cursors]::Hand + + $form.Controls.Add($iconBox) + $form.Controls.Add($titleLabel) + $form.Controls.Add($menuLabel) + $form.Controls.Add($closeLabel) + $form.Controls.Add($questionLabel) + $form.Controls.Add($answerLabel) + + $timeoutTimer = New-Object System.Windows.Forms.Timer + $timeoutTimer.Interval = [Math]::Max(1, $TimeoutSeconds) * 1000 + $timeoutTimer.Add_Tick({ + Write-NotifyDebug "toast timeout" + $timeoutTimer.Stop() + $form.Close() + }) + + $autoClickTimer = $null + if ($AutoClickAfterMilliseconds -gt 0) { + $autoClickTimer = New-Object System.Windows.Forms.Timer + $autoClickTimer.Interval = [Math]::Max(1, $AutoClickAfterMilliseconds) + } + + $activateAndClose = { + if ($script:handlingClick) { return } + $script:handlingClick = $true + $script:notifyClicked = $true + Write-NotifyDebug "toast click received targetHwnd=$($targetHwnd.ToInt64()) hasTargetWindow=$hasTargetWindow" + try { $timeoutTimer.Stop() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop() } catch { } + } + + if ($hasTargetWindow) { + # Keep the clicked tip window alive while requesting foreground access. + # Hiding first can discard the user-click foreground permission on Windows. + $script:notifyActivated = Invoke-ActivateTargetWindow $targetHwnd 1 + Write-NotifyDebug "Invoke-ActivateTargetWindow activated=$script:notifyActivated" + if (-not $script:notifyActivated) { + $script:notifyFlashed = [DC.DeepCodeNotify]::FlashTaskbar($targetHwnd) + Write-NotifyDebug "FlashTaskbar flashed=$script:notifyFlashed" + } + $script:finalForeground = [DC.DeepCodeNotify]::GetForegroundWindow() -eq $targetHwnd + Write-NotifyDebug "final foreground after click=$script:finalForeground" + } else { + Write-NotifyDebug "click ignored because no target window was resolved" + } + + try { + Invoke-ToastClickAnimation $form + $form.Hide() + [System.Windows.Forms.Application]::DoEvents() + } catch { } + $form.Close() + } + + $form.Add_Click($activateAndClose) + $form.Add_MouseUp($activateAndClose) + $form.Add_ToastClickRequested($activateAndClose) + $iconBox.Add_Click($activateAndClose) + $iconBox.Add_MouseUp($activateAndClose) + $titleLabel.Add_Click($activateAndClose) + $titleLabel.Add_MouseUp($activateAndClose) + $menuLabel.Add_Click($activateAndClose) + $menuLabel.Add_MouseUp($activateAndClose) + $questionLabel.Add_Click($activateAndClose) + $questionLabel.Add_MouseUp($activateAndClose) + $answerLabel.Add_Click($activateAndClose) + $answerLabel.Add_MouseUp($activateAndClose) + $closeLabel.Add_MouseEnter({ $closeLabel.BackColor = $chromeHoverColor }) + $closeLabel.Add_MouseLeave({ $closeLabel.BackColor = $backgroundColor }) + $closeLabel.Add_Click($closeOnly) + $closeLabel.Add_MouseUp($closeOnly) + + if ($autoClickTimer) { + $autoClickTimer.Add_Tick({ + Write-NotifyDebug "auto click timer fired" + $autoClickTimer.Stop() + & $activateAndClose + }) + } + + $form.Add_Shown({ + Write-NotifyDebug "toast shown timeoutSeconds=$TimeoutSeconds title='$titleText'" + Invoke-ToastShownSound + if ($ReadyPath) { + try { + $readyDir = Split-Path -Parent $ReadyPath + if ($readyDir) { + New-Item -ItemType Directory -Path $readyDir -Force | Out-Null + } + @{ + ok = $true + mode = "ready" + hwnd = $form.Handle.ToInt64() + left = $form.Left + top = $form.Top + width = $form.Width + height = $form.Height + centerX = $form.Left + [int]($form.Width / 2) + centerY = $form.Top + [int]($form.Height / 2) + } | ConvertTo-Json -Compress | Out-File -FilePath $ReadyPath -Encoding UTF8 + } catch { + Write-NotifyDebug "ready write failed: $($_.Exception.Message)" + } + } + $timeoutTimer.Start() + if ($autoClickTimer) { $autoClickTimer.Start() } + }) + + try { + [System.Windows.Forms.Application]::Run($form) + } finally { + try { $timeoutTimer.Stop(); $timeoutTimer.Dispose() } catch { } + if ($autoClickTimer) { + try { $autoClickTimer.Stop(); $autoClickTimer.Dispose() } catch { } + } + try { $form.Dispose() } catch { } + if ($iconImage) { + try { $iconImage.Dispose() } catch { } + } + if ($hasTargetWindow) { + $script:finalForeground = [DC.DeepCodeNotify]::GetForegroundWindow() -eq $targetHwnd + } + Write-NotifyDebug "toast disposed clicked=$script:notifyClicked activated=$script:notifyActivated flashed=$script:notifyFlashed finalForeground=$script:finalForeground" + } + + if ($ResultPath) { + Write-TestResult @{ + ok = $script:notifyClicked -and $script:finalForeground + mode = "toast" + clicked = $script:notifyClicked + activated = $script:notifyActivated + flashed = $script:notifyFlashed + finalForeground = $script:finalForeground + targetHwnd = if ($hasTargetWindow) { $targetHwnd.ToInt64() } else { 0 } + hasTargetWindow = $hasTargetWindow + } + } +} catch { + Write-NotifyDebug ($_ | Out-String) + if ($ValidateOnly -or $SelfTest -or $ResultPath) { + Write-TestResult @{ + ok = $false + mode = if ($SelfTest) { "selftest" } elseif ($ValidateOnly) { "validate" } else { "notify" } + error = $_.Exception.Message + } + } + exit 1 +} diff --git a/templates/tools/test-notify.ps1 b/templates/tools/test-notify.ps1 new file mode 100644 index 0000000..428953c --- /dev/null +++ b/templates/tools/test-notify.ps1 @@ -0,0 +1,516 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Automated smoke test for the built-in DeepCode Windows notification script. + +.DESCRIPTION + This verifies that deepcode-notify.ps1 can compile its Win32 declarations, + create Windows Forms notification dependencies, and restore a minimized + target window. It opens a disposable WinForms window and closes it after + the test. +#> + +param( + [string]$NotifyScript = (Join-Path $PSScriptRoot "deepcode-notify.ps1"), + [switch]$ShowBalloonSmoke, + [switch]$ManualClickTest, + [switch]$CurrentTerminalClickTest +) + +$ErrorActionPreference = "Stop" + +function Invoke-NotifyScriptJson { + param([string[]]$Arguments) + + $output = & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript @Arguments + if ($LASTEXITCODE -ne 0) { + throw "deepcode-notify.ps1 failed with exit code $LASTEXITCODE. Output: $output" + } + + try { + return ($output | Out-String | ConvertFrom-Json) + } catch { + throw "deepcode-notify.ps1 did not return valid JSON. Output: $output" + } +} + +function Start-TestWindow { + $testDir = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode-notify-test-$PID" + New-Item -ItemType Directory -Path $testDir -Force | Out-Null + + $helperPath = Join-Path $testDir "window-helper.ps1" + $hwndPath = Join-Path $testDir "hwnd.txt" + + @' +param([string]$HwndPath) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$form = New-Object System.Windows.Forms.Form +$form.Text = "DeepCode Notify Test Window" +$form.Size = New-Object System.Drawing.Size(420, 180) +$form.StartPosition = "CenterScreen" +$form.ShowInTaskbar = $true +$form.TopMost = $false + +$label = New-Object System.Windows.Forms.Label +$label.Text = "DeepCode notification smoke test target" +$label.Dock = "Fill" +$label.TextAlign = "MiddleCenter" +$form.Controls.Add($label) + +$form.Add_Shown({ + $form.Handle.ToInt64().ToString() | Out-File -FilePath $HwndPath -NoNewline -Encoding ASCII +}) + +[System.Windows.Forms.Application]::Run($form) +'@ | Out-File -FilePath $helperPath -Encoding UTF8 + + $proc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $helperPath, + "-HwndPath", $hwndPath + ) -PassThru + + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $hwndPath) { + $raw = Get-Content -LiteralPath $hwndPath -Raw + $hwnd = [int64]0 + if ([int64]::TryParse($raw.Trim(), [ref]$hwnd) -and $hwnd -ne 0) { + return @{ + Process = $proc + Hwnd = $hwnd + Directory = $testDir + } + } + } + Start-Sleep -Milliseconds 200 + } + + try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch { } + Remove-Item -LiteralPath $testDir -Recurse -Force -ErrorAction SilentlyContinue + throw "Timed out waiting for the disposable WinForms test window." +} + +function Invoke-TestMouseClick { + param( + [int]$X, + [int]$Y + ) + + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace DeepCodeNotifyTest { + [StructLayout(LayoutKind.Sequential)] + public struct POINT { + public int X; + public int Y; + } + + public static class Mouse { + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int X, int Y); + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT lpPoint); + + [DllImport("user32.dll")] + public static extern IntPtr WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + public static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + [DllImport("user32.dll")] + public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); + } +} +'@ -ErrorAction SilentlyContinue + + $moved = [DeepCodeNotifyTest.Mouse]::SetCursorPos($X, $Y) + Start-Sleep -Milliseconds 80 + $point = New-Object DeepCodeNotifyTest.POINT + [DeepCodeNotifyTest.Mouse]::GetCursorPos([ref]$point) | Out-Null + $hit = [DeepCodeNotifyTest.Mouse]::WindowFromPoint($point) + $root = [DeepCodeNotifyTest.Mouse]::GetAncestor($hit, 2) + Write-Host " Mouse moved=$moved cursor=($($point.X),$($point.Y)) hitHwnd=$($hit.ToInt64()) rootHwnd=$($root.ToInt64())" + [DeepCodeNotifyTest.Mouse]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 40 + [DeepCodeNotifyTest.Mouse]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) + Write-Host " mouse_event click sent" + Start-Sleep -Milliseconds 350 +} + +function Get-TestWindowCenter { + param([int64]$Hwnd) + + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace DeepCodeNotifyTest { + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + public static class WindowRect { + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + } +} +'@ -ErrorAction SilentlyContinue + + $rect = New-Object DeepCodeNotifyTest.RECT + $ok = [DeepCodeNotifyTest.WindowRect]::GetWindowRect([IntPtr]::new($Hwnd), [ref]$rect) + if (-not $ok) { + throw "GetWindowRect failed for HWND $Hwnd" + } + + return @{ + X = [int](($rect.Left + $rect.Right) / 2) + Y = [int](($rect.Top + $rect.Bottom) / 2) + } +} + +function Get-ForegroundWindowHwnd { + Add-Type -MemberDefinition @' +[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); +'@ -Name Foreground -Namespace DeepCodeNotifyTest -ErrorAction SilentlyContinue + + return [DeepCodeNotifyTest.Foreground]::GetForegroundWindow().ToInt64() +} + +function Set-TestWindowMinimized { + param([int64]$Hwnd) + + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace DeepCodeNotifyTest { + public static class WindowState { + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + } +} +'@ -ErrorAction SilentlyContinue + + [DeepCodeNotifyTest.WindowState]::ShowWindow([IntPtr]::new($Hwnd), 6) | Out-Null + Start-Sleep -Milliseconds 300 + return [DeepCodeNotifyTest.WindowState]::IsIconic([IntPtr]::new($Hwnd)) +} + +function Wait-JsonFile { + param( + [string]$Path, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $Path) { + try { + $raw = Get-Content -LiteralPath $Path -Raw + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $json = $raw | ConvertFrom-Json + if ($json) { + return $json + } + } + } catch { } + } + Start-Sleep -Milliseconds 100 + } + + throw "Timed out waiting for JSON file: $Path" +} + +function Wait-ProcessExit { + param( + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if ($Process.HasExited) { + return + } + Start-Sleep -Milliseconds 100 + } + + try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch { } + throw "Timed out waiting for process $($Process.Id) to exit." +} + +Write-Host "=== DeepCode Notify Automated Smoke Test ===" -ForegroundColor Cyan +Write-Host "Script: $NotifyScript" + +if (-not (Test-Path -LiteralPath $NotifyScript)) { + throw "Notify script not found: $NotifyScript" +} + +Write-Host "1. Validating script dependencies..." +$validate = Invoke-NotifyScriptJson @("-ValidateOnly") +if (-not $validate.ok) { + throw "ValidateOnly failed: $($validate | ConvertTo-Json -Compress)" +} +Write-Host " PASS: Add-Type and Windows Forms dependencies loaded" + +if ($CurrentTerminalClickTest) { + Write-Host "2. Current terminal click test: click the DeepCode desktop tip within 45 seconds..." + $testDir = Join-Path ([System.IO.Path]::GetTempPath()) "deepcode-notify-current-terminal-$PID" + New-Item -ItemType Directory -Path $testDir -Force | Out-Null + $debugLog = Join-Path $testDir "notify-current-terminal.log" + $resultPath = Join-Path $testDir "notify-current-terminal.json" + + try { + $foregroundHwnd = Get-ForegroundWindowHwnd + $env:STATUS = "completed" + $env:TITLE = "DeepCode Current Terminal Test" + $env:QUESTION = "Current terminal click test" + $env:BODY = "Click this tip to focus the terminal that launched the test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$foregroundHwnd" + $env:DEEPCODE_NOTIFY_PARENT_PID = "$PID" + $env:DEEPCODE_NOTIFY_PROCESS_PID = "$PID" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $debugLog + + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript ` + -TimeoutSeconds 45 ` + -ResultPath $resultPath + + if ($LASTEXITCODE -ne 0) { + throw "Current terminal click test failed with exit code $LASTEXITCODE" + } + + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + Write-Host " Captured foreground HWND: $foregroundHwnd" + Write-Host " Result: clicked=$($result.clicked) activated=$($result.activated) finalForeground=$($result.finalForeground) targetHwnd=$($result.targetHwnd)" + Write-Host " Debug log:" + if (Test-Path -LiteralPath $debugLog) { + Get-Content -LiteralPath $debugLog | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " " + } + + if (-not $result.ok) { + throw "Current terminal click test did not focus the resolved terminal window." + } + + Write-Host "" + Write-Host "RESULT: PASS" -ForegroundColor Green + exit 0 + } finally { + Remove-Item -LiteralPath $testDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "2. Validating current terminal PID resolution..." +$previousNotifyWindowHwnd = $env:DEEPCODE_NOTIFY_WINDOW_HWND +$previousNotifyParentPid = $env:DEEPCODE_NOTIFY_PARENT_PID +$previousNotifyProcessPid = $env:DEEPCODE_NOTIFY_PROCESS_PID +try { + Remove-Item Env:\DEEPCODE_NOTIFY_WINDOW_HWND -ErrorAction SilentlyContinue + $env:DEEPCODE_NOTIFY_PARENT_PID = "$PID" + $env:DEEPCODE_NOTIFY_PROCESS_PID = "$PID" + $pidResolve = Invoke-NotifyScriptJson @("-ValidateOnly") + if (-not $pidResolve.ok -or -not $pidResolve.hasTargetWindow -or [int64]$pidResolve.targetHwnd -eq 0) { + throw "Current terminal PID resolution failed: $($pidResolve | ConvertTo-Json -Compress)" + } + Write-Host " PASS: targetHwnd=$($pidResolve.targetHwnd)" +} finally { + if ($null -eq $previousNotifyWindowHwnd) { + Remove-Item Env:\DEEPCODE_NOTIFY_WINDOW_HWND -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_WINDOW_HWND = $previousNotifyWindowHwnd + } + if ($null -eq $previousNotifyParentPid) { + Remove-Item Env:\DEEPCODE_NOTIFY_PARENT_PID -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_PARENT_PID = $previousNotifyParentPid + } + if ($null -eq $previousNotifyProcessPid) { + Remove-Item Env:\DEEPCODE_NOTIFY_PROCESS_PID -ErrorAction SilentlyContinue + } else { + $env:DEEPCODE_NOTIFY_PROCESS_PID = $previousNotifyProcessPid + } +} + +Write-Host "3. Opening disposable WinForms target..." +$target = Start-TestWindow +$targetHwnd = $target.Hwnd +Write-Host " Target HWND: $targetHwnd" + +try { + Write-Host "4. Testing minimized-window restore and taskbar flash..." + $selfTest = Invoke-NotifyScriptJson @("-SelfTest", "-TestWindowHwnd", "$targetHwnd") + if (-not $selfTest.ok) { + throw "SelfTest failed: $($selfTest | ConvertTo-Json -Compress)" + } + + Write-Host " PASS: minimized=$($selfTest.minimized) restored=$($selfTest.restored) flashed=$($selfTest.flashed) foreground=$($selfTest.foreground)" + + Write-Host "5. Testing desktop tip click path with automatic click..." + $autoClickResultPath = Join-Path $target.Directory "toast-auto-click.json" + $autoClickReadyPath = Join-Path $target.Directory "toast-auto-click-ready.json" + $autoClickDebugLog = Join-Path $target.Directory "toast-auto-click.log" + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Auto Click" + $env:QUESTION = "Automated click path test question" + $env:BODY = "Automated click path test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $autoClickDebugLog + + $notifyProc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $NotifyScript, + "-TimeoutSeconds", "8", + "-ReadyPath", $autoClickReadyPath, + "-ResultPath", $autoClickResultPath + ) -PassThru + + $ready = Wait-JsonFile -Path $autoClickReadyPath -TimeoutSeconds 5 + Write-Host " Ready: hwnd=$($ready.hwnd) left=$($ready.left) top=$($ready.top) width=$($ready.width) height=$($ready.height) centerX=$($ready.centerX) centerY=$($ready.centerY)" + if ([int64]$ready.hwnd -ne 0) { + $center = Get-TestWindowCenter -Hwnd ([int64]$ready.hwnd) + Write-Host " HWND center: x=$($center.X) y=$($center.Y)" + } elseif ([int]$ready.centerX -ne 0 -and [int]$ready.centerY -ne 0) { + $center = @{ + X = [int]$ready.centerX + Y = [int]$ready.centerY + } + } else { + throw "Desktop tip ready data did not include a usable HWND or center point: $($ready | ConvertTo-Json -Compress)" + } + Write-Host " Clicking tip center: x=$($center.X) y=$($center.Y)" + Invoke-TestMouseClick -X $center.X -Y $center.Y + Wait-ProcessExit -Process $notifyProc -TimeoutSeconds 10 + + if ($notifyProc.ExitCode -ne 0) { + throw "Desktop tip click failed with exit code $($notifyProc.ExitCode)" + } + + $autoClick = Get-Content -LiteralPath $autoClickResultPath -Raw | ConvertFrom-Json + if (-not $autoClick.ok) { + if (Test-Path -LiteralPath $autoClickDebugLog) { + Write-Host " Debug log:" + Get-Content -LiteralPath $autoClickDebugLog | ForEach-Object { Write-Host " $_" } + } + throw "Desktop tip auto-click did not pass: $($autoClick | ConvertTo-Json -Compress)" + } + Write-Host " PASS: clicked=$($autoClick.clicked) activated=$($autoClick.activated) finalForeground=$($autoClick.finalForeground) flashed=$($autoClick.flashed)" + + Write-Host "6. Testing desktop tip click path while target is minimized..." + $minimizedClickResultPath = Join-Path $target.Directory "toast-minimized-click.json" + $minimizedClickReadyPath = Join-Path $target.Directory "toast-minimized-click-ready.json" + $minimizedClickDebugLog = Join-Path $target.Directory "toast-minimized-click.log" + $targetMinimized = Set-TestWindowMinimized -Hwnd $targetHwnd + if (-not $targetMinimized) { + throw "Failed to minimize target window before click test." + } + + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Minimized Click" + $env:QUESTION = "Minimized click path test question" + $env:BODY = "Click should restore and focus the minimized target" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $minimizedClickDebugLog + + $minimizedNotifyProc = Start-Process powershell.exe -ArgumentList @( + "-ExecutionPolicy", "Bypass", "-NoProfile", + "-File", $NotifyScript, + "-TimeoutSeconds", "8", + "-ReadyPath", $minimizedClickReadyPath, + "-ResultPath", $minimizedClickResultPath + ) -PassThru + + $minimizedReady = Wait-JsonFile -Path $minimizedClickReadyPath -TimeoutSeconds 5 + if ([int64]$minimizedReady.hwnd -eq 0) { + throw "Minimized-target tip ready data did not include a usable HWND: $($minimizedReady | ConvertTo-Json -Compress)" + } + $minimizedCenter = Get-TestWindowCenter -Hwnd ([int64]$minimizedReady.hwnd) + Write-Host " Clicking minimized-target tip center: x=$($minimizedCenter.X) y=$($minimizedCenter.Y)" + Invoke-TestMouseClick -X $minimizedCenter.X -Y $minimizedCenter.Y + Wait-ProcessExit -Process $minimizedNotifyProc -TimeoutSeconds 10 + + if ($minimizedNotifyProc.ExitCode -ne 0) { + throw "Minimized target click failed with exit code $($minimizedNotifyProc.ExitCode)" + } + + $minimizedClick = Get-Content -LiteralPath $minimizedClickResultPath -Raw | ConvertFrom-Json + if (-not $minimizedClick.ok) { + if (Test-Path -LiteralPath $minimizedClickDebugLog) { + Write-Host " Debug log:" + Get-Content -LiteralPath $minimizedClickDebugLog | ForEach-Object { Write-Host " $_" } + } + throw "Desktop tip minimized-target click did not pass: $($minimizedClick | ConvertTo-Json -Compress)" + } + Write-Host " PASS: clicked=$($minimizedClick.clicked) activated=$($minimizedClick.activated) finalForeground=$($minimizedClick.finalForeground) flashed=$($minimizedClick.flashed)" + + if ($ShowBalloonSmoke) { + Write-Host "7. Showing a one-second desktop tip smoke test..." + $env:STATUS = "completed" + $env:TITLE = "DeepCode Notify Smoke" + $env:QUESTION = "Smoke test question" + $env:BODY = "Automated smoke test" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript -TimeoutSeconds 1 + if ($LASTEXITCODE -ne 0) { + throw "BalloonTip smoke failed with exit code $LASTEXITCODE" + } + Write-Host " PASS: desktop tip path exited successfully" + } + + if ($ManualClickTest) { + Write-Host "8. Manual click test: click the DeepCode desktop tip within 45 seconds..." + $debugLog = Join-Path $target.Directory "notify-click.log" + $env:STATUS = "completed" + $env:TITLE = "DeepCode Manual Click Test" + $env:QUESTION = "Manual click test question" + $env:BODY = "Click this notification to focus the test window" + $env:DURATION = "1" + $env:DEEPCODE_NOTIFY_WINDOW_HWND = "$targetHwnd" + $env:DEEPCODE_NOTIFY_DEBUG = "1" + $env:DEEPCODE_NOTIFY_DEBUG_LOG = $debugLog + + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $NotifyScript -TimeoutSeconds 45 + if ($LASTEXITCODE -ne 0) { + throw "Manual click test failed with exit code $LASTEXITCODE" + } + + Write-Host " Debug log:" + if (Test-Path -LiteralPath $debugLog) { + Get-Content -LiteralPath $debugLog | ForEach-Object { Write-Host " $_" } + } else { + Write-Host " " + } + } +} finally { + if ($target) { + try { + Get-Process -Id $target.Process.Id -ErrorAction SilentlyContinue | Stop-Process -Force + } catch { } + Remove-Item -LiteralPath $target.Directory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "" +Write-Host "RESULT: PASS" -ForegroundColor Green