|
; Clipboard Image Watcher - AutoHotkey v2 Version |
|
; Monitors clipboard for images and converts them to file paths for terminal pasting. |
|
; |
|
; Requirements: AutoHotkey v2 (https://www.autohotkey.com/) |
|
; |
|
; How to use: |
|
; 1. Install AutoHotkey v2 |
|
; 2. Double-click this script to run |
|
; 3. Take a screenshot (Win+Shift+S) |
|
; 4. The image is automatically saved and clipboard becomes the file path |
|
; 5. Paste into Claude Code terminal |
|
; |
|
; Right-click system tray icon to exit |
|
|
|
#Requires AutoHotkey v2.0 |
|
#SingleInstance Force |
|
Persistent |
|
|
|
; Configuration |
|
global WslDistro := "Ubuntu" |
|
global CacheDir := "\\wsl$\" . WslDistro . "\tmp\clipboard_images" |
|
global WslPathPrefix := "/tmp/clipboard_images/" |
|
global MaxFiles := 5 |
|
global MaxAgeMinutes := 60 |
|
global LastImageHash := "" |
|
global IsProcessing := false |
|
|
|
; Load GDI+ library at startup |
|
global hGdiplus := DllCall("LoadLibrary", "Str", "gdiplus", "Ptr") |
|
global pGdipToken := 0 |
|
|
|
; Initialize GDI+ |
|
InitGDIPlus() |
|
|
|
; Check if WSL is accessible and create cache directory |
|
wslRoot := "\\wsl$\" . WslDistro |
|
if !DirExist(wslRoot) { |
|
MsgBox("WSL (" . WslDistro . ") is not running or not accessible.`n`nPlease start WSL first, then restart this script.", "Clipboard Image Watcher - Error", 48) |
|
ExitApp() |
|
} |
|
|
|
if !DirExist(CacheDir) |
|
DirCreate(CacheDir) |
|
|
|
; Create tray menu |
|
A_TrayMenu.Delete() |
|
A_TrayMenu.Add("Open Cache Folder", (*) => Run(CacheDir)) |
|
A_TrayMenu.Add("Cleanup Now", (*) => CleanupOldFiles()) |
|
A_TrayMenu.Add() |
|
A_TrayMenu.Add("Exit", (*) => ExitScript()) |
|
|
|
; Set tray tip |
|
A_IconTip := "Clipboard Image Watcher`nWSL storage: " . WslDistro . "`nWaiting for screenshots..." |
|
|
|
; Show startup notification |
|
TrayTip("Running (WSL: " . WslDistro . ")`nAuto-detects WSL/Windows mode from active window.", "Clipboard Image Watcher", 1) |
|
|
|
; Monitor clipboard changes |
|
OnClipboardChange(ClipboardChanged) |
|
|
|
; Cleanup timer (every 5 minutes) |
|
SetTimer(CleanupOldFiles, 300000) |
|
|
|
; Initial cleanup |
|
CleanupOldFiles() |
|
|
|
return |
|
|
|
; Ctrl+V hotkey - convert path for Cursor terminals |
|
$^v:: { |
|
global WslPathPrefix, CacheDir |
|
|
|
title := WinGetTitle("A") |
|
|
|
; Only intercept in Cursor when clipboard has our cached file |
|
if InStr(title, "- Cursor") { |
|
clipContent := A_Clipboard |
|
|
|
; Check if clipboard contains a path from our cache |
|
if InStr(clipContent, CacheDir) { |
|
SplitPath(clipContent, &filename) |
|
|
|
if InStr(title, "[WSL:") { |
|
; WSL Cursor - use Linux path |
|
A_Clipboard := WslPathPrefix . filename |
|
} else { |
|
; Windows Cursor - convert CF_HDROP to text path |
|
A_Clipboard := clipContent |
|
} |
|
|
|
Send("{Ctrl down}v{Ctrl up}") |
|
|
|
; Restore file drop list after paste |
|
Sleep(100) |
|
Run("powershell -NoProfile -Command `"Set-Clipboard -Path '" . clipContent . "'`"",, "Hide") |
|
return |
|
} |
|
} |
|
|
|
; For everything else, just paste normally |
|
Send("{Ctrl down}v{Ctrl up}") |
|
} |
|
|
|
ExitScript() { |
|
ShutdownGDIPlus() |
|
ExitApp() |
|
} |
|
|
|
InitGDIPlus() { |
|
global pGdipToken |
|
si := Buffer(24, 0) |
|
NumPut("UInt", 1, si, 0) |
|
DllCall("gdiplus\GdiplusStartup", "Ptr*", &pGdipToken, "Ptr", si, "Ptr", 0) |
|
} |
|
|
|
ShutdownGDIPlus() { |
|
global pGdipToken |
|
if pGdipToken |
|
DllCall("gdiplus\GdiplusShutdown", "Ptr", pGdipToken) |
|
} |
|
|
|
ClipboardChanged(DataType) { |
|
global IsProcessing, LastImageHash, CacheDir |
|
|
|
; Prevent re-entry |
|
if IsProcessing |
|
return |
|
|
|
; Only process if clipboard contains image data |
|
if (DataType != 2) |
|
return |
|
|
|
IsProcessing := true |
|
|
|
try { |
|
; Small delay to let clipboard settle |
|
Sleep(50) |
|
|
|
; Create a unique filename |
|
timestamp := FormatTime(, "yyyyMMdd_HHmmss") |
|
filename := "clipboard_" . timestamp . "_" . Random(1000, 9999) . ".png" |
|
filepath := CacheDir "\" filename |
|
|
|
; Save clipboard image to file |
|
if SaveClipboardImage(filepath) { |
|
; Check file was created and has content |
|
if FileExist(filepath) { |
|
fileSize := FileGetSize(filepath) |
|
|
|
; Skip if same as last image (by size - simple hash) |
|
if (fileSize = LastImageHash) { |
|
try FileDelete(filepath) |
|
IsProcessing := false |
|
return |
|
} |
|
LastImageHash := fileSize |
|
|
|
; Set clipboard as file drop list |
|
RunWait("powershell -NoProfile -Command `"Set-Clipboard -Path '" . filepath . "'`"",, "Hide") |
|
|
|
; Update tray tip |
|
A_IconTip := "Clipboard Image Watcher`nReady to paste: " . filename |
|
|
|
; Cleanup old files |
|
SetTimer(() => CleanupOldFiles(), -1000) |
|
} |
|
} |
|
} catch as e { |
|
; Silently fail |
|
} |
|
|
|
IsProcessing := false |
|
} |
|
|
|
SaveClipboardImage(filepath) { |
|
global pGdipToken |
|
|
|
if !pGdipToken |
|
return false |
|
|
|
result := false |
|
hBitmap := 0 |
|
pBitmap := 0 |
|
clipboardOpened := false |
|
|
|
try { |
|
; Open clipboard |
|
loop 10 { |
|
if DllCall("OpenClipboard", "Ptr", 0) { |
|
clipboardOpened := true |
|
break |
|
} |
|
Sleep(10) |
|
} |
|
|
|
if !clipboardOpened |
|
return false |
|
|
|
; Try to get bitmap handle (CF_BITMAP = 2) |
|
hBitmap := DllCall("GetClipboardData", "UInt", 2, "Ptr") |
|
|
|
if !hBitmap { |
|
; Try CF_DIB (8) and convert |
|
hDib := DllCall("GetClipboardData", "UInt", 8, "Ptr") |
|
if hDib { |
|
hBitmap := DibToHBitmap(hDib) |
|
} |
|
} |
|
|
|
DllCall("CloseClipboard") |
|
clipboardOpened := false |
|
|
|
if !hBitmap |
|
return false |
|
|
|
; Create GDI+ bitmap from HBITMAP |
|
DllCall("gdiplus\GdipCreateBitmapFromHBITMAP", "Ptr", hBitmap, "Ptr", 0, "Ptr*", &pBitmap) |
|
|
|
if !pBitmap { |
|
DllCall("DeleteObject", "Ptr", hBitmap) |
|
return false |
|
} |
|
|
|
; Get PNG encoder CLSID |
|
encoderCLSID := Buffer(16, 0) |
|
GetEncoderCLSID("image/png", encoderCLSID) |
|
|
|
; Save to file |
|
status := DllCall("gdiplus\GdipSaveImageToFile" |
|
, "Ptr", pBitmap |
|
, "WStr", filepath |
|
, "Ptr", encoderCLSID |
|
, "Ptr", 0) |
|
|
|
result := (status = 0) |
|
|
|
} catch as e { |
|
; Error occurred |
|
} |
|
|
|
; Cleanup |
|
if clipboardOpened |
|
DllCall("CloseClipboard") |
|
if pBitmap |
|
DllCall("gdiplus\GdipDisposeImage", "Ptr", pBitmap) |
|
if hBitmap |
|
DllCall("DeleteObject", "Ptr", hBitmap) |
|
|
|
return result |
|
} |
|
|
|
DibToHBitmap(hDib) { |
|
pDib := DllCall("GlobalLock", "Ptr", hDib, "Ptr") |
|
if !pDib |
|
return 0 |
|
|
|
; Parse BITMAPINFOHEADER |
|
headerSize := NumGet(pDib, 0, "UInt") |
|
width := NumGet(pDib, 4, "Int") |
|
height := NumGet(pDib, 8, "Int") |
|
bitCount := NumGet(pDib, 14, "UShort") |
|
|
|
; Calculate pixel data offset |
|
colorTableSize := 0 |
|
if (bitCount <= 8) |
|
colorTableSize := (1 << bitCount) * 4 |
|
pixelOffset := headerSize + colorTableSize |
|
|
|
; Create device context and bitmap |
|
hDC := DllCall("GetDC", "Ptr", 0, "Ptr") |
|
hBitmap := DllCall("CreateCompatibleBitmap", "Ptr", hDC, "Int", width, "Int", Abs(height), "Ptr") |
|
|
|
if hBitmap { |
|
hMemDC := DllCall("CreateCompatibleDC", "Ptr", hDC, "Ptr") |
|
hOldBmp := DllCall("SelectObject", "Ptr", hMemDC, "Ptr", hBitmap, "Ptr") |
|
|
|
DllCall("SetDIBitsToDevice" |
|
, "Ptr", hMemDC |
|
, "Int", 0, "Int", 0 |
|
, "UInt", width, "UInt", Abs(height) |
|
, "Int", 0, "Int", 0 |
|
, "UInt", 0, "UInt", Abs(height) |
|
, "Ptr", pDib + pixelOffset |
|
, "Ptr", pDib |
|
, "UInt", 0) |
|
|
|
DllCall("SelectObject", "Ptr", hMemDC, "Ptr", hOldBmp) |
|
DllCall("DeleteDC", "Ptr", hMemDC) |
|
} |
|
|
|
DllCall("ReleaseDC", "Ptr", 0, "Ptr", hDC) |
|
DllCall("GlobalUnlock", "Ptr", hDib) |
|
|
|
return hBitmap |
|
} |
|
|
|
GetEncoderCLSID(mimeType, clsidBuffer) { |
|
numEncoders := 0 |
|
size := 0 |
|
|
|
DllCall("gdiplus\GdipGetImageEncodersSize", "UInt*", &numEncoders, "UInt*", &size) |
|
|
|
if !size || !numEncoders |
|
return false |
|
|
|
encoders := Buffer(size, 0) |
|
DllCall("gdiplus\GdipGetImageEncoders", "UInt", numEncoders, "UInt", size, "Ptr", encoders) |
|
|
|
; ImageCodecInfo structure size varies by pointer size |
|
stride := 48 + 7 * A_PtrSize |
|
|
|
Loop numEncoders { |
|
offset := (A_Index - 1) * stride |
|
pMimeType := NumGet(encoders, offset + 32 + 4 * A_PtrSize, "Ptr") |
|
|
|
if pMimeType && (StrGet(pMimeType, "UTF-16") = mimeType) { |
|
DllCall("RtlMoveMemory", "Ptr", clsidBuffer, "Ptr", encoders.Ptr + offset, "UInt", 16) |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
CleanupOldFiles() { |
|
global CacheDir, MaxFiles, MaxAgeMinutes |
|
|
|
files := [] |
|
|
|
try { |
|
Loop Files, CacheDir "\clipboard_*.png" { |
|
files.Push({path: A_LoopFilePath, time: A_LoopFileTimeModified}) |
|
} |
|
} catch { |
|
return |
|
} |
|
|
|
if !files.Length |
|
return |
|
|
|
; Sort by time (newest first) using simple bubble sort |
|
n := files.Length |
|
Loop n - 1 { |
|
i := A_Index |
|
Loop n - i { |
|
j := A_Index |
|
if (files[j].time < files[j + 1].time) { |
|
temp := files[j] |
|
files[j] := files[j + 1] |
|
files[j + 1] := temp |
|
} |
|
} |
|
} |
|
|
|
now := A_Now |
|
|
|
; Remove old files and keep only MaxFiles |
|
for i, file in files { |
|
try { |
|
fileAge := DateDiff(now, file.time, "Minutes") |
|
if (fileAge > MaxAgeMinutes) || (i > MaxFiles) { |
|
FileDelete(file.path) |
|
} |
|
} |
|
} |
|
} |