Skip to content
98 changes: 98 additions & 0 deletions python/global_hotkey.py
Original file line number Diff line number Diff line change
@@ -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())
42 changes: 42 additions & 0 deletions src/backgroundScreenshot.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
15 changes: 15 additions & 0 deletions src/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -95,6 +109,7 @@ export function activate(context: vscode.ExtensionContext): void {
}
}),
new vscode.Disposable(disposeConfigWatchers),
globalScreenshotHotkey,
);

void updateProjectReady();
Expand Down
144 changes: 144 additions & 0 deletions src/globalScreenshotHotkey.ts
Original file line number Diff line number Diff line change
@@ -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)}`);
}
}
}
Loading