From 4f5cff7f11dbe5058c3c9a7b12bf6db6c3d1f722 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:40:22 +0800 Subject: [PATCH 01/10] feat: add Windows global screenshot hotkey helper --- python/global_hotkey.py | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 python/global_hotkey.py diff --git a/python/global_hotkey.py b/python/global_hotkey.py new file mode 100644 index 0000000..b91fefe --- /dev/null +++ b/python/global_hotkey.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import ctypes +import sys +from ctypes import wintypes + +HOTKEY_ID = 0x4F4B +WM_HOTKEY = 0x0312 +MOD_ALT = 0x0001 +MOD_CONTROL = 0x0002 +MOD_NOREPEAT = 0x4000 +VK_S = 0x53 + + +def main() -> int: + if sys.platform != "win32": + print("UNSUPPORTED", flush=True) + return 3 + + user32 = ctypes.WinDLL("user32", 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 + + 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 + + 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()) From 1cf781ef4ac5648b6cd0048c78fd6100409d167b Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:40:36 +0800 Subject: [PATCH 02/10] feat: listen for screenshot hotkey outside VS Code --- src/globalScreenshotHotkey.ts | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/globalScreenshotHotkey.ts diff --git a/src/globalScreenshotHotkey.ts b/src/globalScreenshotHotkey.ts new file mode 100644 index 0000000..3ef6a47 --- /dev/null +++ b/src/globalScreenshotHotkey.ts @@ -0,0 +1,120 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ChildProcess, spawn } from 'child_process'; + +/** + * 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; + + 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(); + } + + restart(): void { + if (this.disposed) return; + this.stop(); + this.start(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.stop(); + } + + private start(): void { + if (process.platform !== 'win32') return; + + 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 pythonPath = this.resolvePythonPath(); + const generation = ++this.generation; + let stdoutBuffer = ''; + + try { + const child = spawn(pythonPath, [scriptPath], { + 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}`); + } + }); + + 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'}`); + } + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to start global screenshot hotkey helper: ${message}`); + } + } + + private stop(): void { + ++this.generation; + const child = this.child; + this.child = undefined; + if (child && !child.killed) child.kill(); + } + + private handleLine(line: string): void { + if (!line) return; + if (line === 'TRIGGER') { + this.onTrigger(); + return; + } + if (line === 'READY') { + 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)}`); + } + } +} From 735901d0c3f4de1a3e104b1af4ece83c1d60da32 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:40:56 +0800 Subject: [PATCH 03/10] feat: enable global screenshot shortcut in VS Code --- src/entrypoint.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 606a961..8a30075 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -2,7 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { activate as activateCore, deactivate as deactivateCore } from './extension'; +import { GlobalScreenshotHotkey } from './globalScreenshotHotkey'; import { resolveProjectDir } from './projectConfig'; +import { getProjectConfig } from './screenshotCapture'; const WALKTHROUGH_ID = 'okScriptToolkit.getStarted'; const PROJECT_READY_CONTEXT = 'okScriptToolkit.projectReady'; @@ -30,6 +32,12 @@ function isSupportedProject(projectDir: string): boolean { export function activate(context: vscode.ExtensionContext): void { activateCore(context); + const globalScreenshotHotkey = new GlobalScreenshotHotkey( + context.extensionPath, + () => getProjectConfig().pythonPath, + () => { void vscode.commands.executeCommand('okScriptToolkit.screenshotToTemplate'); }, + ); + const updateProjectReady = () => vscode.commands.executeCommand( 'setContext', PROJECT_READY_CONTEXT, @@ -89,12 +97,17 @@ export function activate(context: vscode.ExtensionContext): void { void updateProjectReady(); }), vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration('okScriptToolkit.okScriptProjectPath')) { + const projectPathChanged = event.affectsConfiguration('okScriptToolkit.okScriptProjectPath'); + if (projectPathChanged) { bindConfigWatchers(); void updateProjectReady(); } + if (projectPathChanged || event.affectsConfiguration('okScriptToolkit.okScriptPython')) { + globalScreenshotHotkey.restart(); + } }), new vscode.Disposable(disposeConfigWatchers), + globalScreenshotHotkey, ); void updateProjectReady(); From a28885ed546f8c3ca5b31caf7aa5e7996c33d040 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:41:26 +0800 Subject: [PATCH 04/10] fix: stop hotkey helper with IDE process --- python/global_hotkey.py | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/python/global_hotkey.py b/python/global_hotkey.py index b91fefe..c5794fd 100644 --- a/python/global_hotkey.py +++ b/python/global_hotkey.py @@ -1,23 +1,65 @@ 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 @@ -27,12 +69,15 @@ def main() -> int: 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: From 530269227c3a85b9b7a2f2a92f61da9f67ac5ff1 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:41:43 +0800 Subject: [PATCH 05/10] fix: tie global hotkey helper to VS Code lifetime --- src/globalScreenshotHotkey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/globalScreenshotHotkey.ts b/src/globalScreenshotHotkey.ts index 3ef6a47..c53dc25 100644 --- a/src/globalScreenshotHotkey.ts +++ b/src/globalScreenshotHotkey.ts @@ -49,7 +49,7 @@ export class GlobalScreenshotHotkey { let stdoutBuffer = ''; try { - const child = spawn(pythonPath, [scriptPath], { + const child = spawn(pythonPath, [scriptPath, '--parent-pid', String(process.pid)], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); From 69edfd42dec422662b8824a6248a6fddecb6196e Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:45:41 +0800 Subject: [PATCH 06/10] refactor: keep global screenshot hotkey stable --- src/entrypoint.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 8a30075..488db58 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -97,14 +97,10 @@ export function activate(context: vscode.ExtensionContext): void { void updateProjectReady(); }), vscode.workspace.onDidChangeConfiguration((event) => { - const projectPathChanged = event.affectsConfiguration('okScriptToolkit.okScriptProjectPath'); - if (projectPathChanged) { + if (event.affectsConfiguration('okScriptToolkit.okScriptProjectPath')) { bindConfigWatchers(); void updateProjectReady(); } - if (projectPathChanged || event.affectsConfiguration('okScriptToolkit.okScriptPython')) { - globalScreenshotHotkey.restart(); - } }), new vscode.Disposable(disposeConfigWatchers), globalScreenshotHotkey, From 3e2a19b6f6d82bb311b11ddc9b99c6fcd72f1abe Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:45:53 +0800 Subject: [PATCH 07/10] refactor: avoid global hotkey re-registration races --- src/globalScreenshotHotkey.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/globalScreenshotHotkey.ts b/src/globalScreenshotHotkey.ts index c53dc25..9e4ecca 100644 --- a/src/globalScreenshotHotkey.ts +++ b/src/globalScreenshotHotkey.ts @@ -23,12 +23,6 @@ export class GlobalScreenshotHotkey { this.start(); } - restart(): void { - if (this.disposed) return; - this.stop(); - this.start(); - } - dispose(): void { if (this.disposed) return; this.disposed = true; From a7c0598e3db50d0dbc99cda79f4597ba372eca0c Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 03:55:55 +0800 Subject: [PATCH 08/10] fix: retry global screenshot hotkey registration --- src/globalScreenshotHotkey.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/globalScreenshotHotkey.ts b/src/globalScreenshotHotkey.ts index 9e4ecca..8fc742a 100644 --- a/src/globalScreenshotHotkey.ts +++ b/src/globalScreenshotHotkey.ts @@ -2,6 +2,9 @@ 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 监听器。 * @@ -13,6 +16,8 @@ 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, @@ -26,23 +31,25 @@ export class GlobalScreenshotHotkey { dispose(): void { if (this.disposed) return; this.disposed = true; + this.clearRetry(); this.stop(); } private start(): void { - if (process.platform !== 'win32') return; + 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 pythonPath = this.resolvePythonPath(); 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'], @@ -73,6 +80,7 @@ export class GlobalScreenshotHotkey { child.on('error', (error) => { if (generation === this.generation && !this.disposed) { this.log(`Failed to start global screenshot hotkey helper: ${error.message}`); + this.scheduleRetry(); } }); @@ -81,11 +89,13 @@ export class GlobalScreenshotHotkey { 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(); } } @@ -96,6 +106,25 @@ export class GlobalScreenshotHotkey { 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') { @@ -103,6 +132,7 @@ export class GlobalScreenshotHotkey { return; } if (line === 'READY') { + this.retryDelayMs = INITIAL_RETRY_DELAY_MS; this.log('Global screenshot hotkey registered: Ctrl+Alt+S'); return; } From 3b2f9341fa7dfb3b275a2625cb232c93ea999bf4 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 04:03:17 +0800 Subject: [PATCH 09/10] fix: keep global screenshot capture in background --- src/backgroundScreenshot.ts | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/backgroundScreenshot.ts 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; +} From da4914e1e144301b7e3902e83504d20d8a849c4c Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Sat, 10 Oct 2026 04:03:30 +0800 Subject: [PATCH 10/10] fix: avoid revealing VS Code on global capture --- src/entrypoint.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 488db58..82a1cb4 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -1,10 +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'; @@ -35,7 +37,11 @@ export function activate(context: vscode.ExtensionContext): void { const globalScreenshotHotkey = new GlobalScreenshotHotkey( context.extensionPath, () => getProjectConfig().pythonPath, - () => { void vscode.commands.executeCommand('okScriptToolkit.screenshotToTemplate'); }, + () => { + void captureTemplateScreenshotInBackground().then((captured) => { + if (captured) repaintAllAssetGalleries(); + }); + }, ); const updateProjectReady = () => vscode.commands.executeCommand(