diff --git a/python/global_hotkey.py b/python/global_hotkey.py new file mode 100644 index 0000000..c5794fd --- /dev/null +++ b/python/global_hotkey.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import argparse +import ctypes +import sys +import threading +from ctypes import wintypes + +HOTKEY_ID = 0x4F4B +WM_HOTKEY = 0x0312 +WM_QUIT = 0x0012 +MOD_ALT = 0x0001 +MOD_CONTROL = 0x0002 +MOD_NOREPEAT = 0x4000 +VK_S = 0x53 +SYNCHRONIZE = 0x00100000 +INFINITE = 0xFFFFFFFF + + +def start_parent_watch(parent_pid: int, message_thread_id: int, user32: ctypes.WinDLL) -> None: + if parent_pid <= 0: + return + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + wait_for_single_object = kernel32.WaitForSingleObject + wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD] + wait_for_single_object.restype = wintypes.DWORD + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + post_thread_message = user32.PostThreadMessageW + post_thread_message.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + post_thread_message.restype = wintypes.BOOL + + handle = open_process(SYNCHRONIZE, False, parent_pid) + if not handle: + return + + def wait_for_parent() -> None: + try: + wait_for_single_object(handle, INFINITE) + post_thread_message(message_thread_id, WM_QUIT, 0, 0) + finally: + close_handle(handle) + + threading.Thread(target=wait_for_parent, name="ok-hotkey-parent-watch", daemon=True).start() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--parent-pid", type=int, default=0) + args = parser.parse_args() + + if sys.platform != "win32": + print("UNSUPPORTED", flush=True) + return 3 + + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + register_hot_key = user32.RegisterHotKey + register_hot_key.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT] + register_hot_key.restype = wintypes.BOOL + unregister_hot_key = user32.UnregisterHotKey + unregister_hot_key.argtypes = [wintypes.HWND, ctypes.c_int] + unregister_hot_key.restype = wintypes.BOOL + get_message = user32.GetMessageW + get_message.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] + get_message.restype = ctypes.c_int + get_current_thread_id = kernel32.GetCurrentThreadId + get_current_thread_id.restype = wintypes.DWORD + + modifiers = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT + if not register_hot_key(None, HOTKEY_ID, modifiers, VK_S): + print(f"ERROR register_hotkey {ctypes.get_last_error()}", flush=True) + return 2 + + start_parent_watch(args.parent_pid, get_current_thread_id(), user32) + print("READY", flush=True) + message = wintypes.MSG() + try: + while True: + result = get_message(ctypes.byref(message), None, 0, 0) + if result == -1: + print(f"ERROR get_message {ctypes.get_last_error()}", flush=True) + return 4 + if result == 0: + return 0 + if message.message == WM_HOTKEY and message.wParam == HOTKEY_ID: + print("TRIGGER", flush=True) + finally: + unregister_hot_key(None, HOTKEY_ID) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/backgroundScreenshot.ts b/src/backgroundScreenshot.ts new file mode 100644 index 0000000..a0a77f0 --- /dev/null +++ b/src/backgroundScreenshot.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { tr } from './localization'; +import { resolveProjectDir, templatesDirectory } from './projectConfig'; +import { captureGameWindow } from './screenshotCapture'; + +/** + * Capture directly into the template authoring directory without revealing an IDE panel. + * + * The system-wide hotkey uses this path so pressing Ctrl+Alt+S while the game is focused + * does not bring VS Code to the foreground before capture. The actual window capture still + * goes through the same captureGameWindow implementation used by the normal asset panel. + */ +export async function captureTemplateScreenshotInBackground(): Promise { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + void vscode.window.showWarningMessage(tr('No workspace folder open.')); + return false; + } + + const projectRoot = resolveProjectDir() || folder.uri.fsPath; + const outputDir = path.join(projectRoot, templatesDirectory(projectRoot)); + fs.mkdirSync(outputDir, { recursive: true }); + + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; + const outputPath = path.join(outputDir, `screenshot_${ts}.png`); + + const outcome = await captureGameWindow(outputPath); + if (!outcome.ok) { + if (outcome.reason === 'noScript') { + void vscode.window.showErrorMessage(tr('Screenshot script not found. capture_game_window.py is missing from the extension.')); + } else if (outcome.reason === 'failed') { + void vscode.window.showErrorMessage(tr('Screenshot failed: {error}', { error: outcome.error || '' })); + } + return false; + } + + void vscode.window.showInformationMessage(tr('Screenshot saved: {name}', { name: path.basename(outputPath) })); + return true; +} diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 606a961..82a1cb4 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -1,8 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { captureTemplateScreenshotInBackground } from './backgroundScreenshot'; import { activate as activateCore, deactivate as deactivateCore } from './extension'; +import { GlobalScreenshotHotkey } from './globalScreenshotHotkey'; import { resolveProjectDir } from './projectConfig'; +import { getProjectConfig } from './screenshotCapture'; +import { repaintAllAssetGalleries } from './templateAssetPanel'; const WALKTHROUGH_ID = 'okScriptToolkit.getStarted'; const PROJECT_READY_CONTEXT = 'okScriptToolkit.projectReady'; @@ -30,6 +34,16 @@ function isSupportedProject(projectDir: string): boolean { export function activate(context: vscode.ExtensionContext): void { activateCore(context); + const globalScreenshotHotkey = new GlobalScreenshotHotkey( + context.extensionPath, + () => getProjectConfig().pythonPath, + () => { + void captureTemplateScreenshotInBackground().then((captured) => { + if (captured) repaintAllAssetGalleries(); + }); + }, + ); + const updateProjectReady = () => vscode.commands.executeCommand( 'setContext', PROJECT_READY_CONTEXT, @@ -95,6 +109,7 @@ export function activate(context: vscode.ExtensionContext): void { } }), new vscode.Disposable(disposeConfigWatchers), + globalScreenshotHotkey, ); void updateProjectReady(); diff --git a/src/globalScreenshotHotkey.ts b/src/globalScreenshotHotkey.ts new file mode 100644 index 0000000..8fc742a --- /dev/null +++ b/src/globalScreenshotHotkey.ts @@ -0,0 +1,144 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ChildProcess, spawn } from 'child_process'; + +const INITIAL_RETRY_DELAY_MS = 2_000; +const MAX_RETRY_DELAY_MS = 30_000; + +/** + * Windows 系统级 Ctrl+Alt+S 监听器。 + * + * VS Code 的 contributes.keybindings 只能在 VS Code 获得焦点时触发;这里启动一个 + * 仅依赖 Python 标准库的轻量辅助进程,通过 RegisterHotKey 收到系统级快捷键后把 + * TRIGGER 事件转回扩展宿主。截图本身仍由现有 command/面板链路完成。 + */ +export class GlobalScreenshotHotkey { + private child: ChildProcess | undefined; + private generation = 0; + private disposed = false; + private retryTimer: NodeJS.Timeout | undefined; + private retryDelayMs = INITIAL_RETRY_DELAY_MS; + + constructor( + private readonly extensionPath: string, + private readonly resolvePythonPath: () => string, + private readonly onTrigger: () => void, + private readonly log: (message: string) => void = (message) => console.log(`[ok-script Toolkit] ${message}`), + ) { + this.start(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.clearRetry(); + this.stop(); + } + + private start(): void { + if (this.disposed || process.platform !== 'win32') return; + + this.clearRetry(); + const scriptPath = path.join(this.extensionPath, 'python', 'global_hotkey.py'); + if (!fs.existsSync(scriptPath)) { + this.log(`Global screenshot hotkey helper not found: ${scriptPath}`); + return; + } + + const generation = ++this.generation; + let stdoutBuffer = ''; + + try { + const pythonPath = this.resolvePythonPath(); + const child = spawn(pythonPath, [scriptPath, '--parent-pid', String(process.pid)], { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.child = child; + + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + if (generation !== this.generation || this.disposed) return; + stdoutBuffer += chunk; + while (true) { + const newline = stdoutBuffer.indexOf('\n'); + if (newline < 0) break; + const line = stdoutBuffer.slice(0, newline).trim(); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + this.handleLine(line); + } + }); + + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk: string) => { + if (generation === this.generation && !this.disposed) { + const text = chunk.trim(); + if (text) this.log(`Global screenshot hotkey helper stderr: ${text}`); + } + }); + + child.on('error', (error) => { + if (generation === this.generation && !this.disposed) { + this.log(`Failed to start global screenshot hotkey helper: ${error.message}`); + this.scheduleRetry(); + } + }); + + child.on('close', (code) => { + if (generation !== this.generation) return; + if (this.child === child) this.child = undefined; + if (!this.disposed && code !== 0) { + this.log(`Global screenshot hotkey helper exited with code ${code ?? 'unknown'}`); + this.scheduleRetry(); + } + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to start global screenshot hotkey helper: ${message}`); + this.scheduleRetry(); + } + } + + private stop(): void { + ++this.generation; + const child = this.child; + this.child = undefined; + if (child && !child.killed) child.kill(); + } + + private clearRetry(): void { + if (!this.retryTimer) return; + clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } + + private scheduleRetry(): void { + if (this.disposed || process.platform !== 'win32' || this.retryTimer) return; + + const delayMs = this.retryDelayMs; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, MAX_RETRY_DELAY_MS); + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined; + this.start(); + }, delayMs); + this.retryTimer.unref?.(); + this.log(`Retrying global screenshot hotkey registration after ${delayMs}ms`); + } + + private handleLine(line: string): void { + if (!line) return; + if (line === 'TRIGGER') { + this.onTrigger(); + return; + } + if (line === 'READY') { + this.retryDelayMs = INITIAL_RETRY_DELAY_MS; + this.log('Global screenshot hotkey registered: Ctrl+Alt+S'); + return; + } + if (line === 'UNSUPPORTED') return; + if (line.startsWith('ERROR ')) { + this.log(`Global screenshot hotkey unavailable: ${line.slice('ERROR '.length)}`); + } + } +}