From 7e2bc150f681b0b11425ad3ab3a64777143c1b37 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 18 Jan 2026 09:56:12 +0800 Subject: [PATCH 01/37] feat: initialize DeskAct core and CI - add cross-platform keyboard/mouse/display/screenshot implementations and CGO headers - add examples and deskact-tester command - add Go module and GitHub Actions build workflows --- .github/workflows/build-examples.yml | 75 + .github/workflows/build-tester.yml | 73 + .gitignore | 9 +- base/deadbeef_rand.h | 28 + base/deadbeef_rand_c.h | 23 + base/inline_keywords.h | 14 + base/microsleep.h | 31 + base/os.h | 55 + base/pubs.h | 33 + base/types.h | 68 + base/xdisplay_c.h | 63 + cgo.go | 14 + cmd/deskact-tester/main.go | 2543 +++++++++++++++++++++ defaults.go | 75 + defaults_other.go | 8 + defaults_windows.go | 8 + display.go | 98 + display_darwin.go | 212 ++ display_linux.go | 156 ++ display_windows.go | 287 +++ dpi_windows.go | 90 + examples/capture/main.go | 323 +++ examples/display/main.go | 118 + go.mod | 13 + go.sum | 13 + internal/screenshot/darwin.go | 223 ++ internal/screenshot/nix_dbus_available.go | 20 + internal/screenshot/nix_wayland.go | 97 + internal/screenshot/nix_xwindow.go | 134 ++ internal/screenshot/screenshot.go | 29 + internal/screenshot/unsupported.go | 13 + internal/screenshot/windows.go | 96 + key/keycode.h | 403 ++++ key/keycode_c.h | 9 + key/keycode_c_macos.h | 81 + key/keycode_c_windows.h | 11 + key/keycode_c_x11.h | 31 + key/keypress.h | 46 + key/keypress_c.h | 19 + key/keypress_c_macos.h | 249 ++ key/keypress_c_windows.h | 235 ++ key/keypress_c_x11.h | 183 ++ keyboard.go | 594 +++++ mouse.go | 285 +++ mouse/mouse.h | 47 + mouse/mouse_c.h | 17 + mouse/mouse_c_macos.h | 208 ++ mouse/mouse_c_windows.h | 167 ++ mouse/mouse_c_x11.h | 152 ++ screen/display_c.h | 24 + screen/display_c_macos.h | 134 ++ screen/display_c_windows.h | 215 ++ screen/display_c_x11.h | 189 ++ sleep.go | 13 + special.go | 77 + types.go | 19 + 56 files changed, 8448 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build-examples.yml create mode 100644 .github/workflows/build-tester.yml create mode 100644 base/deadbeef_rand.h create mode 100644 base/deadbeef_rand_c.h create mode 100644 base/inline_keywords.h create mode 100644 base/microsleep.h create mode 100644 base/os.h create mode 100644 base/pubs.h create mode 100644 base/types.h create mode 100644 base/xdisplay_c.h create mode 100644 cgo.go create mode 100644 cmd/deskact-tester/main.go create mode 100644 defaults.go create mode 100644 defaults_other.go create mode 100644 defaults_windows.go create mode 100644 display.go create mode 100644 display_darwin.go create mode 100644 display_linux.go create mode 100644 display_windows.go create mode 100644 dpi_windows.go create mode 100644 examples/capture/main.go create mode 100644 examples/display/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/screenshot/darwin.go create mode 100644 internal/screenshot/nix_dbus_available.go create mode 100644 internal/screenshot/nix_wayland.go create mode 100644 internal/screenshot/nix_xwindow.go create mode 100644 internal/screenshot/screenshot.go create mode 100644 internal/screenshot/unsupported.go create mode 100644 internal/screenshot/windows.go create mode 100644 key/keycode.h create mode 100644 key/keycode_c.h create mode 100644 key/keycode_c_macos.h create mode 100644 key/keycode_c_windows.h create mode 100644 key/keycode_c_x11.h create mode 100644 key/keypress.h create mode 100644 key/keypress_c.h create mode 100644 key/keypress_c_macos.h create mode 100644 key/keypress_c_windows.h create mode 100644 key/keypress_c_x11.h create mode 100644 keyboard.go create mode 100644 mouse.go create mode 100644 mouse/mouse.h create mode 100644 mouse/mouse_c.h create mode 100644 mouse/mouse_c_macos.h create mode 100644 mouse/mouse_c_windows.h create mode 100644 mouse/mouse_c_x11.h create mode 100644 screen/display_c.h create mode 100644 screen/display_c_macos.h create mode 100644 screen/display_c_windows.h create mode 100644 screen/display_c_x11.h create mode 100644 sleep.go create mode 100644 special.go create mode 100644 types.go diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml new file mode 100644 index 0000000..c0fbbc8 --- /dev/null +++ b/.github/workflows/build-examples.yml @@ -0,0 +1,75 @@ +name: Build Examples + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - goos: darwin + goarch: amd64 + runner: macos-15-intel + - goos: darwin + goarch: arm64 + runner: macos-15 + - goos: windows + goarch: amd64 + runner: windows-latest + - goos: linux + goarch: amd64 + runner: ubuntu-latest + + runs-on: ${{ matrix.runner }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + + - name: Install Linux dependencies + if: matrix.goos == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libx11-dev \ + libxtst-dev \ + libxinerama-dev \ + libpng-dev \ + xclip \ + xsel + + - name: Build examples + shell: bash + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 1 + CGO_LDFLAGS_ALLOW: "-weak_framework|ScreenCaptureKit" + CGO_CFLAGS: ${{ matrix.goos == 'darwin' && '-mmacosx-version-min=11.0' || '' }} + CGO_LDFLAGS: ${{ matrix.goos == 'darwin' && '-mmacosx-version-min=11.0' || '' }} + run: | + suffix="" + if [ "${{ matrix.goos }}" = "windows" ]; then + suffix=".exe" + fi + go build -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture + go build -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: examples-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + capture-${{ matrix.goos }}-${{ matrix.goarch }}* + display-${{ matrix.goos }}-${{ matrix.goarch }}* + retention-days: 30 diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml new file mode 100644 index 0000000..7e9a281 --- /dev/null +++ b/.github/workflows/build-tester.yml @@ -0,0 +1,73 @@ +name: Build Tester + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - goos: darwin + goarch: amd64 + runner: macos-15-intel + - goos: darwin + goarch: arm64 + runner: macos-15 + - goos: windows + goarch: amd64 + runner: windows-latest + - goos: linux + goarch: amd64 + runner: ubuntu-latest + + runs-on: ${{ matrix.runner }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + + - name: Install Linux dependencies + if: matrix.goos == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libx11-dev \ + libxtst-dev \ + libxinerama-dev \ + libpng-dev \ + xclip \ + xsel + + - name: Build deskact-tester + shell: bash + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 1 + CGO_LDFLAGS_ALLOW: "-weak_framework|ScreenCaptureKit" + CGO_CFLAGS: ${{ matrix.goos == 'darwin' && '-mmacosx-version-min=11.0' || '' }} + CGO_LDFLAGS: ${{ matrix.goos == 'darwin' && '-mmacosx-version-min=11.0' || '' }} + run: | + suffix="" + if [ "${{ matrix.goos }}" = "windows" ]; then + suffix=".exe" + fi + go build -v -o "deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./cmd/deskact-tester + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}* + retention-days: 30 diff --git a/.gitignore b/.gitignore index aaadf73..2c0d931 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,10 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ +.deskact-tester/ +.deskact-tester/* + +.refer +.refer/* \ No newline at end of file diff --git a/base/deadbeef_rand.h b/base/deadbeef_rand.h new file mode 100644 index 0000000..02e3d4e --- /dev/null +++ b/base/deadbeef_rand.h @@ -0,0 +1,28 @@ +#ifndef DEADBEEF_RAND_H +#define DEADBEEF_RAND_H + +#include + +#define DEADBEEF_MAX UINT32_MAX +/* Dead Beef Random Number Generator From: http://inglorion.net/software/deadbeef_rand */ + +/* Generates a random number between 0 and DEADBEEF_MAX. */ +uint32_t deadbeef_rand(void); + +/* Seeds with the given integer. */ +void deadbeef_srand(uint32_t x); + +/* Generates seed from the current time. */ +uint32_t deadbeef_generate_seed(void); + +/* Seeds with the above function. */ +#define deadbeef_srand_time() deadbeef_srand(deadbeef_generate_seed()) + +/* Returns random double in the range [a, b).*/ +#define DEADBEEF_UNIFORM(a, b) \ + ((a) + (deadbeef_rand() / (((double)DEADBEEF_MAX / (b - a) + 1)))) + +/* Returns random integer in the range [a, b).*/ +#define DEADBEEF_RANDRANGE(a, b) (uint32_t)DEADBEEF_UNIFORM(a, b) + +#endif /* DEADBEEF_RAND_H */ diff --git a/base/deadbeef_rand_c.h b/base/deadbeef_rand_c.h new file mode 100644 index 0000000..1a0173f --- /dev/null +++ b/base/deadbeef_rand_c.h @@ -0,0 +1,23 @@ +#include "deadbeef_rand.h" +#include + +static uint32_t deadbeef_seed; +static uint32_t deadbeef_beef = 0xdeadbeef; + +uint32_t deadbeef_rand(void) { + deadbeef_seed = (deadbeef_seed << 7) ^ ((deadbeef_seed >> 25) + deadbeef_beef); + deadbeef_beef = (deadbeef_beef << 7) ^ ((deadbeef_beef >> 25) + 0xdeadbeef); + return deadbeef_seed; +} + +void deadbeef_srand(uint32_t x) { + deadbeef_seed = x; + deadbeef_beef = 0xdeadbeef; +} + +/* Taken directly from the documentation: http://inglorion.net/software/cstuff/deadbeef_rand/ */ +uint32_t deadbeef_generate_seed(void) { + uint32_t t = (uint32_t)time(NULL); + uint32_t c = (uint32_t)clock(); + return (t << 24) ^ (c << 11) ^ t ^ (size_t) &c; +} diff --git a/base/inline_keywords.h b/base/inline_keywords.h new file mode 100644 index 0000000..db0cc6d --- /dev/null +++ b/base/inline_keywords.h @@ -0,0 +1,14 @@ +#pragma once + +/* A complicated, portable model for declaring inline functions in header files. */ +#if !defined(H_INLINE) + #if defined(__GNUC__) + #define H_INLINE static __inline__ __attribute__((always_inline)) + #elif defined(__MWERKS__) || defined(__cplusplus) + #define H_INLINE static inline + #elif defined(_MSC_VER) + #define H_INLINE static __inline + #elif TARGET_OS_WIN32 + #define H_INLINE static __inline__ + #endif +#endif /* H_INLINE */ diff --git a/base/microsleep.h b/base/microsleep.h new file mode 100644 index 0000000..2c08258 --- /dev/null +++ b/base/microsleep.h @@ -0,0 +1,31 @@ +#pragma once +#ifndef MICROSLEEP_H +#define MICROSLEEP_H + +#include "os.h" +#include "inline_keywords.h" + +// todo: removed +#if !defined(IS_WINDOWS) + /* Make sure nanosleep gets defined even when using C89. */ + #if !defined(__USE_POSIX199309) || !__USE_POSIX199309 + #define __USE_POSIX199309 1 + #endif + + #include /* For nanosleep() */ +#endif + +/* A more widely supported alternative to usleep(), based on Sleep() in Windows and nanosleep() */ +H_INLINE void microsleep(double milliseconds) { +#if defined(IS_WINDOWS) + Sleep((DWORD)milliseconds); /* (Unfortunately truncated to a 32-bit integer.) */ +#else + /* Technically, nanosleep() is not an ANSI function */ + struct timespec sleepytime; + sleepytime.tv_sec = milliseconds / 1000; + sleepytime.tv_nsec = (milliseconds - (sleepytime.tv_sec * 1000)) * 1000000; + nanosleep(&sleepytime, NULL); +#endif +} + +#endif /* MICROSLEEP_H */ diff --git a/base/os.h b/base/os.h new file mode 100644 index 0000000..2274a41 --- /dev/null +++ b/base/os.h @@ -0,0 +1,55 @@ +#pragma once +#ifndef OS_H +#define OS_H + +#if !defined(IS_MACOSX) && defined(__APPLE__) && defined(__MACH__) + #define IS_MACOSX +#endif /* IS_MACOSX */ + +#if !defined(IS_WINDOWS) && (defined(WIN32) || defined(_WIN32) || \ + defined(__WIN32__) || defined(__WINDOWS__) || defined(__CYGWIN__)) + #define IS_WINDOWS +#endif /* IS_WINDOWS */ + +#if !defined(USE_X11) && !defined(NUSE_X11) && !defined(IS_MACOSX) && !defined(IS_WINDOWS) + #define USE_X11 +#endif /* USE_X11 */ + +#if defined(IS_WINDOWS) + #define STRICT /* Require use of exact types. */ + #define WIN32_LEAN_AND_MEAN 1 /* Speed up compilation. */ + #include +#elif !defined(IS_MACOSX) && !defined(USE_X11) + #error "Sorry, this platform isn't supported yet!" +#endif + +/* Interval to align by for large buffers (e.g. bitmaps). Must be a power of 2. */ +#ifndef BYTE_ALIGN + #define BYTE_ALIGN 4 /* Bytes to align pixel buffers to. */ + /* #include */ + /* #define BYTE_ALIGN (sizeof(size_t)) */ +#endif /* BYTE_ALIGN */ + +#if BYTE_ALIGN == 0 + /* No alignment needed. */ + #define ADD_PADDING(width) (width) +#else + /* Aligns given width to padding. */ + #define ADD_PADDING(width) (BYTE_ALIGN + (((width) - 1) & ~(BYTE_ALIGN - 1))) +#endif + +#if defined(IS_WINDOWS) + #if defined (_WIN64) + #define DeskAct_64 + #else + #define DeskAct_32 + #endif +#else + #if defined (__x86_64__) + #define DeskAct_64 + #else + #define DeskAct_32 + #endif +#endif + +#endif /* OS_H */ diff --git a/base/pubs.h b/base/pubs.h new file mode 100644 index 0000000..1ad716b --- /dev/null +++ b/base/pubs.h @@ -0,0 +1,33 @@ +#if defined(IS_WINDOWS) + BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData) { + uint32_t *count = (uint32_t*)dwData; + (*count)++; + return TRUE; + } + + typedef struct{ + HWND hWnd; + DWORD dwPid; + }WNDINFO; + + BOOL CALLBACK EnumWindowsProc(HWND hWnd, LPARAM lParam){ + WNDINFO* pInfo = (WNDINFO*)lParam; + DWORD dwProcessId = 0; + GetWindowThreadProcessId(hWnd, &dwProcessId); + + if (dwProcessId == pInfo->dwPid) { + pInfo->hWnd = hWnd; + return FALSE; + } + return TRUE; + } + + HWND GetHwndByPid(DWORD dwProcessId) { + WNDINFO info = {0}; + info.hWnd = NULL; + info.dwPid = dwProcessId; + EnumWindows(EnumWindowsProc, (LPARAM)&info); + + return info.hWnd; + } +#endif \ No newline at end of file diff --git a/base/types.h b/base/types.h new file mode 100644 index 0000000..252da4f --- /dev/null +++ b/base/types.h @@ -0,0 +1,68 @@ +#pragma once +#ifndef TYPES_H +#define TYPES_H + +#include "os.h" +#include "inline_keywords.h" /* For H_INLINE */ +#include +#include +#include + +/* Some generic, cross-platform types. */ +#ifdef DeskAct_64 + typedef int64_t intptr; + typedef uint64_t uintptr; +#else + typedef int32_t intptr; + typedef uint32_t uintptr; // Unsigned pointer integer +#endif + +struct _MMPointInt32 { + int32_t x; + int32_t y; +}; +typedef struct _MMPointInt32 MMPointInt32; + +struct _MMSizeInt32 { + int32_t w; + int32_t h; +}; +typedef struct _MMSizeInt32 MMSizeInt32; + +struct _MMRectInt32 { + MMPointInt32 origin; + MMSizeInt32 size; +}; +typedef struct _MMRectInt32 MMRectInt32; + +H_INLINE MMPointInt32 MMPointInt32Make(int32_t x, int32_t y) { + MMPointInt32 point; + point.x = x; + point.y = y; + return point; +} + +H_INLINE MMSizeInt32 MMSizeInt32Make(int32_t w, int32_t h) { + MMSizeInt32 size; + size.w = w; + size.h = h; + return size; +} + +H_INLINE MMRectInt32 MMRectInt32Make(int32_t x, int32_t y, int32_t w, int32_t h) { + MMRectInt32 rect; + rect.origin = MMPointInt32Make(x, y); + rect.size = MMSizeInt32Make(w, h); + return rect; +} + +#define MMPointZero MMPointInt32Make(0, 0) + +#if defined(IS_MACOSX) + #define CGPointFromMMPointInt32(p) CGPointMake((CGFloat)(p).x, (CGFloat)(p).y) + #define MMPointInt32FromCGPoint(p) MMPointInt32Make((int32_t)(p).x, (int32_t)(p).y) +#elif defined(IS_WINDOWS) + #define MMPointInt32FromPOINT(p) MMPointInt32Make((int32_t)p.x, (int32_t)p.y) +#endif + +#endif /* TYPES_H */ diff --git a/base/xdisplay_c.h b/base/xdisplay_c.h new file mode 100644 index 0000000..c6b5223 --- /dev/null +++ b/base/xdisplay_c.h @@ -0,0 +1,63 @@ +#ifndef XDISPLAY_C_H +#define XDISPLAY_C_H + +#include /* For fputs() */ +#include /* For atexit() */ +#include /* For strdup() */ +#include + +static Display *mainDisplay = NULL; +static int registered = 0; + +static char *displayName = NULL; +static int hasDisplayNameChanged = 0; + +static void XCloseMainDisplay(void) { + if (mainDisplay != NULL) { + XCloseDisplay(mainDisplay); + mainDisplay = NULL; + } +} + +static Display *XGetMainDisplay(void) { + /* Close the display if displayName has changed */ + if (hasDisplayNameChanged) { + XCloseMainDisplay(); + hasDisplayNameChanged = 0; + } + + if (mainDisplay == NULL) { + /* First try the user set displayName */ + mainDisplay = XOpenDisplay(displayName); + + /* Then try using environment variable DISPLAY */ + if (mainDisplay == NULL && displayName != NULL) { + mainDisplay = XOpenDisplay(NULL); + } + + /* Fall back to the most likely :0.0*/ + if (mainDisplay == NULL) { + mainDisplay = XOpenDisplay(":0.0"); + } + + if (mainDisplay == NULL) { + fputs("Could not open main display\n", stderr); + } else if (!registered) { + atexit(&XCloseMainDisplay); + registered = 1; + } + } + + return mainDisplay; +} + +static void setXDisplay(char *name) { + displayName = strdup(name); + hasDisplayNameChanged = 1; +} + +static char *getXDisplay(void) { + return displayName; +} + +#endif /* XDISPLAY_C_H */ diff --git a/cgo.go b/cgo.go new file mode 100644 index 0000000..f38df6c --- /dev/null +++ b/cgo.go @@ -0,0 +1,14 @@ +package deskact + +/* +#cgo darwin CFLAGS: -x objective-c -Wno-deprecated-declarations +#cgo darwin LDFLAGS: -framework Cocoa -framework CoreFoundation -framework IOKit +#cgo darwin LDFLAGS: -framework Carbon -framework OpenGL +#cgo darwin LDFLAGS: -weak_framework ScreenCaptureKit + +#cgo linux CFLAGS: -I/usr/src +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst + +#cgo windows LDFLAGS: -lgdi32 -luser32 +*/ +import "C" diff --git a/cmd/deskact-tester/main.go b/cmd/deskact-tester/main.go new file mode 100644 index 0000000..d5313f8 --- /dev/null +++ b/cmd/deskact-tester/main.go @@ -0,0 +1,2543 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "image" + "image/png" + "log" + "math/rand" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + deskact "github.com/PekingSpades/DeskAct" +) + +const ( + stateIdle = "idle" + stateRunning = "running" + stateDone = "done" + + stepPending = "pending" + stepRunning = "running" + stepPass = "pass" + stepFail = "fail" +) + +const indexHTML = ` + + + + + DeskAct Auto Tester + + + +
+
+

DeskAct Auto Tester

+

Run a local browser-driven test that simulates keyboard input, mouse activity, and screen capture.

+
+ +
+
+ + Idle +
+

Keep this tab focused. The run will auto-type, click, and drag inside the panels below.

+
+
+ +
+
+ Keyboard + Mouse + Screenshot +
+ +
+

Keyboard Test

+
+ + +
+
+
+ Expected text + - +
+
+ Text match + pending +
+
+ Hold key + - +
+
+ Hold status + pending +
+
+ Numpad sequence + - +
+
+ Numpad status + pending +
+
+

The input stays focused while the test types, holds a modifier key, and sends numpad digits.

+
+ +
+

Mouse Test

+

Random targets should receive single, double, and triple clicks. Drag from S to E.

+
+
+
+
    +
    + +
    +

    Screenshot Test

    +

    The capture should include the colored blocks below.

    +
    +
    A
    +
    B
    +
    C
    +
    D
    +
    E
    +
    F
    +
    G
    +
    H
    +
    I
    +
    J
    +
    K
    +
    L
    +
    +
    +
    + +
    +
    +

    Progress

    +
      +
      +
      +

      Live Log

      +
      
      +      
      +
      + + +
      + + + + +` + +type StepResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +type RunStatus struct { + RunID string `json:"run_id,omitempty"` + State string `json:"state"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Success bool `json:"success,omitempty"` + Steps []StepResult `json:"steps,omitempty"` + Logs []string `json:"logs,omitempty"` + ScreenshotURL string `json:"screenshot_url,omitempty"` + ScreenshotSizeBytes int64 `json:"screenshot_size_bytes,omitempty"` + KeyboardReport *KeyboardReport `json:"keyboard_report,omitempty"` + MouseReport *MouseReport `json:"mouse_report,omitempty"` + Error string `json:"error,omitempty"` +} + +type MouseArea struct { + Left float64 `json:"left"` + Top float64 `json:"top"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type ClientInfo struct { + ViewportWidth int `json:"viewport_width"` + ViewportHeight int `json:"viewport_height"` + DevicePixelRatio float64 `json:"device_pixel_ratio"` + MouseArea MouseArea `json:"mouse_area"` + KeyboardInput MouseArea `json:"keyboard_input"` + ViewportOffsetX float64 `json:"viewport_offset_x"` + ViewportOffsetY float64 `json:"viewport_offset_y"` + UserAgent string `json:"user_agent,omitempty"` +} + +type StartRequest struct { + Client ClientInfo `json:"client"` +} + +type KeyboardPlan struct { + Text string `json:"text"` + HoldKey string `json:"hold_key"` + HoldMinMs int `json:"hold_min_ms"` + NumpadSequence string `json:"numpad_sequence"` +} + +type MouseTargetPlan struct { + ID string `json:"id"` + XNorm float64 `json:"x_norm"` + YNorm float64 `json:"y_norm"` + Clicks int `json:"clicks"` +} + +type DragPlan struct { + StartXNorm float64 `json:"start_x_norm"` + StartYNorm float64 `json:"start_y_norm"` + EndXNorm float64 `json:"end_x_norm"` + EndYNorm float64 `json:"end_y_norm"` +} + +type RunPlan struct { + Keyboard KeyboardPlan `json:"keyboard"` + MouseTargets []MouseTargetPlan `json:"mouse_targets"` + Drag DragPlan `json:"drag"` +} + +type KeyboardReport struct { + Text string `json:"text"` + TextMatch bool `json:"text_match"` + HoldKey string `json:"hold_key"` + HoldDurationMs int64 `json:"hold_duration_ms"` + HoldPass bool `json:"hold_pass"` + NumpadSequence string `json:"numpad_sequence"` + NumpadPass bool `json:"numpad_pass"` + Pass bool `json:"pass"` + Errors []string `json:"errors,omitempty"` +} + +type MouseTargetReport struct { + ID string `json:"id"` + ExpectedClicks int `json:"expected_clicks"` + ActualClicks int `json:"actual_clicks"` + Hit bool `json:"hit"` + ClickX float64 `json:"click_x"` + ClickY float64 `json:"click_y"` +} + +type DragReport struct { + StartHit bool `json:"start_hit"` + EndHit bool `json:"end_hit"` + Moved bool `json:"moved"` +} + +type MouseReport struct { + Targets []MouseTargetReport `json:"targets"` + Drag DragReport `json:"drag"` + Pass bool `json:"pass"` + Errors []string `json:"errors,omitempty"` +} + +type runState struct { + status RunStatus + screenshotPath string + client ClientInfo + plan RunPlan + keyboardReport *KeyboardReport + mouseReport *MouseReport + keyboardReady chan struct{} + mouseReady chan struct{} + keyboardReadyClosed bool + mouseReadyClosed bool +} + +type runManager struct { + mu sync.Mutex + current *runState +} + +var manager = &runManager{} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/api/start", handleStart) + mux.HandleFunc("/api/status", handleStatus) + mux.HandleFunc("/api/plan", handlePlan) + mux.HandleFunc("/api/ready", handleReady) + mux.HandleFunc("/api/report/keyboard", handleKeyboardReport) + mux.HandleFunc("/api/report/mouse", handleMouseReport) + mux.HandleFunc("/result/", handleResult) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatal(err) + } + addr := listener.Addr().String() + log.Printf("DeskAct tester running at http://%s", addr) + + if err := http.Serve(listener, mux); err != nil { + log.Fatal(err) + } +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +func handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req StartRequest + if r.Body != nil { + dec := json.NewDecoder(r.Body) + _ = dec.Decode(&req) + } + status, err := manager.Start(req.Client) + if err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"run_id": status.RunID}) +} + +func handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + id := r.URL.Query().Get("id") + status, err := manager.Status(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, status) +} + +func handlePlan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + id := r.URL.Query().Get("id") + plan, err := manager.Plan(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, plan) +} + +func handleReady(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req struct { + RunID string `json:"run_id"` + Step string `json:"step"` + Client *ClientInfo `json:"client,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ready payload"}) + return + } + if req.Client != nil { + if err := manager.UpdateClient(req.RunID, *req.Client); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + } + if err := manager.MarkReady(req.RunID, req.Step); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func handleKeyboardReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req struct { + RunID string `json:"run_id"` + Report KeyboardReport `json:"report"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid report"}) + return + } + if err := manager.StoreKeyboardReport(req.RunID, req.Report); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func handleMouseReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req struct { + RunID string `json:"run_id"` + Report MouseReport `json:"report"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid report"}) + return + } + if err := manager.StoreMouseReport(req.RunID, req.Report); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func handleResult(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/result/") + path, ok := manager.ScreenshotPath(id) + if !ok { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, path) +} + +func writeJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func (m *runManager) Start(client ClientInfo) (RunStatus, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current != nil && m.current.status.State == stateRunning { + return RunStatus{}, errors.New("test already running") + } + + runID := fmt.Sprintf("%d", time.Now().UnixNano()) + now := time.Now() + normalizeClientInfo(&client) + plan := buildPlan() + status := RunStatus{ + RunID: runID, + State: stateRunning, + StartedAt: &now, + Steps: []StepResult{ + {Name: "Keyboard", Status: stepPending}, + {Name: "Mouse", Status: stepPending}, + {Name: "Screenshot", Status: stepPending}, + }, + Logs: []string{}, + } + + m.current = &runState{ + status: status, + client: client, + plan: plan, + keyboardReady: make(chan struct{}), + mouseReady: make(chan struct{}), + } + go m.run(runID) + + return cloneStatus(status), nil +} + +func (m *runManager) Status(runID string) (RunStatus, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current == nil { + return RunStatus{State: stateIdle}, nil + } + if runID != "" && m.current.status.RunID != runID { + return RunStatus{}, errors.New("run not found") + } + return cloneStatus(m.current.status), nil +} + +func (m *runManager) Plan(runID string) (RunPlan, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil { + return RunPlan{}, errors.New("run not found") + } + if runID != "" && m.current.status.RunID != runID { + return RunPlan{}, errors.New("run not found") + } + return m.current.plan, nil +} + +func (m *runManager) StoreKeyboardReport(runID string, report KeyboardReport) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return errors.New("run not found") + } + report = evaluateKeyboardReport(m.current.plan.Keyboard, report) + m.current.keyboardReport = &report + m.current.status.KeyboardReport = &report + return nil +} + +func (m *runManager) UpdateClient(runID string, client ClientInfo) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return errors.New("run not found") + } + normalizeClientInfo(&client) + m.current.client = client + return nil +} + +func (m *runManager) StoreMouseReport(runID string, report MouseReport) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return errors.New("run not found") + } + report = evaluateMouseReport(m.current.plan, report) + m.current.mouseReport = &report + m.current.status.MouseReport = &report + return nil +} + +func (m *runManager) MarkReady(runID, step string) error { + step = strings.ToLower(step) + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return errors.New("run not found") + } + switch step { + case "keyboard": + if !m.current.keyboardReadyClosed { + close(m.current.keyboardReady) + m.current.keyboardReadyClosed = true + } + case "mouse": + if !m.current.mouseReadyClosed { + close(m.current.mouseReady) + m.current.mouseReadyClosed = true + } + default: + return errors.New("unknown step") + } + return nil +} + +func (m *runManager) readyChannel(runID, step string) (chan struct{}, error) { + step = strings.ToLower(step) + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return nil, errors.New("run not found") + } + switch step { + case "keyboard": + return m.current.keyboardReady, nil + case "mouse": + return m.current.mouseReady, nil + default: + return nil, errors.New("unknown step") + } +} + +func (m *runManager) waitForReady(runID, step string, timeout time.Duration) error { + ch, err := m.readyChannel(runID, step) + if err != nil { + return err + } + select { + case <-ch: + return nil + case <-time.After(timeout): + return errors.New("frontend not ready") + } +} + +func (m *runManager) ScreenshotPath(runID string) (string, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return "", false + } + if m.current.screenshotPath == "" { + return "", false + } + return m.current.screenshotPath, true +} + +func (m *runManager) runContext(runID string) (ClientInfo, RunPlan) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return ClientInfo{}, RunPlan{} + } + return m.current.client, m.current.plan +} + +func (m *runManager) run(runID string) { + m.appendLog(runID, "Test started") + time.Sleep(350 * time.Millisecond) + + keySettings := deskact.DefaultKeyboardSettings() + mouseSettings := deskact.DefaultMouseSettings() + display := detectActiveDisplay() + if display != nil { + m.appendLog(runID, fmt.Sprintf("Display %d: %dx%d", display.Index(), display.Width(), display.Height())) + } else { + m.appendLog(runID, "No display detected") + } + + m.runStep(runID, 0, "Keyboard test", func() error { + client, plan := m.runContext(runID) + return runKeyboardTest(runID, plan.Keyboard, client, display, keySettings) + }) + m.runStep(runID, 1, "Mouse test", func() error { + client, plan := m.runContext(runID) + return runMouseTest(runID, plan, client, display, mouseSettings) + }) + m.runStep(runID, 2, "Screenshot test", func() error { + return runScreenshotTest(runID, display) + }) + + m.finish(runID) + m.appendLog(runID, "Test completed") +} + +func (m *runManager) runStep(runID string, index int, label string, fn func() error) { + m.setStepStatus(runID, index, stepRunning, "") + m.appendLog(runID, label+" started") + start := time.Now() + err := fn() + duration := time.Since(start) + if err != nil { + m.setStepStatus(runID, index, stepFail, err.Error()) + m.appendLog(runID, label+" failed: "+err.Error()) + } else { + m.setStepStatus(runID, index, stepPass, "") + m.appendLog(runID, label+" passed") + } + m.setStepDuration(runID, index, duration) +} + +func (m *runManager) setStepStatus(runID string, index int, status string, errMsg string) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return + } + if index < 0 || index >= len(m.current.status.Steps) { + return + } + m.current.status.Steps[index].Status = status + m.current.status.Steps[index].Error = errMsg +} + +func (m *runManager) setStepDuration(runID string, index int, duration time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return + } + if index < 0 || index >= len(m.current.status.Steps) { + return + } + m.current.status.Steps[index].DurationMs = duration.Milliseconds() +} + +func (m *runManager) appendLog(runID string, message string) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return + } + entry := time.Now().Format("15:04:05") + " " + message + m.current.status.Logs = append(m.current.status.Logs, entry) +} + +func (m *runManager) finish(runID string) { + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil || m.current.status.RunID != runID { + return + } + now := time.Now() + m.current.status.State = stateDone + m.current.status.EndedAt = &now + success := true + for _, step := range m.current.status.Steps { + if step.Status != stepPass { + success = false + break + } + } + m.current.status.Success = success +} + +func cloneStatus(status RunStatus) RunStatus { + cloned := status + if status.Steps != nil { + cloned.Steps = make([]StepResult, len(status.Steps)) + copy(cloned.Steps, status.Steps) + } + if status.Logs != nil { + cloned.Logs = make([]string, len(status.Logs)) + copy(cloned.Logs, status.Logs) + } + if status.KeyboardReport != nil { + kr := *status.KeyboardReport + cloned.KeyboardReport = &kr + } + if status.MouseReport != nil { + mr := *status.MouseReport + cloned.MouseReport = &mr + } + return cloned +} + +func runKeyboardTest(runID string, plan KeyboardPlan, client ClientInfo, display *deskact.Display, settings deskact.KeyboardSettings) error { + if err := manager.waitForReady(runID, "keyboard", 10*time.Second); err != nil { + return err + } + updatedClient, updatedPlan := manager.runContext(runID) + client = updatedClient + plan = updatedPlan.Keyboard + if display == nil { + return errors.New("no display detected") + } + if plan.Text == "" { + plan.Text = "DeskAct keyboard test OK" + } + if plan.HoldKey == "" { + plan.HoldKey = "Shift" + } + if plan.HoldMinMs <= 0 { + plan.HoldMinMs = 400 + } + if plan.NumpadSequence == "" { + plan.NumpadSequence = "123" + } + + if err := focusKeyboardInput(client, display, deskact.DefaultMouseSettings()); err != nil { + manager.appendLog(runID, "Keyboard focus warning: "+err.Error()) + } + time.Sleep(150 * time.Millisecond) + + manager.appendLog(runID, "Typing expected text") + deskact.Type(plan.Text, 0, settings) + time.Sleep(150 * time.Millisecond) + + holdKey := strings.ToLower(plan.HoldKey) + manager.appendLog(runID, fmt.Sprintf("Holding key %s", plan.HoldKey)) + if err := deskact.KeyToggle(holdKey, true, nil, settings); err != nil { + return err + } + time.Sleep(time.Duration(plan.HoldMinMs+200) * time.Millisecond) + if err := deskact.KeyToggle(holdKey, false, nil, settings); err != nil { + return err + } + + manager.appendLog(runID, "Typing numpad sequence "+plan.NumpadSequence) + for _, ch := range plan.NumpadSequence { + key := "num" + string(ch) + if err := deskact.KeyTap(key, nil, settings); err != nil { + return err + } + time.Sleep(80 * time.Millisecond) + } + + report, err := manager.waitForKeyboardReport(runID, 9*time.Second) + if err != nil { + return err + } + if !report.Pass { + return reportError("keyboard validation failed", report.Errors) + } + return nil +} + +func runMouseTest(runID string, plan RunPlan, client ClientInfo, display *deskact.Display, settings deskact.MouseSettings) error { + if display == nil { + return errors.New("no display detected") + } + if err := manager.waitForReady(runID, "mouse", 12*time.Second); err != nil { + return err + } + updatedClient, updatedPlan := manager.runContext(runID) + client = updatedClient + plan = updatedPlan + if err := primeMouseArea(client, display, settings); err != nil { + manager.appendLog(runID, "Mouse focus warning: "+err.Error()) + } + time.Sleep(350 * time.Millisecond) + width, height := display.Width(), display.Height() + if width <= 0 || height <= 0 { + return fmt.Errorf("invalid display size: %dx%d", width, height) + } + if len(plan.MouseTargets) == 0 { + return errors.New("mouse plan not available") + } + tolerance := maxInt(6, minInt(26, maxInt(width, height)/140)) + normalizeClientInfo(&client) + + for _, target := range plan.MouseTargets { + pt := targetToPhysicalPoint(target, client, display) + manager.appendLog(runID, fmt.Sprintf("Mouse target %s (%dx) at %d,%d", target.ID, target.Clicks, pt.X, pt.Y)) + if err := verifyMouseMove(display, pt, settings, tolerance); err != nil { + return fmt.Errorf("move to %s: %w", target.ID, err) + } + var err error + if target.Clicks <= 1 { + err = deskact.Click("left", false, settings) + } else { + err = deskact.MultiClick("left", target.Clicks, settings) + } + if err != nil { + return err + } + time.Sleep(150 * time.Millisecond) + if err := verifyMouseAt(display, pt, tolerance); err != nil { + return fmt.Errorf("click verify %s: %w", target.ID, err) + } + } + + dragStart := targetPointFromNorm(plan.Drag.StartXNorm, plan.Drag.StartYNorm, client, display) + dragEnd := targetPointFromNorm(plan.Drag.EndXNorm, plan.Drag.EndYNorm, client, display) + manager.appendLog(runID, fmt.Sprintf("Drag from %d,%d to %d,%d", dragStart.X, dragStart.Y, dragEnd.X, dragEnd.Y)) + if err := verifyMouseMove(display, dragStart, settings, tolerance); err != nil { + return fmt.Errorf("drag start: %w", err) + } + _ = deskact.Toggle("left", true, false, settings) + ok := display.MoveSmooth(dragEnd.X, dragEnd.Y, settings) + time.Sleep(150 * time.Millisecond) + _ = deskact.Toggle("left", false, false, settings) + time.Sleep(120 * time.Millisecond) + if !ok { + return errors.New("drag move failed") + } + if err := verifyMouseAt(display, dragEnd, tolerance); err != nil { + return fmt.Errorf("drag end: %w", err) + } + + report, err := manager.waitForMouseReport(runID, 12*time.Second) + if err != nil { + return err + } + if !report.Pass { + return reportError("mouse validation failed", report.Errors) + } + return nil +} + +func runScreenshotTest(runID string, display *deskact.Display) error { + if display == nil { + return errors.New("no display detected") + } + width, height := display.Width(), display.Height() + if width <= 0 || height <= 0 { + return fmt.Errorf("invalid display size: %dx%d", width, height) + } + time.Sleep(300 * time.Millisecond) + img, err := display.CaptureRect(0, 0, width, height, deskact.DefaultCaptureOptions()) + if err != nil { + return err + } + outputDir := filepath.Join(".", ".deskact-tester") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return err + } + outputPath := filepath.Join(outputDir, "run-"+runID+".png") + size, err := savePNG(img, outputPath) + if err != nil { + return err + } + manager.mu.Lock() + if manager.current != nil && manager.current.status.RunID == runID { + manager.current.screenshotPath = outputPath + manager.current.status.ScreenshotURL = "/result/" + runID + manager.current.status.ScreenshotSizeBytes = size + } + manager.mu.Unlock() + return nil +} + +func savePNG(img image.Image, path string) (int64, error) { + file, err := os.Create(path) + if err != nil { + return 0, err + } + defer file.Close() + if err := png.Encode(file, img); err != nil { + return 0, err + } + info, err := file.Stat() + if err != nil { + return 0, err + } + return info.Size(), nil +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} + +func verifyMouseMove(display *deskact.Display, target deskact.Point, settings deskact.MouseSettings, tolerance int) error { + display.Move(target.X, target.Y, settings) + time.Sleep(140 * time.Millisecond) + return verifyMouseAt(display, target, tolerance) +} + +func verifyMouseAt(display *deskact.Display, target deskact.Point, tolerance int) error { + x, y, ok := display.MouseLocation() + if !ok { + return errors.New("mouse not on target display") + } + dx := absInt(x - target.X) + dy := absInt(y - target.Y) + if dx > tolerance || dy > tolerance { + return fmt.Errorf("expected (%d,%d) got (%d,%d) tol=%d", target.X, target.Y, x, y, tolerance) + } + return nil +} + +func (m *runManager) waitForKeyboardReport(runID string, timeout time.Duration) (KeyboardReport, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + m.mu.Lock() + if m.current != nil && m.current.status.RunID == runID && m.current.keyboardReport != nil { + report := *m.current.keyboardReport + m.mu.Unlock() + return report, nil + } + m.mu.Unlock() + time.Sleep(80 * time.Millisecond) + } + return KeyboardReport{}, errors.New("keyboard report timeout") +} + +func (m *runManager) waitForMouseReport(runID string, timeout time.Duration) (MouseReport, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + m.mu.Lock() + if m.current != nil && m.current.status.RunID == runID && m.current.mouseReport != nil { + report := *m.current.mouseReport + m.mu.Unlock() + return report, nil + } + m.mu.Unlock() + time.Sleep(80 * time.Millisecond) + } + return MouseReport{}, errors.New("mouse report timeout") +} + +func normalizeClientInfo(client *ClientInfo) { + if client.DevicePixelRatio <= 0 { + client.DevicePixelRatio = 1 + } + if client.ViewportWidth < 0 { + client.ViewportWidth = 0 + } + if client.ViewportHeight < 0 { + client.ViewportHeight = 0 + } + if client.MouseArea.Width < 0 { + client.MouseArea.Width = 0 + } + if client.MouseArea.Height < 0 { + client.MouseArea.Height = 0 + } + if client.MouseArea.Width == 0 && client.ViewportWidth > 0 { + client.MouseArea.Width = float64(client.ViewportWidth) + } + if client.MouseArea.Height == 0 && client.ViewportHeight > 0 { + client.MouseArea.Height = float64(client.ViewportHeight) + } + if client.MouseArea.Left < 0 { + client.MouseArea.Left = 0 + } + if client.MouseArea.Top < 0 { + client.MouseArea.Top = 0 + } + if client.KeyboardInput.Width < 0 { + client.KeyboardInput.Width = 0 + } + if client.KeyboardInput.Height < 0 { + client.KeyboardInput.Height = 0 + } + if client.KeyboardInput.Left < 0 { + client.KeyboardInput.Left = 0 + } + if client.KeyboardInput.Top < 0 { + client.KeyboardInput.Top = 0 + } + if client.ViewportOffsetX < 0 { + client.ViewportOffsetX = 0 + } + if client.ViewportOffsetY < 0 { + client.ViewportOffsetY = 0 + } +} + +func buildPlan() RunPlan { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + targets := make([]MouseTargetPlan, 0, 3) + clicks := []int{1, 2, 3} + for i, count := range clicks { + x, y := randomTarget(rng, targets) + targets = append(targets, MouseTargetPlan{ + ID: fmt.Sprintf("t%d", i+1), + XNorm: x, + YNorm: y, + Clicks: count, + }) + } + return RunPlan{ + Keyboard: KeyboardPlan{ + Text: "DeskAct keyboard test OK", + HoldKey: "Shift", + HoldMinMs: 400, + NumpadSequence: "123", + }, + MouseTargets: targets, + Drag: DragPlan{ + StartXNorm: 0.2, + StartYNorm: 0.6, + EndXNorm: 0.8, + EndYNorm: 0.6, + }, + } +} + +func randomTarget(rng *rand.Rand, existing []MouseTargetPlan) (float64, float64) { + const min = 0.15 + const max = 0.85 + for i := 0; i < 12; i++ { + x := min + rng.Float64()*(max-min) + y := min + rng.Float64()*(max-min) + if farEnough(x, y, existing) { + return x, y + } + } + return min + rng.Float64()*(max-min), min + rng.Float64()*(max-min) +} + +func farEnough(x, y float64, existing []MouseTargetPlan) bool { + const minDistSq = 0.04 + for _, t := range existing { + dx := x - t.XNorm + dy := y - t.YNorm + if dx*dx+dy*dy < minDistSq { + return false + } + } + return true +} + +func targetToPhysicalPoint(target MouseTargetPlan, client ClientInfo, display *deskact.Display) deskact.Point { + return targetPointFromNorm(target.XNorm, target.YNorm, client, display) +} + +func targetPointFromNorm(xNorm, yNorm float64, client ClientInfo, display *deskact.Display) deskact.Point { + dpr := client.DevicePixelRatio + if dpr <= 0 { + dpr = 1 + } + area := client.MouseArea + if area.Width <= 0 || area.Height <= 0 { + area = MouseArea{ + Left: 0, + Top: 0, + Width: float64(display.Width()), + Height: float64(display.Height()), + } + dpr = 1 + } + cssX := client.ViewportOffsetX + area.Left + area.Width*xNorm + cssY := client.ViewportOffsetY + area.Top + area.Height*yNorm + physX := int(cssX * dpr) + physY := int(cssY * dpr) + if physX < 0 { + physX = 0 + } + if physY < 0 { + physY = 0 + } + if physX >= display.Width() { + physX = display.Width() - 1 + } + if physY >= display.Height() { + physY = display.Height() - 1 + } + return deskact.Point{X: physX, Y: physY} +} + +func rectCenterPoint(rect MouseArea, dpr float64, offsetX, offsetY float64, display *deskact.Display) deskact.Point { + if dpr <= 0 { + dpr = 1 + } + cssX := offsetX + rect.Left + rect.Width*0.5 + cssY := offsetY + rect.Top + rect.Height*0.5 + physX := int(cssX * dpr) + physY := int(cssY * dpr) + if physX < 0 { + physX = 0 + } + if physY < 0 { + physY = 0 + } + if physX >= display.Width() { + physX = display.Width() - 1 + } + if physY >= display.Height() { + physY = display.Height() - 1 + } + return deskact.Point{X: physX, Y: physY} +} + +func focusKeyboardInput(client ClientInfo, display *deskact.Display, settings deskact.MouseSettings) error { + rect := client.KeyboardInput + if rect.Width <= 0 || rect.Height <= 0 { + return errors.New("keyboard input bounds missing") + } + pt := rectCenterPoint(rect, client.DevicePixelRatio, client.ViewportOffsetX, client.ViewportOffsetY, display) + display.Move(pt.X, pt.Y, settings) + time.Sleep(120 * time.Millisecond) + if err := deskact.Click("left", false, settings); err != nil { + return err + } + time.Sleep(120 * time.Millisecond) + return deskact.Click("left", false, settings) +} + +func primeMouseArea(client ClientInfo, display *deskact.Display, settings deskact.MouseSettings) error { + area := client.MouseArea + if area.Width <= 0 || area.Height <= 0 { + return errors.New("mouse area bounds missing") + } + pt := targetPointFromNorm(0.5, 0.5, client, display) + display.Move(pt.X, pt.Y, settings) + time.Sleep(120 * time.Millisecond) + return deskact.Click("left", false, settings) +} + +func evaluateKeyboardReport(plan KeyboardPlan, report KeyboardReport) KeyboardReport { + if plan.Text == "" { + plan.Text = "DeskAct keyboard test OK" + } + if plan.HoldKey == "" { + plan.HoldKey = "Shift" + } + if plan.HoldMinMs <= 0 { + plan.HoldMinMs = 400 + } + if plan.NumpadSequence == "" { + plan.NumpadSequence = "123" + } + report.HoldKey = plan.HoldKey + report.TextMatch = report.Text == plan.Text + report.HoldPass = report.HoldDurationMs >= int64(plan.HoldMinMs) + report.NumpadPass = report.NumpadSequence == plan.NumpadSequence + report.Pass = report.TextMatch && report.HoldPass && report.NumpadPass + if !report.TextMatch { + report.Errors = append(report.Errors, "typed text mismatch") + } + if !report.HoldPass { + report.Errors = append(report.Errors, "hold duration too short") + } + if !report.NumpadPass { + report.Errors = append(report.Errors, "numpad sequence mismatch") + } + return report +} + +func evaluateMouseReport(plan RunPlan, report MouseReport) MouseReport { + report.Pass = true + if len(plan.MouseTargets) == 0 { + report.Pass = false + report.Errors = append(report.Errors, "mouse plan missing") + return report + } + reportTargets := make(map[string]MouseTargetReport) + for _, t := range report.Targets { + reportTargets[t.ID] = t + } + for _, target := range plan.MouseTargets { + rt, ok := reportTargets[target.ID] + if !ok { + report.Pass = false + report.Errors = append(report.Errors, "missing target "+target.ID) + continue + } + if !rt.Hit || rt.ActualClicks != target.Clicks { + report.Pass = false + report.Errors = append(report.Errors, "target "+target.ID+" mismatch") + } + } + if !(report.Drag.StartHit && report.Drag.EndHit && report.Drag.Moved) { + report.Pass = false + report.Errors = append(report.Errors, "drag mismatch") + } + return report +} + +func reportError(prefix string, errs []string) error { + if len(errs) == 0 { + return errors.New(prefix) + } + return errors.New(prefix + ": " + strings.Join(errs, "; ")) +} + +func detectActiveDisplay() *deskact.Display { + options := deskact.DefaultDisplayOptions() + displays := deskact.AllDisplays(options) + absX, absY := deskact.Location() + for _, display := range displays { + if display.Contains(absX, absY) { + return display + } + } + return deskact.MainDisplay(options) +} diff --git a/defaults.go b/defaults.go new file mode 100644 index 0000000..aa53a8a --- /dev/null +++ b/defaults.go @@ -0,0 +1,75 @@ +package deskact + +const ( + DefaultMouseSleep = 0 + DefaultKeySleep = 10 + DefaultTypeDelay = 0 + DefaultTypeUTFDelay = 7 + DefaultMoveSmoothLow = 1.0 + DefaultMoveSmoothHigh = 3.0 + DefaultMoveSmoothDelay = 1 + DefaultScrollDelay = 10 + DefaultScrollSmoothCount = 5 + DefaultScrollSmoothInterval = 100 + DefaultScrollSmoothX = 0 + DefaultDPIAware = false +) + +// MouseSettings defines timing and smoothing parameters for mouse actions. +// Use DefaultMouseSettings() to start from the built-in defaults. +type MouseSettings struct { + Sleep int + MoveSmoothLow float64 + MoveSmoothHigh float64 + MoveSmoothDelay int + ScrollDelay int + ScrollSmoothCount int + ScrollSmoothInterval int + ScrollSmoothX int +} + +// DefaultMouseSettings returns the default mouse settings. +func DefaultMouseSettings() MouseSettings { + return MouseSettings{ + Sleep: DefaultMouseSleep, + MoveSmoothLow: DefaultMoveSmoothLow, + MoveSmoothHigh: DefaultMoveSmoothHigh, + MoveSmoothDelay: DefaultMoveSmoothDelay, + ScrollDelay: DefaultScrollDelay, + ScrollSmoothCount: DefaultScrollSmoothCount, + ScrollSmoothInterval: DefaultScrollSmoothInterval, + ScrollSmoothX: DefaultScrollSmoothX, + } +} + +// KeyboardSettings defines timing parameters for keyboard actions. +// Use DefaultKeyboardSettings() to start from the built-in defaults. +type KeyboardSettings struct { + Sleep int + TypeDelay int + TypeUTFDelay int +} + +// DefaultKeyboardSettings returns the default keyboard settings. +func DefaultKeyboardSettings() KeyboardSettings { + return KeyboardSettings{ + Sleep: DefaultKeySleep, + TypeDelay: DefaultTypeDelay, + TypeUTFDelay: DefaultTypeUTFDelay, + } +} + +// DisplayOptions defines platform-specific display options. +type DisplayOptions struct { + DPIAware bool +} + +// CaptureOptions defines platform-specific capture options. +type CaptureOptions struct { + WaylandToken uint64 +} + +// DefaultCaptureOptions returns the default capture options. +func DefaultCaptureOptions() CaptureOptions { + return CaptureOptions{} +} diff --git a/defaults_other.go b/defaults_other.go new file mode 100644 index 0000000..a3612b5 --- /dev/null +++ b/defaults_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package deskact + +// DefaultDisplayOptions returns the default display options. +func DefaultDisplayOptions() DisplayOptions { + return DisplayOptions{DPIAware: DefaultDPIAware} +} diff --git a/defaults_windows.go b/defaults_windows.go new file mode 100644 index 0000000..4ded041 --- /dev/null +++ b/defaults_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package deskact + +// DefaultDisplayOptions returns the default display options. +func DefaultDisplayOptions() DisplayOptions { + return DisplayOptions{DPIAware: IsDPIAware()} +} diff --git a/display.go b/display.go new file mode 100644 index 0000000..256f0ec --- /dev/null +++ b/display.go @@ -0,0 +1,98 @@ +package deskact + +// PlatformInfo is an interface for platform-specific display information. +type PlatformInfo interface { + // Platform returns the platform name (e.g., "windows", "darwin", "linux"). + Platform() string +} + +// Display represents a physical display/monitor. +type Display struct { + id int // Platform-specific display ID + index int // Display index (0 is main display) + isMain bool // Whether this is the main display + origin Rect // Bounds in platform's coordinate system (position + logical size) + size Size // Physical pixel size + scale float64 // Scale factor (physical pixels / virtual points) + platform PlatformInfo // Platform-specific information (nil if not set) + dpiAware bool // Windows-only DPI awareness flag +} + +// DisplayInfo contains detailed information about a display. +type DisplayInfo struct { + ID int // Platform-specific ID + Index int // Index + IsMain bool // Whether this is the main display + Origin Rect // Bounds in platform's coordinate system + Size Size // Physical pixel size + ScaleFactor float64 // Scale factor +} + +// ID returns the platform-specific display identifier. +func (d *Display) ID() int { + return d.id +} + +// Index returns the display index (0 is the main display). +func (d *Display) Index() int { + return d.index +} + +// IsMain returns whether this is the main display. +func (d *Display) IsMain() bool { + return d.isMain +} + +// Origin returns the display bounds in platform's coordinate system. +func (d *Display) Origin() Rect { + return d.origin +} + +// Size returns the display physical pixel size. +func (d *Display) Size() Size { + return d.size +} + +// Width returns the display physical pixel width. +func (d *Display) Width() int { + return d.size.W +} + +// Height returns the display physical pixel height. +func (d *Display) Height() int { + return d.size.H +} + +// Scale returns the scale factor (physical pixels / virtual points). +func (d *Display) Scale() float64 { + return d.scale +} + +// GetPlatformInfo returns the platform-specific information. +func (d *Display) GetPlatformInfo() PlatformInfo { + return d.platform +} + +// Info returns detailed display information. +func (d *Display) Info() DisplayInfo { + return DisplayInfo{ + ID: d.id, + Index: d.index, + IsMain: d.isMain, + Origin: d.origin, + Size: d.size, + ScaleFactor: d.scale, + } +} + +// Platform-specific methods: +// - ToAbsolute(x, y int) (absX, absY int) +// - ToRelative(absX, absY int) (x, y int, ok bool) +// - Contains(absX, absY int) bool +// - Move(x, y int, settings MouseSettings) +// - MoveSmooth(x, y int, settings MouseSettings) bool +// - Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) +// - DragTo(x, y int, button string, settings MouseSettings) +// - CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) +// - MouseLocation() (x, y int, ok bool) +// - ContainsMouse() bool diff --git a/display_darwin.go b/display_darwin.go new file mode 100644 index 0000000..97d69b4 --- /dev/null +++ b/display_darwin.go @@ -0,0 +1,212 @@ +//go:build darwin +// +build darwin + +package deskact + +/* +#cgo darwin CFLAGS: -x objective-c -Wno-deprecated-declarations +#cgo darwin LDFLAGS: -framework Cocoa -framework CoreFoundation -framework IOKit +#cgo darwin LDFLAGS: -framework Carbon -framework OpenGL +#cgo darwin LDFLAGS: -weak_framework ScreenCaptureKit + +#include "screen/display_c.h" +*/ +import "C" + +import ( + "image" + + "github.com/PekingSpades/DeskAct/internal/screenshot" +) + +// MainDisplay returns the main display. +func MainDisplay(options DisplayOptions) *Display { + info := C.getMainDisplay() + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + // Calculate logical size for origin. + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, + } +} + +// AllDisplays returns all displays. +func AllDisplays(options DisplayOptions) []*Display { + count := int(C.getDisplayCount()) + if count <= 0 { + return nil + } + + var cDisplays [32]C.DisplayInfoC + actualCount := int(C.getAllDisplays(&cDisplays[0], C.int32_t(32))) + + displays := make([]*Display, actualCount) + for i := 0; i < actualCount; i++ { + info := cDisplays[i] + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + displays[i] = &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, + } + } + + return displays +} + +// DisplayAt returns the display at the specified index. +// Returns nil if the index is invalid. +func DisplayAt(index int, options DisplayOptions) *Display { + if index < 0 { + return nil + } + + info := C.getDisplayAt(C.int32_t(index)) + if info.w == 0 && info.h == 0 { + return nil + } + + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, + } +} + +// DisplayCount returns the number of displays. +func DisplayCount() int { + return int(C.getDisplayCount()) +} + +// ToAbsolute converts physical pixel coordinates relative to this display +// to virtual absolute coordinates (for use with macOS APIs). +func (d *Display) ToAbsolute(physX, physY int) (virtAbsX, virtAbsY int) { + if d.scale > 0 { + // Convert physical to virtual relative, then add virtual origin. + virtAbsX = d.origin.X + int(float64(physX)/d.scale) + virtAbsY = d.origin.Y + int(float64(physY)/d.scale) + } else { + virtAbsX = d.origin.X + physX + virtAbsY = d.origin.Y + physY + } + return +} + +// ToRelative converts virtual absolute coordinates to physical pixel coordinates +// relative to this display. +func (d *Display) ToRelative(virtAbsX, virtAbsY int) (physX, physY int, ok bool) { + if !d.Contains(virtAbsX, virtAbsY) { + return 0, 0, false + } + // Calculate virtual relative coordinates. + virtRelX := virtAbsX - d.origin.X + virtRelY := virtAbsY - d.origin.Y + // Convert to physical coordinates. + if d.scale > 0 { + physX = int(float64(virtRelX) * d.scale) + physY = int(float64(virtRelY) * d.scale) + } else { + physX = virtRelX + physY = virtRelY + } + return physX, physY, true +} + +// Contains checks if the specified virtual absolute coordinate is within this display. +func (d *Display) Contains(virtAbsX, virtAbsY int) bool { + return virtAbsX >= d.origin.X && virtAbsX < d.origin.X+d.origin.W && + virtAbsY >= d.origin.Y && virtAbsY < d.origin.Y+d.origin.H +} + +// Move moves the mouse to the specified physical pixel coordinates relative to this display. +func (d *Display) Move(physX, physY int, settings MouseSettings) { + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + Move(virtAbsX, virtAbsY, settings) +} + +// MoveSmooth smoothly moves the mouse to the specified physical pixel coordinates +// relative to this display. +func (d *Display) MoveSmooth(physX, physY int, settings MouseSettings) bool { + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + return MoveSmooth(virtAbsX, virtAbsY, settings) +} + +// Drag drags the mouse from one position to another on this display. +func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { + d.Move(fromX, fromY, settings) + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(toX, toY, settings) + _ = Toggle(button, false, false, settings) +} + +// DragTo drags the mouse from the current position to the specified position on this display. +func (d *Display) DragTo(physX, physY int, button string, settings MouseSettings) { + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(physX, physY, settings) + _ = Toggle(button, false, false, settings) +} + +// CaptureRect captures a rectangular region of this display. +func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (*image.RGBA, error) { + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + // Convert size from physical to virtual for the capture API. + virtW := w + virtH := h + if d.scale > 0 { + virtW = int(float64(w) / d.scale) + virtH = int(float64(h) / d.scale) + } + return screenshot.Capture(virtAbsX, virtAbsY, virtW, virtH, options.WaylandToken) +} + +// MouseLocation gets the mouse location in physical pixels relative to this display. +func (d *Display) MouseLocation() (physX, physY int, ok bool) { + virtAbsX, virtAbsY := Location() + return d.ToRelative(virtAbsX, virtAbsY) +} + +// ContainsMouse checks if the mouse is on this display. +func (d *Display) ContainsMouse() bool { + virtAbsX, virtAbsY := Location() + return d.Contains(virtAbsX, virtAbsY) +} diff --git a/display_linux.go b/display_linux.go new file mode 100644 index 0000000..1002a9e --- /dev/null +++ b/display_linux.go @@ -0,0 +1,156 @@ +//go:build linux +// +build linux + +package deskact + +/* +#cgo linux CFLAGS: -I/usr/src +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst -lXinerama + +#include "screen/display_c.h" +*/ +import "C" + +import ( + "image" + + "github.com/PekingSpades/DeskAct/internal/screenshot" +) + +// MainDisplay returns the main display. +func MainDisplay(options DisplayOptions) *Display { + info := C.getMainDisplay() + physW, physH := int(info.w), int(info.h) + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, + } +} + +// AllDisplays returns all displays. +func AllDisplays(options DisplayOptions) []*Display { + count := int(C.getDisplayCount()) + if count <= 0 { + return nil + } + + var cDisplays [32]C.DisplayInfoC + actualCount := int(C.getAllDisplays(&cDisplays[0], C.int32_t(32))) + + displays := make([]*Display, actualCount) + for i := 0; i < actualCount; i++ { + info := cDisplays[i] + physW, physH := int(info.w), int(info.h) + displays[i] = &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, + } + } + + return displays +} + +// DisplayAt returns the display at the specified index. +// Returns nil if the index is invalid. +func DisplayAt(index int, options DisplayOptions) *Display { + if index < 0 { + return nil + } + + info := C.getDisplayAt(C.int32_t(index)) + if info.w == 0 && info.h == 0 { + return nil + } + + physW, physH := int(info.w), int(info.h) + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, + } +} + +// DisplayCount returns the number of displays. +func DisplayCount() int { + return int(C.getDisplayCount()) +} + +// ToAbsolute converts coordinates relative to this display to absolute coordinates. +func (d *Display) ToAbsolute(x, y int) (absX, absY int) { + return d.origin.X + x, d.origin.Y + y +} + +// ToRelative converts absolute coordinates to coordinates relative to this display. +func (d *Display) ToRelative(absX, absY int) (x, y int, ok bool) { + if !d.Contains(absX, absY) { + return 0, 0, false + } + return absX - d.origin.X, absY - d.origin.Y, true +} + +// Contains checks if the specified absolute coordinate is within this display. +func (d *Display) Contains(absX, absY int) bool { + return absX >= d.origin.X && absX < d.origin.X+d.origin.W && + absY >= d.origin.Y && absY < d.origin.Y+d.origin.H +} + +// Move moves the mouse to the specified coordinates relative to this display. +func (d *Display) Move(x, y int, settings MouseSettings) { + absX, absY := d.ToAbsolute(x, y) + Move(absX, absY, settings) +} + +// MoveSmooth smoothly moves the mouse to the specified coordinates relative to this display. +func (d *Display) MoveSmooth(x, y int, settings MouseSettings) bool { + absX, absY := d.ToAbsolute(x, y) + return MoveSmooth(absX, absY, settings) +} + +// Drag drags the mouse from one position to another on this display. +func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { + d.Move(fromX, fromY, settings) + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(toX, toY, settings) + _ = Toggle(button, false, false, settings) +} + +// DragTo drags the mouse from the current position to the specified position on this display. +func (d *Display) DragTo(x, y int, button string, settings MouseSettings) { + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(x, y, settings) + _ = Toggle(button, false, false, settings) +} + +// CaptureRect captures a rectangular region of this display. +func (d *Display) CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) { + absX, absY := d.ToAbsolute(x, y) + return screenshot.Capture(absX, absY, w, h, options.WaylandToken) +} + +// MouseLocation gets the mouse location relative to this display. +func (d *Display) MouseLocation() (x, y int, ok bool) { + absX, absY := Location() + return d.ToRelative(absX, absY) +} + +// ContainsMouse checks if the mouse is on this display. +func (d *Display) ContainsMouse() bool { + absX, absY := Location() + return d.Contains(absX, absY) +} diff --git a/display_windows.go b/display_windows.go new file mode 100644 index 0000000..01a755e --- /dev/null +++ b/display_windows.go @@ -0,0 +1,287 @@ +//go:build windows +// +build windows + +package deskact + +/* +#cgo windows LDFLAGS: -lgdi32 -luser32 + +#include "screen/display_c.h" +*/ +import "C" + +import ( + "errors" + "image" + + "github.com/PekingSpades/DeskAct/internal/screenshot" +) + +// WindowsPlatformInfo contains Windows-specific display information. +type WindowsPlatformInfo struct { + // PhysicalOrigin is the top-left corner in physical pixel coordinates. + PhysicalOrigin Point +} + +// Platform returns the platform name. +func (w *WindowsPlatformInfo) Platform() string { + return "windows" +} + +// MainDisplay returns the main display. +func MainDisplay(options DisplayOptions) *Display { + info := C.getMainDisplay() + dpiAware := options.DPIAware + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + // Calculate logical size. + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + // Always store physical origin for capture operations. + physicalOrigin := Point{X: int(info.x), Y: int(info.y)} + + var origin Rect + if dpiAware { + // DPI aware: use physical coordinates and size. + origin = Rect{ + Point: Point{X: int(info.x), Y: int(info.y)}, + Size: Size{W: physW, H: physH}, + } + } else { + // DPI unaware: use virtual coordinates and logical size. + origin = Rect{ + Point: Point{X: int(info.vx), Y: int(info.vy)}, + Size: Size{W: logicalW, H: logicalH}, + } + } + + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, + platform: &WindowsPlatformInfo{ + PhysicalOrigin: physicalOrigin, + }, + } +} + +// AllDisplays returns all displays. +func AllDisplays(options DisplayOptions) []*Display { + count := int(C.getDisplayCount()) + if count <= 0 { + return nil + } + + var cDisplays [32]C.DisplayInfoC + actualCount := int(C.getAllDisplays(&cDisplays[0], C.int32_t(32))) + + displays := make([]*Display, actualCount) + for i := 0; i < actualCount; i++ { + info := cDisplays[i] + dpiAware := options.DPIAware + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + // Always store physical origin for capture operations. + physicalOrigin := Point{X: int(info.x), Y: int(info.y)} + + var origin Rect + if dpiAware { + origin = Rect{ + Point: Point{X: int(info.x), Y: int(info.y)}, + Size: Size{W: physW, H: physH}, + } + } else { + origin = Rect{ + Point: Point{X: int(info.vx), Y: int(info.vy)}, + Size: Size{W: logicalW, H: logicalH}, + } + } + + displays[i] = &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, + platform: &WindowsPlatformInfo{ + PhysicalOrigin: physicalOrigin, + }, + } + } + + return displays +} + +// DisplayAt returns the display at the specified index. +// Returns nil if the index is invalid. +func DisplayAt(index int, options DisplayOptions) *Display { + if index < 0 { + return nil + } + + info := C.getDisplayAt(C.int32_t(index)) + if info.w == 0 && info.h == 0 { + return nil + } + + dpiAware := options.DPIAware + scale := float64(info.scale) + physW, physH := int(info.w), int(info.h) + + logicalW, logicalH := physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } + + // Always store physical origin for capture operations. + physicalOrigin := Point{X: int(info.x), Y: int(info.y)} + + var origin Rect + if dpiAware { + origin = Rect{ + Point: Point{X: int(info.x), Y: int(info.y)}, + Size: Size{W: physW, H: physH}, + } + } else { + origin = Rect{ + Point: Point{X: int(info.vx), Y: int(info.vy)}, + Size: Size{W: logicalW, H: logicalH}, + } + } + + return &Display{ + id: int(info.handle), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, + platform: &WindowsPlatformInfo{ + PhysicalOrigin: physicalOrigin, + }, + } +} + +// DisplayCount returns the number of displays. +func DisplayCount() int { + return int(C.getDisplayCount()) +} + +// ToAbsolute converts physical pixel coordinates relative to this display +// to absolute coordinates suitable for mouse APIs. +func (d *Display) ToAbsolute(physX, physY int) (absX, absY int) { + if d.dpiAware { + // DPI aware: origin is physical, coordinates are physical. + return d.origin.X + physX, d.origin.Y + physY + } + // DPI unaware: origin is virtual, convert physical to virtual. + if d.scale > 0 { + absX = d.origin.X + int(float64(physX)/d.scale) + absY = d.origin.Y + int(float64(physY)/d.scale) + } else { + absX = d.origin.X + physX + absY = d.origin.Y + physY + } + return +} + +// ToRelative converts absolute coordinates from mouse APIs to physical pixel +// coordinates relative to this display. +func (d *Display) ToRelative(absX, absY int) (physX, physY int, ok bool) { + if !d.Contains(absX, absY) { + return 0, 0, false + } + if d.dpiAware { + // DPI aware: coordinates are physical. + return absX - d.origin.X, absY - d.origin.Y, true + } + // DPI unaware: coordinates are virtual, convert to physical. + virtRelX := absX - d.origin.X + virtRelY := absY - d.origin.Y + if d.scale > 0 { + physX = int(float64(virtRelX) * d.scale) + physY = int(float64(virtRelY) * d.scale) + } else { + physX = virtRelX + physY = virtRelY + } + return physX, physY, true +} + +// Contains checks if the specified absolute coordinate is within this display. +func (d *Display) Contains(absX, absY int) bool { + return absX >= d.origin.X && absX < d.origin.X+d.origin.W && + absY >= d.origin.Y && absY < d.origin.Y+d.origin.H +} + +// Move moves the mouse to the specified coordinates relative to this display. +func (d *Display) Move(x, y int, settings MouseSettings) { + absX, absY := d.ToAbsolute(x, y) + Move(absX, absY, settings) +} + +// MoveSmooth smoothly moves the mouse to the specified coordinates relative to this display. +func (d *Display) MoveSmooth(x, y int, settings MouseSettings) bool { + absX, absY := d.ToAbsolute(x, y) + return MoveSmooth(absX, absY, settings) +} + +// Drag drags the mouse from one position to another on this display. +func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { + d.Move(fromX, fromY, settings) + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(toX, toY, settings) + _ = Toggle(button, false, false, settings) +} + +// DragTo drags the mouse from the current position to the specified position on this display. +func (d *Display) DragTo(x, y int, button string, settings MouseSettings) { + _ = Toggle(button, true, false, settings) + MilliSleep(50) + d.MoveSmooth(x, y, settings) + _ = Toggle(button, false, false, settings) +} + +// CaptureRect captures a rectangular region of this display. +func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (*image.RGBA, error) { + pi, ok := d.platform.(*WindowsPlatformInfo) + if !ok { + return nil, errors.New("invalid platform info") + } + absX := pi.PhysicalOrigin.X + physX + absY := pi.PhysicalOrigin.Y + physY + return screenshot.Capture(absX, absY, w, h, options.WaylandToken) +} + +// MouseLocation gets the mouse location relative to this display. +func (d *Display) MouseLocation() (x, y int, ok bool) { + absX, absY := Location() + return d.ToRelative(absX, absY) +} + +// ContainsMouse checks if the mouse is on this display. +func (d *Display) ContainsMouse() bool { + absX, absY := Location() + return d.Contains(absX, absY) +} diff --git a/dpi_windows.go b/dpi_windows.go new file mode 100644 index 0000000..df496f9 --- /dev/null +++ b/dpi_windows.go @@ -0,0 +1,90 @@ +//go:build windows +// +build windows + +package deskact + +import ( + "syscall" + "unsafe" +) + +// DPI awareness constants. +const ( + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ^uintptr(3) // -4 + DPI_AWARENESS_CONTEXT_UNAWARE = ^uintptr(0) // -1 + PROCESS_PER_MONITOR_DPI_AWARE = 2 + + // Error codes. + E_ACCESSDENIED = 0x80070005 +) + +// IsDPIAware returns whether the process is DPI aware. +func IsDPIAware() bool { + user32 := syscall.NewLazyDLL("user32.dll") + if getProc := user32.NewProc("GetThreadDpiAwarenessContext"); getProc.Find() == nil { + ctx, _, _ := getProc.Call() + if ctx != 0 { + if eqProc := user32.NewProc("AreDpiAwarenessContextsEqual"); eqProc.Find() == nil { + isUnaware, _, _ := eqProc.Call(ctx, DPI_AWARENESS_CONTEXT_UNAWARE) + if isUnaware == 0 { + return true + } + return false + } + } + } + + shcore := syscall.NewLazyDLL("shcore.dll") + if getProc := shcore.NewProc("GetProcessDpiAwareness"); getProc.Find() == nil { + var awareness uint32 + ret, _, _ := getProc.Call(0, uintptr(unsafe.Pointer(&awareness))) + if ret == 0 && awareness >= 1 { + return true + } + } + + return false +} + +// InitDPIAwareness sets the process DPI awareness to Per-Monitor V2. +func InitDPIAwareness() bool { + user32 := syscall.NewLazyDLL("user32.dll") + + // Try SetProcessDpiAwarenessContext first (Windows 10 1703+). + if proc := user32.NewProc("SetProcessDpiAwarenessContext"); proc.Find() == nil { + ret, _, _ := proc.Call(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + if ret != 0 { + return true // Success + } + // Failed - might be already set or access denied. + if IsDPIAware() { + return true + } + } + + // Fallback to SetProcessDpiAwareness (Windows 8.1+). + shcore := syscall.NewLazyDLL("shcore.dll") + if proc := shcore.NewProc("SetProcessDpiAwareness"); proc.Find() == nil { + ret, _, _ := proc.Call(PROCESS_PER_MONITOR_DPI_AWARE) + // S_OK = 0, E_ACCESSDENIED means already set. + if ret == 0 || ret == E_ACCESSDENIED { + if IsDPIAware() { + return true + } + if ret == 0 { + return true + } + } + } + + // Fallback to SetProcessDPIAware (Vista+). + if proc := user32.NewProc("SetProcessDPIAware"); proc.Find() == nil { + ret, _, _ := proc.Call() + if ret != 0 { + return true + } + } + + // All methods failed - process is DPI unaware. + return false +} diff --git a/examples/capture/main.go b/examples/capture/main.go new file mode 100644 index 0000000..5f88254 --- /dev/null +++ b/examples/capture/main.go @@ -0,0 +1,323 @@ +package main + +import ( + "bufio" + "fmt" + "image" + "image/color" + "image/png" + "os" + "runtime" + "strings" + + deskact "github.com/PekingSpades/DeskAct" + + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Capture Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + // deskact.InitDPIAwareness() + displayOptions := deskact.DefaultDisplayOptions() + captureOptions := deskact.DefaultCaptureOptions() + + displays := deskact.AllDisplays(displayOptions) + count := len(displays) + fmt.Printf("\nTotal displays: %d\n", count) + fmt.Println("----------------------------------------") + + for _, d := range displays { + info := d.Info() + fmt.Printf("Display #%d\n", info.Index) + fmt.Printf(" ID: %d\n", info.ID) + fmt.Printf(" IsMain: %v\n", info.IsMain) + fmt.Printf(" Origin: {X: %d, Y: %d, W: %d, H: %d}\n", + info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H) + fmt.Printf(" Size: {W: %d, H: %d}\n", info.Size.W, info.Size.H) + fmt.Printf(" Scale: %.2f\n", info.ScaleFactor) + fmt.Println("----------------------------------------") + } + + if count == 0 { + fmt.Println("No displays detected.") + return + } + + var minX, minY, maxX, maxY int + for i, d := range displays { + info := d.Info() + x := info.Origin.X + y := info.Origin.Y + w := info.Origin.W + h := info.Origin.H + + if i == 0 { + minX, minY = x, y + maxX, maxY = x+w, y+h + } else { + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x+w > maxX { + maxX = x + w + } + if y+h > maxY { + maxY = y + h + } + } + } + + overviewWidth := maxX - minX + overviewHeight := maxY - minY + fmt.Printf("\nOverview canvas: %dx%d (offset: %d, %d)\n", + overviewWidth, overviewHeight, minX, minY) + + axisMargin := 50 + canvasWidth := overviewWidth + axisMargin + canvasHeight := overviewHeight + axisMargin + + overview := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) + + bgColor := color.RGBA{R: 40, G: 40, B: 40, A: 255} + draw.Draw(overview, overview.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) + + drawAxes(overview, axisMargin, canvasWidth, canvasHeight, minX, minY, maxX, maxY) + + fmt.Println("\nCapturing displays...") + for _, d := range displays { + info := d.Info() + idx := info.Index + + img, err := d.CaptureRect(0, 0, info.Size.W, info.Size.H, captureOptions) + if err != nil { + fmt.Printf(" Display #%d: capture failed: %v\n", idx, err) + continue + } + + filename := fmt.Sprintf("display_%d.png", idx) + if err := savePNG(img, filename); err != nil { + fmt.Printf(" Display #%d: save failed: %v\n", idx, err) + continue + } + fmt.Printf(" Display #%d: saved to %s (%dx%d)\n", + idx, filename, img.Bounds().Dx(), img.Bounds().Dy()) + + posX := info.Origin.X - minX + axisMargin + posY := info.Origin.Y - minY + axisMargin + destW := info.Origin.W + destH := info.Origin.H + + destRect := image.Rect(posX, posY, posX+destW, posY+destH) + draw.CatmullRom.Scale(overview, destRect, img, img.Bounds(), draw.Over, nil) + + drawOriginLabel(overview, posX, posY, info.Origin.X, info.Origin.Y) + drawDisplayInfo(overview, posX, posY, destW, destH, info) + } + + overviewFile := "display_overview.png" + if err := savePNG(overview, overviewFile); err != nil { + fmt.Printf("\nFailed to save overview: %v\n", err) + } else { + fmt.Printf("\nOverview saved to %s (%dx%d)\n", + overviewFile, canvasWidth, canvasHeight) + } + + fmt.Println("\n========================================") + fmt.Println("Done!") + fmt.Println("========================================") + + var logBuilder strings.Builder + logBuilder.WriteString("DeskAct Capture Example\n") + logBuilder.WriteString(fmt.Sprintf("Go version: %s\n", runtime.Version())) + logBuilder.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) + logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) + for _, d := range displays { + info := d.Info() + logBuilder.WriteString(fmt.Sprintf("Display #%d: ID=%d, IsMain=%v, Origin=(%d,%d,%d,%d), Size=%dx%d, Scale=%.2f\n", + info.Index, info.ID, info.IsMain, info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H, info.Size.W, info.Size.H, info.ScaleFactor)) + } + logBuilder.WriteString(fmt.Sprintf("Overview canvas: %dx%d\n", canvasWidth, canvasHeight)) + + fmt.Println("\nPress 's' to save log and exit, or 'e' to exit directly:") + reader := bufio.NewReader(os.Stdin) + for { + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input == "s" { + if err := os.WriteFile("capture_log.txt", []byte(logBuilder.String()), 0644); err != nil { + fmt.Printf("Failed to save log: %v\n", err) + } else { + fmt.Println("Log saved to capture_log.txt") + } + break + } else if input == "e" { + break + } + fmt.Println("Press 's' to save log and exit, or 'e' to exit directly:") + } +} + +func savePNG(img image.Image, path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + return png.Encode(file, img) +} + +func drawDisplayInfo(img *image.RGBA, displayX, displayY, displayW, displayH int, info deskact.DisplayInfo) { + lines := []string{ + fmt.Sprintf("Display #%d", info.Index), + fmt.Sprintf("Resolution: %dx%d", info.Size.W, info.Size.H), + fmt.Sprintf("Origin: (%d, %d, %d, %d)", info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H), + fmt.Sprintf("Size: %dx%d", info.Size.W, info.Size.H), + fmt.Sprintf("Scale: %.2f", info.ScaleFactor), + } + + face := basicfont.Face7x13 + + lineHeight := 16 + padding := 10 + maxWidth := 0 + + for _, line := range lines { + w := font.MeasureString(face, line).Ceil() + if w > maxWidth { + maxWidth = w + } + } + + boxWidth := maxWidth + padding*2 + boxHeight := len(lines)*lineHeight + padding*2 + + boxX := displayX + displayW - boxWidth - padding + boxY := displayY + displayH - boxHeight - padding + + for y := boxY; y < boxY+boxHeight; y++ { + for x := boxX; x < boxX+boxWidth; x++ { + if x >= 0 && y >= 0 && x < img.Bounds().Dx() && y < img.Bounds().Dy() { + img.Set(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 180}) + } + } + } + + textColor := color.RGBA{R: 255, G: 255, B: 255, A: 255} + for i, line := range lines { + x := boxX + padding + y := boxY + padding + (i+1)*lineHeight - 3 + + drawText(img, x, y, line, face, textColor) + } +} + +func drawText(img *image.RGBA, x, y int, text string, face font.Face, col color.Color) { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(col), + Face: face, + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, + } + d.DrawString(text) +} + +func drawAxes(img *image.RGBA, margin, canvasW, canvasH, minX, minY, maxX, maxY int) { + face := basicfont.Face7x13 + axisColor := color.RGBA{R: 200, G: 200, B: 200, A: 255} + tickColor := color.RGBA{R: 150, G: 150, B: 150, A: 255} + + for y := margin; y < canvasH; y++ { + img.Set(margin-1, y, axisColor) + img.Set(margin, y, axisColor) + } + + for x := margin; x < canvasW; x++ { + img.Set(x, margin-1, axisColor) + img.Set(x, margin, axisColor) + } + + contentW := maxX - minX + tickInterval := calculateTickInterval(contentW) + startTick := (minX / tickInterval) * tickInterval + if startTick < minX { + startTick += tickInterval + } + + for tick := startTick; tick <= maxX; tick += tickInterval { + x := tick - minX + margin + for ty := margin - 5; ty < margin; ty++ { + img.Set(x, ty, tickColor) + } + label := fmt.Sprintf("%d", tick) + labelW := font.MeasureString(face, label).Ceil() + drawText(img, x-labelW/2, margin-10, label, face, axisColor) + } + + contentH := maxY - minY + tickIntervalY := calculateTickInterval(contentH) + startTickY := (minY / tickIntervalY) * tickIntervalY + if startTickY < minY { + startTickY += tickIntervalY + } + + for tick := startTickY; tick <= maxY; tick += tickIntervalY { + y := tick - minY + margin + for tx := margin - 5; tx < margin; tx++ { + img.Set(tx, y, tickColor) + } + label := fmt.Sprintf("%d", tick) + labelW := font.MeasureString(face, label).Ceil() + drawText(img, margin-labelW-8, y+4, label, face, axisColor) + } + + originLabel := fmt.Sprintf("(%d,%d)", minX, minY) + drawText(img, 2, margin-10, originLabel, face, axisColor) +} + +func calculateTickInterval(size int) int { + if size <= 500 { + return 100 + } + if size <= 1000 { + return 200 + } + if size <= 2000 { + return 500 + } + if size <= 5000 { + return 1000 + } + return 2000 +} + +func drawOriginLabel(img *image.RGBA, x, y, originX, originY int) { + face := basicfont.Face7x13 + label := fmt.Sprintf("(%d, %d)", originX, originY) + + padding := 5 + labelW := font.MeasureString(face, label).Ceil() + boxW := labelW + padding*2 + boxH := 16 + padding + + for py := y; py < y+boxH; py++ { + for px := x; px < x+boxW; px++ { + if px >= 0 && py >= 0 && px < img.Bounds().Dx() && py < img.Bounds().Dy() { + img.Set(px, py, color.RGBA{R: 0, G: 0, B: 0, A: 180}) + } + } + } + + textColor := color.RGBA{R: 255, G: 255, B: 0, A: 255} + drawText(img, x+padding, y+14, label, face, textColor) +} diff --git a/examples/display/main.go b/examples/display/main.go new file mode 100644 index 0000000..960ebd4 --- /dev/null +++ b/examples/display/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strings" + + deskact "github.com/PekingSpades/DeskAct" +) + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Display Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + + displayOptions := deskact.DefaultDisplayOptions() + mouseSettings := deskact.DefaultMouseSettings() + + count := deskact.DisplayCount() + fmt.Printf("\nTotal displays: %d\n", count) + fmt.Println("----------------------------------------") + + displays := deskact.AllDisplays(displayOptions) + for _, d := range displays { + info := d.Info() + fmt.Printf("Display #%d\n", info.Index) + fmt.Printf(" ID: %d\n", info.ID) + fmt.Printf(" IsMain: %v\n", info.IsMain) + fmt.Printf(" Origin: {X: %d, Y: %d, W: %d, H: %d}\n", + info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H) + fmt.Printf(" Size: {W: %d, H: %d}\n", info.Size.W, info.Size.H) + fmt.Printf(" Scale: %.2f\n", info.ScaleFactor) + fmt.Println("----------------------------------------") + } + + mainDisplay := deskact.MainDisplay(displayOptions) + if mainDisplay != nil { + fmt.Printf("MainDisplay: Index=%d, Size=%dx%d, Scale=%.2f\n", + mainDisplay.Index(), mainDisplay.Width(), mainDisplay.Height(), mainDisplay.Scale()) + } + + fmt.Println("\n========================================") + fmt.Println("Moving mouse to each display's corners") + fmt.Println("========================================") + + margin := 32 + + for _, d := range displays { + info := d.Info() + w, h := info.Size.W, info.Size.H + fmt.Printf("\nDisplay #%d (%dx%d) @ (%d,%d):\n", + info.Index, w, h, info.Origin.X, info.Origin.Y) + + positions := []struct { + name string + x, y int + }{ + {"top-left", margin, margin}, + {"top-right", w - 1 - margin, margin}, + {"bottom-left", margin, h - 1 - margin}, + {"bottom-right", w - 1 - margin, h - 1 - margin}, + {"center", w / 2, h / 2}, + } + + for _, pos := range positions { + d.Move(pos.x, pos.y, mouseSettings) + deskact.MilliSleep(800) + + absX, absY := deskact.Location() + relX, relY, ok := d.MouseLocation() + if ok { + fmt.Printf(" %-12s: target=(%4d,%4d) abs=(%5d,%5d) rel=(%4d,%4d)\n", + pos.name, pos.x, pos.y, absX, absY, relX, relY) + } else { + fmt.Printf(" %-12s: target=(%4d,%4d) abs=(%5d,%5d) (mouse not on this display)\n", + pos.name, pos.x, pos.y, absX, absY) + } + deskact.MilliSleep(200) + } + } + + fmt.Println("\n========================================") + fmt.Println("Done!") + fmt.Println("========================================") + + var logBuilder strings.Builder + logBuilder.WriteString("DeskAct Display Example\n") + logBuilder.WriteString(fmt.Sprintf("Go version: %s\n", runtime.Version())) + logBuilder.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) + logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) + for _, d := range displays { + info := d.Info() + logBuilder.WriteString(fmt.Sprintf("Display #%d: ID=%d, IsMain=%v, Origin=(%d,%d,%d,%d), Size=%dx%d, Scale=%.2f\n", + info.Index, info.ID, info.IsMain, info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H, info.Size.W, info.Size.H, info.ScaleFactor)) + } + + fmt.Println("\nPress 's' to save log and exit, or 'e' to exit directly:") + reader := bufio.NewReader(os.Stdin) + for { + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input == "s" { + if err := os.WriteFile("display_log.txt", []byte(logBuilder.String()), 0644); err != nil { + fmt.Printf("Failed to save log: %v\n", err) + } else { + fmt.Println("Log saved to display_log.txt") + } + break + } else if input == "e" { + break + } + fmt.Println("Press 's' to save log and exit, or 'e' to exit directly:") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8caa619 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/PekingSpades/DeskAct + +go 1.21 + +require ( + github.com/gen2brain/shm v0.1.0 + github.com/godbus/dbus/v5 v5.1.0 + github.com/jezek/xgb v1.1.1 + github.com/lxn/win v0.0.0-20210218163916-a377121e959e + golang.org/x/image v0.22.0 +) + +require golang.org/x/sys v0.24.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41d89ef --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY= +github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= +golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/screenshot/darwin.go b/internal/screenshot/darwin.go new file mode 100644 index 0000000..de51da1 --- /dev/null +++ b/internal/screenshot/darwin.go @@ -0,0 +1,223 @@ +//go:build cgo && darwin + +package screenshot + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework CoreGraphics -framework CoreFoundation +#cgo LDFLAGS: -weak_framework ScreenCaptureKit +#include +#include +#if __has_include() +#include +#define HAS_SCREENCAPTUREKIT 1 +#endif + +static CGImageRef capture(CGDirectDisplayID id, CGRect diIntersectDisplayLocal, CGColorSpaceRef colorSpace) { +#if defined(HAS_SCREENCAPTUREKIT) + if (@available(macOS 14.4, *)) { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block CGImageRef result = nil; + [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) { + @autoreleasepool { + if (error) { + dispatch_semaphore_signal(semaphore); + return; + } + SCDisplay* target = nil; + for (SCDisplay *display in content.displays) { + if (display.displayID == id) { + target = display; + break; + } + } + if (!target) { + dispatch_semaphore_signal(semaphore); + return; + } + SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:target excludingWindows:@[]]; + SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init]; + config.sourceRect = diIntersectDisplayLocal; + config.width = diIntersectDisplayLocal.size.width; + config.height = diIntersectDisplayLocal.size.height; + config.showsCursor = NO; + [SCScreenshotManager captureImageWithFilter:filter + configuration:config + completionHandler:^(CGImageRef img, NSError* error) { + if (!error) { + result = CGImageCreateCopyWithColorSpace(img, colorSpace); + } + dispatch_semaphore_signal(semaphore); + }]; + } + }]; + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + dispatch_release(semaphore); + return result; + } +#if __MAC_OS_X_VERSION_MIN_REQUIRED < 150000 + CGImageRef img = CGDisplayCreateImageForRect(id, diIntersectDisplayLocal); + if (!img) { + return nil; + } + CGImageRef copy = CGImageCreateCopyWithColorSpace(img, colorSpace); + CGImageRelease(img); + if (!copy) { + return nil; + } + return copy; +#endif +#else + CGImageRef img = CGDisplayCreateImageForRect(id, diIntersectDisplayLocal); + if (!img) { + return nil; + } + CGImageRef copy = CGImageCreateCopyWithColorSpace(img, colorSpace); + CGImageRelease(img); + if (!copy) { + return nil; + } + return copy; +#endif + return nil; +} +*/ +import "C" + +import ( + "errors" + "image" + "unsafe" +) + +func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { + _ = waylandToken + if width <= 0 || height <= 0 { + return nil, errors.New("width or height should be > 0") + } + + rect := image.Rect(0, 0, width, height) + img, err := createImage(rect) + if err != nil { + return nil, err + } + + // cg: CoreGraphics coordinate (origin: lower-left corner of primary display, x-axis: rightward, y-axis: upward) + // win: Windows coordinate (origin: upper-left corner of primary display, x-axis: rightward, y-axis: downward) + // di: Display local coordinate (origin: upper-left corner of the display, x-axis: rightward, y-axis: downward) + + cgMainDisplayBounds := getCoreGraphicsCoordinateOfDisplay(C.CGMainDisplayID()) + + winBottomLeft := C.CGPointMake(C.CGFloat(x), C.CGFloat(y+height)) + cgBottomLeft := getCoreGraphicsCoordinateFromWindowsCoordinate(winBottomLeft, cgMainDisplayBounds) + cgCaptureBounds := C.CGRectMake(cgBottomLeft.x, cgBottomLeft.y, C.CGFloat(width), C.CGFloat(height)) + + ids := activeDisplayList() + + ctx := createBitmapContext(width, height, (*C.uint32_t)(unsafe.Pointer(&img.Pix[0])), img.Stride) + if ctx == 0 { + return nil, errors.New("cannot create bitmap context") + } + + colorSpace := createColorspace() + if colorSpace == 0 { + return nil, errors.New("cannot create colorspace") + } + defer C.CGColorSpaceRelease(colorSpace) + + for _, id := range ids { + cgBounds := getCoreGraphicsCoordinateOfDisplay(id) + cgIntersect := C.CGRectIntersection(cgBounds, cgCaptureBounds) + if C.CGRectIsNull(cgIntersect) { + continue + } + if cgIntersect.size.width <= 0 || cgIntersect.size.height <= 0 { + continue + } + + // CGDisplayCreateImageForRect potentially fail in case width/height is odd number. + if int(cgIntersect.size.width)%2 != 0 { + cgIntersect.size.width = C.CGFloat(int(cgIntersect.size.width) + 1) + } + if int(cgIntersect.size.height)%2 != 0 { + cgIntersect.size.height = C.CGFloat(int(cgIntersect.size.height) + 1) + } + + diIntersectDisplayLocal := C.CGRectMake(cgIntersect.origin.x-cgBounds.origin.x, + cgBounds.origin.y+cgBounds.size.height-(cgIntersect.origin.y+cgIntersect.size.height), + cgIntersect.size.width, cgIntersect.size.height) + + image := C.capture(id, diIntersectDisplayLocal, colorSpace) + if unsafe.Pointer(image) == nil { + return nil, errors.New("cannot capture display") + } + defer C.CGImageRelease(image) + + cgDrawRect := C.CGRectMake(cgIntersect.origin.x-cgCaptureBounds.origin.x, cgIntersect.origin.y-cgCaptureBounds.origin.y, + cgIntersect.size.width, cgIntersect.size.height) + C.CGContextDrawImage(ctx, cgDrawRect, image) + } + + i := 0 + for iy := 0; iy < height; iy++ { + j := i + for ix := 0; ix < width; ix++ { + // ARGB => RGBA, and set A to 255 + img.Pix[j], img.Pix[j+1], img.Pix[j+2], img.Pix[j+3] = img.Pix[j+1], img.Pix[j+2], img.Pix[j+3], 255 + j += 4 + } + i += img.Stride + } + + return img, nil +} + +func NumActiveDisplays() int { + var count C.uint32_t = 0 + if C.CGGetActiveDisplayList(0, nil, &count) == C.kCGErrorSuccess { + return int(count) + } else { + return 0 + } +} + +func getCoreGraphicsCoordinateOfDisplay(id C.CGDirectDisplayID) C.CGRect { + main := C.CGDisplayBounds(C.CGMainDisplayID()) + r := C.CGDisplayBounds(id) + return C.CGRectMake(r.origin.x, -r.origin.y-r.size.height+main.size.height, + r.size.width, r.size.height) +} + +func getCoreGraphicsCoordinateFromWindowsCoordinate(p C.CGPoint, mainDisplayBounds C.CGRect) C.CGPoint { + return C.CGPointMake(p.x, mainDisplayBounds.size.height-p.y) +} + +func createBitmapContext(width int, height int, data *C.uint32_t, bytesPerRow int) C.CGContextRef { + colorSpace := createColorspace() + if colorSpace == 0 { + return 0 + } + defer C.CGColorSpaceRelease(colorSpace) + + return C.CGBitmapContextCreate(unsafe.Pointer(data), + C.size_t(width), + C.size_t(height), + 8, // bits per component + C.size_t(bytesPerRow), + colorSpace, + C.kCGImageAlphaNoneSkipFirst) +} + +func createColorspace() C.CGColorSpaceRef { + return C.CGColorSpaceCreateWithName(C.kCGColorSpaceSRGB) +} + +func activeDisplayList() []C.CGDirectDisplayID { + count := C.uint32_t(NumActiveDisplays()) + ret := make([]C.CGDirectDisplayID, count) + if count > 0 && C.CGGetActiveDisplayList(count, (*C.CGDirectDisplayID)(unsafe.Pointer(&ret[0])), nil) == C.kCGErrorSuccess { + return ret + } else { + return make([]C.CGDirectDisplayID, 0) + } +} diff --git a/internal/screenshot/nix_dbus_available.go b/internal/screenshot/nix_dbus_available.go new file mode 100644 index 0000000..0044364 --- /dev/null +++ b/internal/screenshot/nix_dbus_available.go @@ -0,0 +1,20 @@ +//go:build !s390x && !ppc64le && !darwin && !windows && (linux || openbsd || netbsd) + +package screenshot + +import ( + "image" + "os" +) + +// Capture returns screen capture of specified desktop region. +// x and y represent distance from the upper-left corner of primary display. +// Y-axis is downward direction. This means coordinates system is similar to Windows OS. +func Capture(x, y, width, height int, waylandToken uint64) (img *image.RGBA, e error) { + sessionType := os.Getenv("XDG_SESSION_TYPE") + if sessionType == "wayland" { + return captureDbus(x, y, width, height, waylandToken) + } else { + return captureXinerama(x, y, width, height) + } +} diff --git a/internal/screenshot/nix_wayland.go b/internal/screenshot/nix_wayland.go new file mode 100644 index 0000000..8573e33 --- /dev/null +++ b/internal/screenshot/nix_wayland.go @@ -0,0 +1,97 @@ +//go:build !s390x && !ppc64le && !darwin && !windows && !freebsd && (linux || openbsd || netbsd) + +package screenshot + +import ( + "fmt" + "github.com/godbus/dbus/v5" + "image" + "image/draw" + "image/png" + "net/url" + "os" + "time" +) + +func captureDbus(x, y, width, height int, token uint64) (img *image.RGBA, e error) { + c, err := dbus.ConnectSessionBus() + if err != nil { + return nil, fmt.Errorf("dbus.SessionBus() failed: %v", err) + } + defer func(c *dbus.Conn) { + err := c.Close() + if err != nil { + e = err + } + }(c) + if token == 0 { + token = uint64(time.Now().UnixNano()) + } + options := map[string]dbus.Variant{ + "modal": dbus.MakeVariant(false), + "interactive": dbus.MakeVariant(false), + "handle_token": dbus.MakeVariant(token), + } + obj := c.Object("org.freedesktop.portal.Desktop", dbus.ObjectPath("/org/freedesktop/portal/desktop")) + call := obj.Call("org.freedesktop.portal.Screenshot.Screenshot", 0, "", options) + var path dbus.ObjectPath + err = call.Store(&path) + if err != nil { + return nil, fmt.Errorf("dbus.Store() failed: %v", err) + } + ch := make(chan *dbus.Message) + c.Eavesdrop(ch) + for msg := range ch { + o, ok := msg.Headers[dbus.FieldPath] + if !ok { + continue + } + s, ok := o.Value().(dbus.ObjectPath) + if !ok { + return nil, fmt.Errorf("dbus.FieldPath value does't have ObjectPath type") + } + if s != path { + continue + } + for _, body := range msg.Body { + v, ok := body.(map[string]dbus.Variant) + if !ok { + continue + } + uri, ok := v["uri"] + if !ok { + continue + } + path, ok := uri.Value().(string) + if !ok { + return nil, fmt.Errorf("uri is not a string") + } + fpath, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("url.Parse(%v) failed: %v", path, err) + } + if fpath.Scheme != "file" { + return nil, fmt.Errorf("uri is not a file path") + } + file, err := os.Open(fpath.Path) + if err != nil { + return nil, fmt.Errorf("os.Open(%s) failed: %v", path, err) + } + defer func(file *os.File) { + _ = file.Close() + _ = os.Remove(fpath.Path) + }(file) + img, err := png.Decode(file) + if err != nil { + return nil, fmt.Errorf("png.Decode(%s) failed: %v", path, err) + } + canvas, err := createImage(image.Rect(0, 0, width, height)) + if err != nil { + return nil, fmt.Errorf("createImage(%v) failed: %v", path, err) + } + draw.Draw(canvas, image.Rect(0, 0, width, height), img, image.Point{x, y}, draw.Src) + return canvas, e + } + } + return nil, fmt.Errorf("dbus.Message doesn't contain uri") +} diff --git a/internal/screenshot/nix_xwindow.go b/internal/screenshot/nix_xwindow.go new file mode 100644 index 0000000..d52e422 --- /dev/null +++ b/internal/screenshot/nix_xwindow.go @@ -0,0 +1,134 @@ +//go:build !s390x && !ppc64le && !darwin && !windows && (linux || freebsd || openbsd || netbsd) + +package screenshot + +import ( + "fmt" + "github.com/gen2brain/shm" + "github.com/jezek/xgb" + mshm "github.com/jezek/xgb/shm" + "github.com/jezek/xgb/xinerama" + "github.com/jezek/xgb/xproto" + "image" + "image/color" +) + +func captureXinerama(x, y, width, height int) (img *image.RGBA, e error) { + defer func() { + err := recover() + if err != nil { + img = nil + e = fmt.Errorf("%v", err) + } + }() + c, err := xgb.NewConn() + if err != nil { + return nil, err + } + defer c.Close() + + err = xinerama.Init(c) + if err != nil { + return nil, err + } + + reply, err := xinerama.QueryScreens(c).Reply() + if err != nil { + return nil, err + } + + primary := reply.ScreenInfo[0] + x0 := int(primary.XOrg) + y0 := int(primary.YOrg) + + useShm := true + err = mshm.Init(c) + if err != nil { + useShm = false + } + + screen := xproto.Setup(c).DefaultScreen(c) + wholeScreenBounds := image.Rect(0, 0, int(screen.WidthInPixels), int(screen.HeightInPixels)) + targetBounds := image.Rect(x+x0, y+y0, x+x0+width, y+y0+height) + intersect := wholeScreenBounds.Intersect(targetBounds) + + rect := image.Rect(0, 0, width, height) + img, err = createImage(rect) + if err != nil { + return nil, err + } + + // Paint with opaque black + index := 0 + for iy := 0; iy < height; iy++ { + j := index + for ix := 0; ix < width; ix++ { + img.Pix[j+3] = 255 + j += 4 + } + index += img.Stride + } + + if !intersect.Empty() { + var data []byte + + if useShm { + shmSize := intersect.Dx() * intersect.Dy() * 4 + shmId, err := shm.Get(shm.IPC_PRIVATE, shmSize, shm.IPC_CREAT|0777) + if err != nil { + return nil, err + } + + seg, err := mshm.NewSegId(c) + if err != nil { + return nil, err + } + + data, err = shm.At(shmId, 0, 0) + if err != nil { + return nil, err + } + + mshm.Attach(c, seg, uint32(shmId), false) + + defer mshm.Detach(c, seg) + defer func() { + _ = shm.Rm(shmId) + }() + defer func() { + _ = shm.Dt(data) + }() + + _, err = mshm.GetImage(c, xproto.Drawable(screen.Root), + int16(intersect.Min.X), int16(intersect.Min.Y), + uint16(intersect.Dx()), uint16(intersect.Dy()), 0xffffffff, + byte(xproto.ImageFormatZPixmap), seg, 0).Reply() + if err != nil { + return nil, err + } + } else { + xImg, err := xproto.GetImage(c, xproto.ImageFormatZPixmap, xproto.Drawable(screen.Root), + int16(intersect.Min.X), int16(intersect.Min.Y), + uint16(intersect.Dx()), uint16(intersect.Dy()), 0xffffffff).Reply() + if err != nil { + return nil, err + } + + data = xImg.Data + } + + // BitBlt by hand + offset := 0 + for iy := intersect.Min.Y; iy < intersect.Max.Y; iy++ { + for ix := intersect.Min.X; ix < intersect.Max.X; ix++ { + r := data[offset+2] + g := data[offset+1] + b := data[offset] + img.SetRGBA(ix-(x+x0), iy-(y+y0), color.RGBA{r, g, b, 255}) + offset += 4 + } + } + } + + return img, e +} diff --git a/internal/screenshot/screenshot.go b/internal/screenshot/screenshot.go new file mode 100644 index 0000000..43d6cf5 --- /dev/null +++ b/internal/screenshot/screenshot.go @@ -0,0 +1,29 @@ +package screenshot + +import ( + "errors" + "image" +) + +const errUnsupportedMessage = "screenshot does not support your platform" + +// errUnsupported returns an error when the platform does not support screenshot capture. +func errUnsupported() error { + return errors.New(errUnsupportedMessage) +} + +func createImage(rect image.Rectangle) (img *image.RGBA, e error) { + img = nil + e = errors.New("Cannot create image.RGBA") + + defer func() { + err := recover() + if err == nil { + e = nil + } + }() + // image.NewRGBA may panic if rect is too large. + img = image.NewRGBA(rect) + + return img, e +} diff --git a/internal/screenshot/unsupported.go b/internal/screenshot/unsupported.go new file mode 100644 index 0000000..6fd509b --- /dev/null +++ b/internal/screenshot/unsupported.go @@ -0,0 +1,13 @@ +//go:build s390x || ppc64le || (!(cgo && darwin) && !windows && !linux && !freebsd && !openbsd && !netbsd) + +package screenshot + +import "image" + +// Capture returns screen capture of specified desktop region. +// x and y represent distance from the upper-left corner of primary display. +// Y-axis is downward direction. This means coordinates system is similar to Windows OS. +func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { + _ = waylandToken + return nil, errUnsupported() +} diff --git a/internal/screenshot/windows.go b/internal/screenshot/windows.go new file mode 100644 index 0000000..4b32a1a --- /dev/null +++ b/internal/screenshot/windows.go @@ -0,0 +1,96 @@ +//go:build windows + +package screenshot + +import ( + "errors" + "github.com/lxn/win" + "image" + "syscall" + "unsafe" +) + +func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { + _ = waylandToken + rect := image.Rect(0, 0, width, height) + img, err := createImage(rect) + if err != nil { + return nil, err + } + + hwnd := getDesktopWindow() + hdc := win.GetDC(hwnd) + if hdc == 0 { + return nil, errors.New("GetDC failed") + } + defer win.ReleaseDC(hwnd, hdc) + + memory_device := win.CreateCompatibleDC(hdc) + if memory_device == 0 { + return nil, errors.New("CreateCompatibleDC failed") + } + defer win.DeleteDC(memory_device) + + bitmap := win.CreateCompatibleBitmap(hdc, int32(width), int32(height)) + if bitmap == 0 { + return nil, errors.New("CreateCompatibleBitmap failed") + } + defer win.DeleteObject(win.HGDIOBJ(bitmap)) + + var header win.BITMAPINFOHEADER + header.BiSize = uint32(unsafe.Sizeof(header)) + header.BiPlanes = 1 + header.BiBitCount = 32 + header.BiWidth = int32(width) + header.BiHeight = int32(-height) + header.BiCompression = win.BI_RGB + header.BiSizeImage = 0 + + // GetDIBits balks at using Go memory on some systems. The MSDN example uses + // GlobalAlloc, so we'll do that too. See: + // https://docs.microsoft.com/en-gb/windows/desktop/gdi/capturing-an-image + bitmapDataSize := uintptr(((int64(width)*int64(header.BiBitCount) + 31) / 32) * 4 * int64(height)) + hmem := win.GlobalAlloc(win.GMEM_MOVEABLE, bitmapDataSize) + defer win.GlobalFree(hmem) + memptr := win.GlobalLock(hmem) + defer win.GlobalUnlock(hmem) + + old := win.SelectObject(memory_device, win.HGDIOBJ(bitmap)) + if old == 0 { + return nil, errors.New("SelectObject failed") + } + defer win.SelectObject(memory_device, old) + + if !win.BitBlt(memory_device, 0, 0, int32(width), int32(height), hdc, int32(x), int32(y), win.SRCCOPY) { + return nil, errors.New("BitBlt failed") + } + + if win.GetDIBits(hdc, bitmap, 0, uint32(height), (*uint8)(memptr), (*win.BITMAPINFO)(unsafe.Pointer(&header)), win.DIB_RGB_COLORS) == 0 { + return nil, errors.New("GetDIBits failed") + } + + i := 0 + src := uintptr(memptr) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + v0 := *(*uint8)(unsafe.Pointer(src)) + v1 := *(*uint8)(unsafe.Pointer(src + 1)) + v2 := *(*uint8)(unsafe.Pointer(src + 2)) + + // BGRA => RGBA, and set A to 255 + img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = v2, v1, v0, 255 + + i += 4 + src += 4 + } + } + + return img, nil +} + +func getDesktopWindow() win.HWND { + user32 := syscall.NewLazyDLL("user32.dll") + proc := user32.NewProc("GetDesktopWindow") + ret, _, _ := proc.Call() + return win.HWND(ret) +} diff --git a/key/keycode.h b/key/keycode.h new file mode 100644 index 0000000..d5577fa --- /dev/null +++ b/key/keycode.h @@ -0,0 +1,403 @@ +#pragma once +#ifndef KEYCODE_H +#define KEYCODE_H + +#include "../base/os.h" + +#if defined(IS_MACOSX) + +#include /* Really only need */ +#include +#import + +enum _MMKeyCode { + // a-z, 0-9 + K_NOT_A_KEY = 9999, + K_BACKSPACE = kVK_Delete, + K_DELETE = kVK_ForwardDelete, + K_RETURN = kVK_Return, + K_TAB = kVK_Tab, + K_ESCAPE = kVK_Escape, + K_UP = kVK_UpArrow, + K_DOWN = kVK_DownArrow, + K_RIGHT = kVK_RightArrow, + K_LEFT = kVK_LeftArrow, + K_HOME = kVK_Home, + K_END = kVK_End, + K_PAGEUP = kVK_PageUp, + K_PAGEDOWN = kVK_PageDown, + + K_F1 = kVK_F1, + K_F2 = kVK_F2, + K_F3 = kVK_F3, + K_F4 = kVK_F4, + K_F5 = kVK_F5, + K_F6 = kVK_F6, + K_F7 = kVK_F7, + K_F8 = kVK_F8, + K_F9 = kVK_F9, + K_F10 = kVK_F10, + K_F11 = kVK_F11, + K_F12 = kVK_F12, + K_F13 = kVK_F13, + K_F14 = kVK_F14, + K_F15 = kVK_F15, + K_F16 = kVK_F16, + K_F17 = kVK_F17, + K_F18 = kVK_F18, + K_F19 = kVK_F19, + K_F20 = kVK_F20, + K_F21 = K_NOT_A_KEY, + K_F22 = K_NOT_A_KEY, + K_F23 = K_NOT_A_KEY, + K_F24 = K_NOT_A_KEY, + + K_META = kVK_Command, + K_LMETA = kVK_Command, + K_RMETA = kVK_RightCommand, + K_ALT = kVK_Option, + K_LALT = kVK_Option, + K_RALT = kVK_RightOption, + K_CONTROL = kVK_Control, + K_LCONTROL = kVK_Control, + K_RCONTROL = kVK_RightControl, + K_SHIFT = kVK_Shift, + K_LSHIFT = kVK_Shift, + K_RSHIFT = kVK_RightShift, + K_CAPSLOCK = kVK_CapsLock, + K_SPACE = kVK_Space, + K_INSERT = kVK_Help, + // K_PRINTSCREEN = K_NOT_A_KEY, + K_PRINTSCREEN = kVK_F13, + K_MENU = K_NOT_A_KEY, + + K_NUMPAD_0 = kVK_ANSI_Keypad0, + K_NUMPAD_1 = kVK_ANSI_Keypad1, + K_NUMPAD_2 = kVK_ANSI_Keypad2, + K_NUMPAD_3 = kVK_ANSI_Keypad3, + K_NUMPAD_4 = kVK_ANSI_Keypad4, + K_NUMPAD_5 = kVK_ANSI_Keypad5, + K_NUMPAD_6 = kVK_ANSI_Keypad6, + K_NUMPAD_7 = kVK_ANSI_Keypad7, + K_NUMPAD_8 = kVK_ANSI_Keypad8, + K_NUMPAD_9 = kVK_ANSI_Keypad9, + K_NUMPAD_LOCK = kVK_ANSI_KeypadClear, + // + K_NUMPAD_DECIMAL = kVK_ANSI_KeypadDecimal, + K_NUMPAD_PLUS = kVK_ANSI_KeypadPlus, + K_NUMPAD_MINUS = kVK_ANSI_KeypadMinus, + K_NUMPAD_MUL = kVK_ANSI_KeypadMultiply, + K_NUMPAD_DIV = kVK_ANSI_KeypadDivide, + K_NUMPAD_CLEAR = kVK_ANSI_KeypadClear, + K_NUMPAD_ENTER = kVK_ANSI_KeypadEnter, + K_NUMPAD_EQUAL = kVK_ANSI_KeypadEquals, + K_NUMPAD_LB = kVK_ANSI_LeftBracket, + K_NUMPAD_RB = kVK_ANSI_RightBracket, + K_Backslash = kVK_ANSI_Backslash, + K_Semicolon = kVK_ANSI_Semicolon, + K_Quote = kVK_ANSI_Quote, + K_Slash = kVK_ANSI_Slash, + K_Grave = kVK_ANSI_Grave, + + K_AUDIO_VOLUME_MUTE = 1007, + K_AUDIO_VOLUME_DOWN = 1001, + K_AUDIO_VOLUME_UP = 1000, + K_AUDIO_PLAY = 1016, + K_AUDIO_STOP = K_NOT_A_KEY, + K_AUDIO_PAUSE = 1016, + K_AUDIO_PREV = 1018, + K_AUDIO_NEXT = 1017, + K_AUDIO_REWIND = K_NOT_A_KEY, + K_AUDIO_FORWARD = K_NOT_A_KEY, + K_AUDIO_REPEAT = K_NOT_A_KEY, + K_AUDIO_RANDOM = K_NOT_A_KEY, + + K_LIGHTS_MON_UP = 1002, + K_LIGHTS_MON_DOWN = 1003, + K_LIGHTS_KBD_TOGGLE = 1023, + K_LIGHTS_KBD_UP = 1021, + K_LIGHTS_KBD_DOWN = 1022 +}; + +typedef CGKeyCode MMKeyCode; + +#elif defined(USE_X11) + +#include +#include + +enum _MMKeyCode { + K_NOT_A_KEY = 9999, + K_BACKSPACE = XK_BackSpace, + K_DELETE = XK_Delete, + K_RETURN = XK_Return, + K_TAB = XK_Tab, + K_ESCAPE = XK_Escape, + K_UP = XK_Up, + K_DOWN = XK_Down, + K_RIGHT = XK_Right, + K_LEFT = XK_Left, + K_HOME = XK_Home, + K_END = XK_End, + K_PAGEUP = XK_Page_Up, + K_PAGEDOWN = XK_Page_Down, + + K_F1 = XK_F1, + K_F2 = XK_F2, + K_F3 = XK_F3, + K_F4 = XK_F4, + K_F5 = XK_F5, + K_F6 = XK_F6, + K_F7 = XK_F7, + K_F8 = XK_F8, + K_F9 = XK_F9, + K_F10 = XK_F10, + K_F11 = XK_F11, + K_F12 = XK_F12, + K_F13 = XK_F13, + K_F14 = XK_F14, + K_F15 = XK_F15, + K_F16 = XK_F16, + K_F17 = XK_F17, + K_F18 = XK_F18, + K_F19 = XK_F19, + K_F20 = XK_F20, + K_F21 = XK_F21, + K_F22 = XK_F22, + K_F23 = XK_F23, + K_F24 = XK_F24, + + K_META = XK_Super_L, + K_LMETA = XK_Super_L, + K_RMETA = XK_Super_R, + K_ALT = XK_Alt_L, + K_LALT = XK_Alt_L, + K_RALT = XK_Alt_R, + K_CONTROL = XK_Control_L, + K_LCONTROL = XK_Control_L, + K_RCONTROL = XK_Control_R, + K_SHIFT = XK_Shift_L, + K_LSHIFT = XK_Shift_L, + K_RSHIFT = XK_Shift_R, + K_CAPSLOCK = XK_Caps_Lock, + K_SPACE = XK_space, + K_INSERT = XK_Insert, + K_PRINTSCREEN = XK_Print, + K_MENU = K_NOT_A_KEY, + + // K_NUMPAD_0 = K_NOT_A_KEY, + K_NUMPAD_0 = XK_KP_0, + K_NUMPAD_1 = XK_KP_1, + K_NUMPAD_2 = XK_KP_2, + K_NUMPAD_3 = XK_KP_3, + K_NUMPAD_4 = XK_KP_4, + K_NUMPAD_5 = XK_KP_5, + K_NUMPAD_6 = XK_KP_6, + K_NUMPAD_7 = XK_KP_7, + K_NUMPAD_8 = XK_KP_8, + K_NUMPAD_9 = XK_KP_9, + K_NUMPAD_LOCK = XK_Num_Lock, + // + K_NUMPAD_DECIMAL = XK_KP_Decimal, + K_NUMPAD_PLUS = 78, // XK_KP_Add + K_NUMPAD_MINUS = 74, // XK_KP_Subtract + K_NUMPAD_MUL = 55, // XK_KP_Multiply + K_NUMPAD_DIV = 98, // XK_KP_Divide + K_NUMPAD_CLEAR = K_NOT_A_KEY, + K_NUMPAD_ENTER = 96, // XK_KP_Enter + K_NUMPAD_EQUAL = XK_equal, + K_NUMPAD_LB = XK_bracketleft, + K_NUMPAD_RB = XK_bracketright, + K_Backslash = XK_backslash, + K_Semicolon = XK_semicolon, + K_Quote = XK_apostrophe, + K_Slash = XK_slash, + K_Grave = XK_grave, + + K_AUDIO_VOLUME_MUTE = XF86XK_AudioMute, + K_AUDIO_VOLUME_DOWN = XF86XK_AudioLowerVolume, + K_AUDIO_VOLUME_UP = XF86XK_AudioRaiseVolume, + K_AUDIO_PLAY = XF86XK_AudioPlay, + K_AUDIO_STOP = XF86XK_AudioStop, + K_AUDIO_PAUSE = XF86XK_AudioPause, + K_AUDIO_PREV = XF86XK_AudioPrev, + K_AUDIO_NEXT = XF86XK_AudioNext, + K_AUDIO_REWIND = XF86XK_AudioRewind, + K_AUDIO_FORWARD = XF86XK_AudioForward, + K_AUDIO_REPEAT = XF86XK_AudioRepeat, + K_AUDIO_RANDOM = XF86XK_AudioRandomPlay, + + K_LIGHTS_MON_UP = XF86XK_MonBrightnessUp, + K_LIGHTS_MON_DOWN = XF86XK_MonBrightnessDown, + K_LIGHTS_KBD_TOGGLE = XF86XK_KbdLightOnOff, + K_LIGHTS_KBD_UP = XF86XK_KbdBrightnessUp, + K_LIGHTS_KBD_DOWN = XF86XK_KbdBrightnessDown +}; + +typedef KeySym MMKeyCode; + +/* + * Structs to store key mappings not handled by XStringToKeysym() on some + * Linux systems. + */ +struct XSpecialCharacterMapping { + char name; + MMKeyCode code; +}; + +struct XSpecialCharacterMapping XSpecialCharacterTable[] = { + {'~', XK_asciitilde}, + {'_', XK_underscore}, + {'[', XK_bracketleft}, + {']', XK_bracketright}, + {'!', XK_exclam}, + {'#', XK_numbersign}, + {'$', XK_dollar}, + {'%', XK_percent}, + {'&', XK_ampersand}, + {'*', XK_asterisk}, + {'+', XK_plus}, + {',', XK_comma}, + {'-', XK_minus}, + {'.', XK_period}, + {'?', XK_question}, + {'<', XK_less}, + {'>', XK_greater}, + {'=', XK_equal}, + {'@', XK_at}, + {':', XK_colon}, + {';', XK_semicolon}, + {'{', XK_braceleft}, + {'}', XK_braceright}, + {'|', XK_bar}, + {'^', XK_asciicircum}, + {'(', XK_parenleft}, + {')', XK_parenright}, + {' ', XK_space}, + {'/', XK_slash}, + {'\\', XK_backslash}, + {'`', XK_grave}, + {'"', XK_quoteright}, + {'\'', XK_quotedbl}, + {'\t', XK_Tab}, + {'\n', XK_Return} +}; + +#elif defined(IS_WINDOWS) + +enum _MMKeyCode { + K_NOT_A_KEY = 9999, + K_BACKSPACE = VK_BACK, + K_DELETE = VK_DELETE, + K_RETURN = VK_RETURN, + K_TAB = VK_TAB, + K_ESCAPE = VK_ESCAPE, + K_UP = VK_UP, + K_DOWN = VK_DOWN, + K_RIGHT = VK_RIGHT, + K_LEFT = VK_LEFT, + K_HOME = VK_HOME, + K_END = VK_END, + K_PAGEUP = VK_PRIOR, + K_PAGEDOWN = VK_NEXT, + + K_F1 = VK_F1, + K_F2 = VK_F2, + K_F3 = VK_F3, + K_F4 = VK_F4, + K_F5 = VK_F5, + K_F6 = VK_F6, + K_F7 = VK_F7, + K_F8 = VK_F8, + K_F9 = VK_F9, + K_F10 = VK_F10, + K_F11 = VK_F11, + K_F12 = VK_F12, + K_F13 = VK_F13, + K_F14 = VK_F14, + K_F15 = VK_F15, + K_F16 = VK_F16, + K_F17 = VK_F17, + K_F18 = VK_F18, + K_F19 = VK_F19, + K_F20 = VK_F20, + K_F21 = VK_F21, + K_F22 = VK_F22, + K_F23 = VK_F23, + K_F24 = VK_F24, + + K_META = VK_LWIN, + K_LMETA = VK_LWIN, + K_RMETA = VK_RWIN, + K_ALT = VK_MENU, + K_LALT = VK_LMENU, + K_RALT = VK_RMENU, + K_CONTROL = VK_CONTROL, + K_LCONTROL = VK_LCONTROL, + K_RCONTROL = VK_RCONTROL, + K_SHIFT = VK_SHIFT, + K_LSHIFT = VK_LSHIFT, + K_RSHIFT = VK_RSHIFT, + K_CAPSLOCK = VK_CAPITAL, + K_SPACE = VK_SPACE, + K_PRINTSCREEN = VK_SNAPSHOT, + K_INSERT = VK_INSERT, + K_MENU = VK_APPS, + + K_NUMPAD_0 = VK_NUMPAD0, + K_NUMPAD_1 = VK_NUMPAD1, + K_NUMPAD_2 = VK_NUMPAD2, + K_NUMPAD_3 = VK_NUMPAD3, + K_NUMPAD_4 = VK_NUMPAD4, + K_NUMPAD_5 = VK_NUMPAD5, + K_NUMPAD_6 = VK_NUMPAD6, + K_NUMPAD_7 = VK_NUMPAD7, + K_NUMPAD_8 = VK_NUMPAD8, + K_NUMPAD_9 = VK_NUMPAD9, + K_NUMPAD_LOCK = VK_NUMLOCK, + // VK_NUMPAD_ + K_NUMPAD_DECIMAL = VK_DECIMAL, + K_NUMPAD_PLUS = VK_ADD, + K_NUMPAD_MINUS = VK_SUBTRACT, + K_NUMPAD_MUL = VK_MULTIPLY, + K_NUMPAD_DIV = VK_DIVIDE, + K_NUMPAD_CLEAR = K_NOT_A_KEY, + K_NUMPAD_ENTER = VK_RETURN, + K_NUMPAD_EQUAL = VK_OEM_PLUS, + K_NUMPAD_LB = VK_OEM_4, + K_NUMPAD_RB = VK_OEM_6, + K_Backslash = VK_OEM_5, + K_Semicolon = VK_OEM_1, + K_Quote = VK_OEM_7, + K_Slash = VK_OEM_2, + K_Grave = VK_OEM_3, + + K_AUDIO_VOLUME_MUTE = VK_VOLUME_MUTE, + K_AUDIO_VOLUME_DOWN = VK_VOLUME_DOWN, + K_AUDIO_VOLUME_UP = VK_VOLUME_UP, + K_AUDIO_PLAY = VK_MEDIA_PLAY_PAUSE, + K_AUDIO_STOP = VK_MEDIA_STOP, + K_AUDIO_PAUSE = VK_MEDIA_PLAY_PAUSE, + K_AUDIO_PREV = VK_MEDIA_PREV_TRACK, + K_AUDIO_NEXT = VK_MEDIA_NEXT_TRACK, + K_AUDIO_REWIND = K_NOT_A_KEY, + K_AUDIO_FORWARD = K_NOT_A_KEY, + K_AUDIO_REPEAT = K_NOT_A_KEY, + K_AUDIO_RANDOM = K_NOT_A_KEY, + + K_LIGHTS_MON_UP = K_NOT_A_KEY, + K_LIGHTS_MON_DOWN = K_NOT_A_KEY, + K_LIGHTS_KBD_TOGGLE = K_NOT_A_KEY, + K_LIGHTS_KBD_UP = K_NOT_A_KEY, + K_LIGHTS_KBD_DOWN = K_NOT_A_KEY +}; + +typedef int MMKeyCode; + +#endif + +/* Returns the keyCode corresponding to the current keyboard layout for the + * given ASCII character. */ +MMKeyCode keyCodeForChar(const char c); + +#endif /* KEYCODE_H */ diff --git a/key/keycode_c.h b/key/keycode_c.h new file mode 100644 index 0000000..9ef957d --- /dev/null +++ b/key/keycode_c.h @@ -0,0 +1,9 @@ +#include "../base/os.h" + +#if defined(IS_MACOSX) + #include "keycode_c_macos.h" +#elif defined(IS_WINDOWS) + #include "keycode_c_windows.h" +#elif defined(USE_X11) + #include "keycode_c_x11.h" +#endif diff --git a/key/keycode_c_macos.h b/key/keycode_c_macos.h new file mode 100644 index 0000000..96d5a3d --- /dev/null +++ b/key/keycode_c_macos.h @@ -0,0 +1,81 @@ +#include "keycode.h" + +#include +#include /* For kVK_ constants, and TIS functions. */ + +/* Returns string representation of key, if it is printable. +Ownership follows the Create Rule; +it is the caller's responsibility to release the returned object. */ +CFStringRef createStringForKey(CGKeyCode keyCode); + +MMKeyCode keyCodeForChar(const char c) { + /* OS X does not appear to have a built-in function for this, + so instead it to write our own. */ + static CFMutableDictionaryRef charToCodeDict = NULL; + CGKeyCode code; + UniChar character = c; + CFStringRef charStr = NULL; + + /* Generate table of keycodes and characters. */ + if (charToCodeDict == NULL) { + size_t i; + charToCodeDict = CFDictionaryCreateMutable(kCFAllocatorDefault, 128, + &kCFCopyStringDictionaryKeyCallBacks, NULL); + if (charToCodeDict == NULL) { return K_NOT_A_KEY; } + + /* Loop through every keycode (0 - 127) to find its current mapping. */ + for (i = 0; i < 128; ++i) { + CFStringRef string = createStringForKey((CGKeyCode)i); + if (string != NULL) { + CFDictionaryAddValue(charToCodeDict, string, (const void *)i); + CFRelease(string); + } + } + } + + charStr = CFStringCreateWithCharacters(kCFAllocatorDefault, &character, 1); + /* Our values may be NULL (0), so we need to use this function. */ + /* Use pointer-sized variable to avoid stack overflow on 64-bit systems */ + const void *codePtr = NULL; + if (!CFDictionaryGetValueIfPresent(charToCodeDict, charStr, &codePtr)) { + code = UINT16_MAX; /* Error */ + } else { + code = (CGKeyCode)(uintptr_t)codePtr; + } + CFRelease(charStr); + + // TISGetInputSourceProperty may return nil so we need fallback + if (code == UINT16_MAX) { + return K_NOT_A_KEY; + } + + return (MMKeyCode)code; +} + +CFStringRef createStringForKey(CGKeyCode keyCode){ + // TISInputSourceRef currentKeyboard = TISCopyCurrentASCIICapableKeyboardInputSource(); + TISInputSourceRef currentKeyboard = TISCopyCurrentKeyboardLayoutInputSource(); + + /* Check if currentKeyboard is NULL to avoid crash */ + if (currentKeyboard == NULL) { return NULL; } + + CFDataRef layoutData = (CFDataRef) TISGetInputSourceProperty( + currentKeyboard, kTISPropertyUnicodeKeyLayoutData); + + if (layoutData == nil) { + CFRelease(currentKeyboard); /* Fix memory leak */ + return NULL; + } + + const UCKeyboardLayout *keyboardLayout = (const UCKeyboardLayout *) CFDataGetBytePtr(layoutData); + UInt32 keysDown = 0; + UniChar chars[4]; + UniCharCount realLength; + + UCKeyTranslate(keyboardLayout, keyCode, kUCKeyActionDisplay, 0, LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, &keysDown, + sizeof(chars) / sizeof(chars[0]), &realLength, chars); + CFRelease(currentKeyboard); + + return CFStringCreateWithCharacters(kCFAllocatorDefault, chars, 1); +} diff --git a/key/keycode_c_windows.h b/key/keycode_c_windows.h new file mode 100644 index 0000000..d2fec96 --- /dev/null +++ b/key/keycode_c_windows.h @@ -0,0 +1,11 @@ +#include "keycode.h" + +MMKeyCode keyCodeForChar(const char c) { + MMKeyCode code; + code = VkKeyScan(c); + if (code == 0xFFFF) { + return K_NOT_A_KEY; + } + + return code; +} diff --git a/key/keycode_c_x11.h b/key/keycode_c_x11.h new file mode 100644 index 0000000..8fa1cc4 --- /dev/null +++ b/key/keycode_c_x11.h @@ -0,0 +1,31 @@ +#include "keycode.h" + +MMKeyCode keyCodeForChar(const char c) { + char buf[2]; + buf[0] = c; + buf[1] = '\0'; + + MMKeyCode code = XStringToKeysym(buf); + if (code == NoSymbol) { + /* Some special keys are apparently not handled properly */ + struct XSpecialCharacterMapping* xs = XSpecialCharacterTable; + while (xs->name) { + if (c == xs->name) { + code = xs->code; + // + break; + } + xs++; + } + } + + if (code == NoSymbol) { + return K_NOT_A_KEY; + } + + // x11 key bug + if (c == 60) { + code = 44; + } + return code; +} diff --git a/key/keypress.h b/key/keypress.h new file mode 100644 index 0000000..3079d29 --- /dev/null +++ b/key/keypress.h @@ -0,0 +1,46 @@ +#pragma once +#ifndef KEYPRESS_H +#define KEYPRESS_H + +#include +#include "../base/os.h" +#include "../base/types.h" + +#include "keycode.h" +#include + +#if defined(IS_MACOSX) + typedef enum { + MOD_NONE = 0, + MOD_META = kCGEventFlagMaskCommand, + MOD_ALT = kCGEventFlagMaskAlternate, + MOD_CONTROL = kCGEventFlagMaskControl, + MOD_SHIFT = kCGEventFlagMaskShift + } MMKeyFlags; +#elif defined(USE_X11) + enum _MMKeyFlags { + MOD_NONE = 0, + MOD_META = Mod4Mask, + MOD_ALT = Mod1Mask, + MOD_CONTROL = ControlMask, + MOD_SHIFT = ShiftMask + }; + typedef unsigned int MMKeyFlags; +#elif defined(IS_WINDOWS) + enum _MMKeyFlags { + MOD_NONE = 0, + /* These are already defined by the Win32 API */ + /* MOD_ALT = 0, + MOD_CONTROL = 0, + MOD_SHIFT = 0, */ + MOD_META = MOD_WIN + }; + typedef unsigned int MMKeyFlags; +#endif + +#if defined(IS_WINDOWS) + /* Send win32 key event for given key. */ + void win32KeyEvent(int key, MMKeyFlags flags, uintptr pid, int8_t isPid); +#endif + +#endif /* KEYPRESS_H */ diff --git a/key/keypress_c.h b/key/keypress_c.h new file mode 100644 index 0000000..cdf9ab6 --- /dev/null +++ b/key/keypress_c.h @@ -0,0 +1,19 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "../base/os.h" + +#if defined(IS_WINDOWS) + #include "keypress_c_windows.h" +#elif defined(USE_X11) + #include "keypress_c_x11.h" +#elif defined(IS_MACOSX) + #include "keypress_c_macos.h" +#endif diff --git a/key/keypress_c_macos.h b/key/keypress_c_macos.h new file mode 100644 index 0000000..cc5b443 --- /dev/null +++ b/key/keypress_c_macos.h @@ -0,0 +1,249 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "../base/deadbeef_rand_c.h" +#include "../base/microsleep.h" +#include "keypress.h" +#include "keycode_c.h" + +#include /* For isupper() */ +#include +#import +#import + +/* + * Platform-specific helper functions + */ +static int SendTo(uintptr pid, CGEventRef event) { + if (pid != 0) { + CGEventPostToPid(pid, event); + } else { + CGEventPost(kCGHIDEventTap, event); + } + CFRelease(event); + return 0; +} + +static io_connect_t _getAuxiliaryKeyDriver(void) { + static mach_port_t sEventDrvrRef = 0; + mach_port_t masterPort, service, iter; + kern_return_t kr; + + if (!sEventDrvrRef) { + kr = IOMasterPort(bootstrap_port, &masterPort); + assert(KERN_SUCCESS == kr); + kr = IOServiceGetMatchingServices(masterPort, IOServiceMatching(kIOHIDSystemClass), &iter); + assert(KERN_SUCCESS == kr); + + service = IOIteratorNext(iter); + assert(service); + + kr = IOServiceOpen(service, mach_task_self(), kIOHIDParamConnectType, &sEventDrvrRef); + assert(KERN_SUCCESS == kr); + + IOObjectRelease(service); + IOObjectRelease(iter); + } + return sEventDrvrRef; +} + +/* Helper: post media key event via IOKit HID */ +static int postMediaKeyEvent(MMKeyCode code, bool down) { + NXEventData event; + kern_return_t kr; + IOGPoint loc = { 0, 0 }; + UInt32 evtInfo = code << 16 | (down ? NX_KEYDOWN : NX_KEYUP) << 8; + + bzero(&event, sizeof(NXEventData)); + event.compound.subType = NX_SUBTYPE_AUX_CONTROL_BUTTONS; + event.compound.misc.L[0] = evtInfo; + + kr = IOHIDPostEvent(_getAuxiliaryKeyDriver(), + NX_SYSDEFINED, loc, &event, kNXEventDataVersion, 0, FALSE); + return (kr == KERN_SUCCESS) ? 0 : -1; +} + +/* + * keyTap - Atomic key tap (press + release) with modifiers + * + * Press order: Modifiers -> Main key + * Release order: Main key -> Modifiers (LIFO) + */ +int keyTap(MMKeyCode code, MMKeyFlags flags) { + /* The media keys all have 1000 added to them to help us detect them. */ + if (code >= 1000) { + code = code - 1000; /* Get the real keycode. */ + postMediaKeyEvent(code, true); + microsleep(5.0); + postMediaKeyEvent(code, false); + return 0; + } + + /* macOS: CGEventFlags makes it atomic - modifiers are set on the event itself */ + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + + /* Press */ + CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, true); + if (keyDown == NULL) { + CFRelease(source); + return -1; + } + if (flags != 0) { + CGEventSetFlags(keyDown, (CGEventFlags)flags); + } + CGEventPost(kCGHIDEventTap, keyDown); + CFRelease(keyDown); + + /* Release */ + CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, false); + if (keyUp == NULL) { + CFRelease(source); + return -1; + } + if (flags != 0) { + CGEventSetFlags(keyUp, (CGEventFlags)flags); + } + CGEventPost(kCGHIDEventTap, keyUp); + CFRelease(keyUp); + + CFRelease(source); + return 0; +} + +/* + * keyToggle - Atomic key toggle (press or release) with modifiers + * + * down=true: Modifiers -> Main key (press order) + * down=false: Main key -> Modifiers (release order, LIFO) + */ +int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { + /* The media keys all have 1000 added to them to help us detect them. */ + if (code >= 1000) { + code = code - 1000; /* Get the real keycode. */ + return postMediaKeyEvent(code, down); + } + + /* macOS: CGEventFlags makes it atomic */ + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, down); + + if (keyEvent == NULL) { + CFRelease(source); + return -1; + } + + CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); + if (flags != 0) { + CGEventSetFlags(keyEvent, (CGEventFlags)flags); + } + + CGEventPost(kCGHIDEventTap, keyEvent); + CFRelease(keyEvent); + CFRelease(source); + return 0; +} + +/* + * keyTapPid - Key tap to a specific process (non-atomic, uses PostMessage on Windows) + */ +int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { + /* macOS: supports PID natively */ + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + + CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, true); + if (keyDown == NULL) { + CFRelease(source); + return -1; + } + if (flags != 0) { + CGEventSetFlags(keyDown, (CGEventFlags)flags); + } + CGEventPostToPid(pid, keyDown); + CFRelease(keyDown); + + CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, false); + if (keyUp == NULL) { + CFRelease(source); + return -1; + } + if (flags != 0) { + CGEventSetFlags(keyUp, (CGEventFlags)flags); + } + CGEventPostToPid(pid, keyUp); + CFRelease(keyUp); + + CFRelease(source); + return 0; +} + +/* + * keyTogglePid - Key toggle to a specific process + */ +int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) { + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, down); + + if (keyEvent == NULL) { + CFRelease(source); + return -1; + } + + CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); + if (flags != 0) { + CGEventSetFlags(keyEvent, (CGEventFlags)flags); + } + + CGEventPostToPid(pid, keyEvent); + CFRelease(keyEvent); + CFRelease(source); + return 0; +} + +/* + * Legacy functions for compatibility + */ +void toggleKey(char c, const bool down, MMKeyFlags flags, uintptr pid) { + MMKeyCode keyCode = keyCodeForChar(c); + + if (isupper(c) && !(flags & MOD_SHIFT)) { + flags |= MOD_SHIFT; + } + + if (pid != 0) { + keyTogglePid(keyCode, down, flags, pid); + } else { + keyToggle(keyCode, down, flags); + } +} + +void toggleUnicode(UniChar ch, const bool down, uintptr pid) { + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, 0, down); + if (keyEvent == NULL) { + fputs("Could not create keyboard event.\n", stderr); + CFRelease(source); + return; + } + + CGEventKeyboardSetUnicodeString(keyEvent, 1, &ch); + SendTo(pid, keyEvent); + CFRelease(source); +} + +void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { + UniChar ch = (UniChar)value; + toggleUnicode(ch, true, pid); + microsleep(5.0); + toggleUnicode(ch, false, pid); +} + +int input_utf(const char *utf) { + return 0; +} diff --git a/key/keypress_c_windows.h b/key/keypress_c_windows.h new file mode 100644 index 0000000..b86c0f7 --- /dev/null +++ b/key/keypress_c_windows.h @@ -0,0 +1,235 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "../base/deadbeef_rand_c.h" +#include "../base/microsleep.h" +#include "keypress.h" +#include "keycode_c.h" + +#include /* For isupper() */ + +/* + * Platform-specific helper functions + */ +HWND GetHwndByPid(DWORD dwProcessId); + +HWND getHwnd(uintptr pid, int8_t isPid) { + HWND hwnd = (HWND) pid; + if (isPid == 0) { + hwnd = GetHwndByPid(pid); + } + return hwnd; +} + +/* Helper: check if a key is an extended key */ +static inline DWORD getExtendedKeyFlags(int key) { + switch (key) { + case VK_RCONTROL: + case VK_SNAPSHOT: /* Print Screen */ + case VK_RMENU: /* Right Alt / Alt Gr */ + case VK_PAUSE: /* Pause / Break */ + case VK_HOME: + case VK_UP: + case VK_PRIOR: /* Page up */ + case VK_LEFT: + case VK_RIGHT: + case VK_END: + case VK_DOWN: + case VK_NEXT: /* Page Down */ + case VK_INSERT: + case VK_DELETE: + case VK_LWIN: + case VK_RWIN: + case VK_APPS: /* Application */ + case VK_VOLUME_MUTE: + case VK_VOLUME_DOWN: + case VK_VOLUME_UP: + case VK_MEDIA_NEXT_TRACK: + case VK_MEDIA_PREV_TRACK: + case VK_MEDIA_STOP: + case VK_MEDIA_PLAY_PAUSE: + case VK_BROWSER_BACK: + case VK_BROWSER_FORWARD: + case VK_BROWSER_REFRESH: + case VK_BROWSER_STOP: + case VK_BROWSER_SEARCH: + case VK_BROWSER_FAVORITES: + case VK_BROWSER_HOME: + case VK_LAUNCH_MAIL: + return KEYEVENTF_EXTENDEDKEY; + default: + return 0; + } +} + +/* Helper: add a key input to an INPUT array */ +static inline void addKeyInput(INPUT *input, int key, DWORD flags) { + input->type = INPUT_KEYBOARD; + input->ki.wVk = key; + input->ki.wScan = MapVirtualKey(key & 0xff, MAPVK_VK_TO_VSC); + input->ki.dwFlags = flags | getExtendedKeyFlags(key); + input->ki.time = 0; + input->ki.dwExtraInfo = 0; +} + +/* Send key event to a specific window via PostMessage */ +void keyEventToWindow(int key, DWORD flags, uintptr pid, int8_t isPid) { + HWND hwnd = getHwnd(pid, isPid); + int msg = (flags & KEYEVENTF_KEYUP) ? WM_KEYUP : WM_KEYDOWN; + PostMessageW(hwnd, msg, key, 0); +} + +/* + * keyTap - Atomic key tap (press + release) with modifiers + * + * Press order: Modifiers -> Main key + * Release order: Main key -> Modifiers (LIFO) + */ +int keyTap(MMKeyCode code, MMKeyFlags flags) { + INPUT inputs[10]; + int count = 0; + + /* Press: modifiers -> main key */ + if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, 0); } + if (flags & MOD_ALT) { addKeyInput(&inputs[count++], K_ALT, 0); } + if (flags & MOD_CONTROL) { addKeyInput(&inputs[count++], K_CONTROL, 0); } + if (flags & MOD_SHIFT) { addKeyInput(&inputs[count++], K_SHIFT, 0); } + addKeyInput(&inputs[count++], code, 0); + + /* Release: main key -> modifiers (LIFO) */ + addKeyInput(&inputs[count++], code, KEYEVENTF_KEYUP); + if (flags & MOD_SHIFT) { addKeyInput(&inputs[count++], K_SHIFT, KEYEVENTF_KEYUP); } + if (flags & MOD_CONTROL) { addKeyInput(&inputs[count++], K_CONTROL, KEYEVENTF_KEYUP); } + if (flags & MOD_ALT) { addKeyInput(&inputs[count++], K_ALT, KEYEVENTF_KEYUP); } + if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, KEYEVENTF_KEYUP); } + + return SendInput(count, inputs, sizeof(INPUT)) == count ? 0 : GetLastError(); +} + +/* + * keyToggle - Atomic key toggle (press or release) with modifiers + * + * down=true: Modifiers -> Main key (press order) + * down=false: Main key -> Modifiers (release order, LIFO) + */ +int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { + INPUT inputs[5]; + int count = 0; + const DWORD dwFlags = down ? 0 : KEYEVENTF_KEYUP; + + if (down) { + /* Press: modifiers -> main key */ + if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, dwFlags); } + if (flags & MOD_ALT) { addKeyInput(&inputs[count++], K_ALT, dwFlags); } + if (flags & MOD_CONTROL) { addKeyInput(&inputs[count++], K_CONTROL, dwFlags); } + if (flags & MOD_SHIFT) { addKeyInput(&inputs[count++], K_SHIFT, dwFlags); } + addKeyInput(&inputs[count++], code, dwFlags); + } else { + /* Release: main key -> modifiers (LIFO) */ + addKeyInput(&inputs[count++], code, dwFlags); + if (flags & MOD_SHIFT) { addKeyInput(&inputs[count++], K_SHIFT, dwFlags); } + if (flags & MOD_CONTROL) { addKeyInput(&inputs[count++], K_CONTROL, dwFlags); } + if (flags & MOD_ALT) { addKeyInput(&inputs[count++], K_ALT, dwFlags); } + if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, dwFlags); } + } + + return SendInput(count, inputs, sizeof(INPUT)) == count ? 0 : GetLastError(); +} + +/* + * keyTapPid - Key tap to a specific process (non-atomic, uses PostMessage on Windows) + */ +int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { + /* Windows: use PostMessage (non-atomic) */ + if (flags & MOD_META) { keyEventToWindow(K_META, 0, pid, 0); } + if (flags & MOD_ALT) { keyEventToWindow(K_ALT, 0, pid, 0); } + if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, 0, pid, 0); } + if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, 0, pid, 0); } + keyEventToWindow(code, 0, pid, 0); + + keyEventToWindow(code, KEYEVENTF_KEYUP, pid, 0); + if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, KEYEVENTF_KEYUP, pid, 0); } + if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, KEYEVENTF_KEYUP, pid, 0); } + if (flags & MOD_ALT) { keyEventToWindow(K_ALT, KEYEVENTF_KEYUP, pid, 0); } + if (flags & MOD_META) { keyEventToWindow(K_META, KEYEVENTF_KEYUP, pid, 0); } + return 0; +} + +/* + * keyTogglePid - Key toggle to a specific process + */ +int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) { + DWORD dwFlags = down ? 0 : KEYEVENTF_KEYUP; + + if (down) { + if (flags & MOD_META) { keyEventToWindow(K_META, dwFlags, pid, 0); } + if (flags & MOD_ALT) { keyEventToWindow(K_ALT, dwFlags, pid, 0); } + if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, dwFlags, pid, 0); } + if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, dwFlags, pid, 0); } + keyEventToWindow(code, dwFlags, pid, 0); + } else { + keyEventToWindow(code, dwFlags, pid, 0); + if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, dwFlags, pid, 0); } + if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, dwFlags, pid, 0); } + if (flags & MOD_ALT) { keyEventToWindow(K_ALT, dwFlags, pid, 0); } + if (flags & MOD_META) { keyEventToWindow(K_META, dwFlags, pid, 0); } + } + return 0; +} + +/* + * Legacy functions for compatibility + */ +void toggleKey(char c, const bool down, MMKeyFlags flags, uintptr pid) { + MMKeyCode keyCode = keyCodeForChar(c); + + if (isupper(c) && !(flags & MOD_SHIFT)) { + flags |= MOD_SHIFT; + } + + int modifiers = keyCode >> 8; + if ((modifiers & 1) != 0) { flags |= MOD_SHIFT; } + if ((modifiers & 2) != 0) { flags |= MOD_CONTROL; } + if ((modifiers & 4) != 0) { flags |= MOD_ALT; } + keyCode = keyCode & 0xff; + + if (pid != 0) { + keyTogglePid(keyCode, down, flags, pid); + } else { + keyToggle(keyCode, down, flags); + } +} + +void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { + if (pid != 0) { + HWND hwnd = getHwnd(pid, isPid); + PostMessageW(hwnd, WM_CHAR, value, 0); + return; + } + + INPUT input[2]; + memset(input, 0, sizeof(input)); + + input[0].type = INPUT_KEYBOARD; + input[0].ki.wVk = 0; + input[0].ki.wScan = value; + input[0].ki.dwFlags = 0x4; // KEYEVENTF_UNICODE + + input[1].type = INPUT_KEYBOARD; + input[1].ki.wVk = 0; + input[1].ki.wScan = value; + input[1].ki.dwFlags = KEYEVENTF_KEYUP | 0x4; + + SendInput(2, input, sizeof(INPUT)); +} + +int input_utf(const char *utf) { + return 0; +} diff --git a/key/keypress_c_x11.h b/key/keypress_c_x11.h new file mode 100644 index 0000000..a168f00 --- /dev/null +++ b/key/keypress_c_x11.h @@ -0,0 +1,183 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "../base/deadbeef_rand_c.h" +#include "../base/microsleep.h" +#include "../base/xdisplay_c.h" +#include "keypress.h" +#include "keycode_c.h" + +#include /* For isupper() */ +#include + +/* + * keyTap - Atomic key tap (press + release) with modifiers + * + * Press order: Modifiers -> Main key + * Release order: Main key -> Modifiers (LIFO) + */ +int keyTap(MMKeyCode code, MMKeyFlags flags) { + Display *display = XGetMainDisplay(); + + /* Press: modifiers -> main key */ + if (flags & MOD_META) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_META), True, CurrentTime); + } + if (flags & MOD_ALT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_ALT), True, CurrentTime); + } + if (flags & MOD_CONTROL) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_CONTROL), True, CurrentTime); + } + if (flags & MOD_SHIFT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_SHIFT), True, CurrentTime); + } + XTestFakeKeyEvent(display, XKeysymToKeycode(display, code), True, CurrentTime); + + /* Release: main key -> modifiers (LIFO) */ + XTestFakeKeyEvent(display, XKeysymToKeycode(display, code), False, CurrentTime); + if (flags & MOD_SHIFT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_SHIFT), False, CurrentTime); + } + if (flags & MOD_CONTROL) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_CONTROL), False, CurrentTime); + } + if (flags & MOD_ALT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_ALT), False, CurrentTime); + } + if (flags & MOD_META) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_META), False, CurrentTime); + } + + XSync(display, false); + return 0; +} + +/* + * keyToggle - Atomic key toggle (press or release) with modifiers + * + * down=true: Modifiers -> Main key (press order) + * down=false: Main key -> Modifiers (release order, LIFO) + */ +int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { + Display *display = XGetMainDisplay(); + const Bool is_press = down ? True : False; + + if (down) { + /* Press: modifiers -> main key */ + if (flags & MOD_META) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_META), is_press, CurrentTime); + } + if (flags & MOD_ALT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_ALT), is_press, CurrentTime); + } + if (flags & MOD_CONTROL) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_CONTROL), is_press, CurrentTime); + } + if (flags & MOD_SHIFT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_SHIFT), is_press, CurrentTime); + } + XTestFakeKeyEvent(display, XKeysymToKeycode(display, code), is_press, CurrentTime); + } else { + /* Release: main key -> modifiers (LIFO) */ + XTestFakeKeyEvent(display, XKeysymToKeycode(display, code), is_press, CurrentTime); + if (flags & MOD_SHIFT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_SHIFT), is_press, CurrentTime); + } + if (flags & MOD_CONTROL) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_CONTROL), is_press, CurrentTime); + } + if (flags & MOD_ALT) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_ALT), is_press, CurrentTime); + } + if (flags & MOD_META) { + XTestFakeKeyEvent(display, XKeysymToKeycode(display, K_META), is_press, CurrentTime); + } + } + + XSync(display, false); + return 0; +} + +/* + * keyTapPid - Key tap to a specific process (non-atomic, uses PostMessage on Windows) + */ +int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { + /* X11: no direct PID support, fall back to global */ + return keyTap(code, flags); +} + +/* + * keyTogglePid - Key toggle to a specific process + */ +int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) { + return keyToggle(code, down, flags); +} + +/* + * Legacy functions for compatibility + */ +bool toUpper(char c) { + if (isupper(c)) { + return true; + } + char *special = "~!@#$%^&*()_+{}|:\"<>?"; + while (*special) { + if (*special == c) { + return true; + } + special++; + } + return false; +} + +void toggleKey(char c, const bool down, MMKeyFlags flags, uintptr pid) { + MMKeyCode keyCode = keyCodeForChar(c); + + if (toUpper(c) && !(flags & MOD_SHIFT)) { + flags |= MOD_SHIFT; + } + + if (pid != 0) { + keyTogglePid(keyCode, down, flags, pid); + } else { + keyToggle(keyCode, down, flags); + } +} + +#define toggleUniKey(c, down) toggleKey(c, down, MOD_NONE, 0) + +void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { + toggleUniKey(value, true); + microsleep(5.0); + toggleUniKey(value, false); +} + +int input_utf(const char *utf) { + Display *dpy = XOpenDisplay(NULL); + KeySym sym = XStringToKeysym(utf); + + int min, max, numcodes; + XDisplayKeycodes(dpy, &min, &max); + KeySym *keysym; + keysym = XGetKeyboardMapping(dpy, min, max-min+1, &numcodes); + keysym[(max-min-1)*numcodes] = sym; + XChangeKeyboardMapping(dpy, min, numcodes, keysym, (max-min)); + XFree(keysym); + XFlush(dpy); + + KeyCode code = XKeysymToKeycode(dpy, sym); + XTestFakeKeyEvent(dpy, code, True, 1); + XTestFakeKeyEvent(dpy, code, False, 1); + + XFlush(dpy); + XCloseDisplay(dpy); + return 0; +} diff --git a/keyboard.go b/keyboard.go new file mode 100644 index 0000000..04d5838 --- /dev/null +++ b/keyboard.go @@ -0,0 +1,594 @@ +package deskact + +/* +#include "base/types.h" +#include "base/pubs.h" +#include "key/keypress_c.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "math/rand" + "runtime" + "strconv" + "strings" + "syscall" + "unicode" + "unsafe" +) + +// Defining a bunch of constants. +const ( + // KeyA define key "a" + KeyA = "a" + KeyB = "b" + KeyC = "c" + KeyD = "d" + KeyE = "e" + KeyF = "f" + KeyG = "g" + KeyH = "h" + KeyI = "i" + KeyJ = "j" + KeyK = "k" + KeyL = "l" + KeyM = "m" + KeyN = "n" + KeyO = "o" + KeyP = "p" + KeyQ = "q" + KeyR = "r" + KeyS = "s" + KeyT = "t" + KeyU = "u" + KeyV = "v" + KeyW = "w" + KeyX = "x" + KeyY = "y" + KeyZ = "z" + // + CapA = "A" + CapB = "B" + CapC = "C" + CapD = "D" + CapE = "E" + CapF = "F" + CapG = "G" + CapH = "H" + CapI = "I" + CapJ = "J" + CapK = "K" + CapL = "L" + CapM = "M" + CapN = "N" + CapO = "O" + CapP = "P" + CapQ = "Q" + CapR = "R" + CapS = "S" + CapT = "T" + CapU = "U" + CapV = "V" + CapW = "W" + CapX = "X" + CapY = "Y" + CapZ = "Z" + // + Key0 = "0" + Key1 = "1" + Key2 = "2" + Key3 = "3" + Key4 = "4" + Key5 = "5" + Key6 = "6" + Key7 = "7" + Key8 = "8" + Key9 = "9" + + // Backspace backspace key string + Backspace = "backspace" + Delete = "delete" + Enter = "enter" + Tab = "tab" + Esc = "esc" + Escape = "escape" + Up = "up" // Up arrow key + Down = "down" // Down arrow key + Right = "right" // Right arrow key + Left = "left" // Left arrow key + Home = "home" + End = "end" + Pageup = "pageup" + Pagedown = "pagedown" + + F1 = "f1" + F2 = "f2" + F3 = "f3" + F4 = "f4" + F5 = "f5" + F6 = "f6" + F7 = "f7" + F8 = "f8" + F9 = "f9" + F10 = "f10" + F11 = "f11" + F12 = "f12" + F13 = "f13" + F14 = "f14" + F15 = "f15" + F16 = "f16" + F17 = "f17" + F18 = "f18" + F19 = "f19" + F20 = "f20" + F21 = "f21" + F22 = "f22" + F23 = "f23" + F24 = "f24" + + Cmd = "cmd" // is the "win" key for windows + Lcmd = "lcmd" // left command + Rcmd = "rcmd" // right command + // "command" + Alt = "alt" + Lalt = "lalt" // left alt + Ralt = "ralt" // right alt + Ctrl = "ctrl" + Lctrl = "lctrl" // left ctrl + Rctrl = "rctrl" // right ctrl + Control = "control" + Shift = "shift" + Lshift = "lshift" // left shift + Rshift = "rshift" // right shift + // "right_shift" + Capslock = "capslock" + Space = "space" + Print = "print" + Printscreen = "printscreen" // No Mac support + Insert = "insert" + Menu = "menu" // Windows only + + AudioMute = "audio_mute" // Mute the volume + AudioVolDown = "audio_vol_down" // Lower the volume + AudioVolUp = "audio_vol_up" // Increase the volume + AudioPlay = "audio_play" + AudioStop = "audio_stop" + AudioPause = "audio_pause" + AudioPrev = "audio_prev" // Previous Track + AudioNext = "audio_next" // Next Track + AudioRewind = "audio_rewind" // Linux only + AudioForward = "audio_forward" // Linux only + AudioRepeat = "audio_repeat" // Linux only + AudioRandom = "audio_random" // Linux only + + Num0 = "num0" // numpad 0 + Num1 = "num1" + Num2 = "num2" + Num3 = "num3" + Num4 = "num4" + Num5 = "num5" + Num6 = "num6" + Num7 = "num7" + Num8 = "num8" + Num9 = "num9" + NumLock = "num_lock" + + NumDecimal = "num." + NumPlus = "num+" + NumMinus = "num-" + NumMul = "num*" + NumDiv = "num/" + NumClear = "num_clear" + NumEnter = "num_enter" + NumEqual = "num_equal" + + LightsMonUp = "lights_mon_up" // Turn up monitor brightness + LightsMonDown = "lights_mon_down" // Turn down monitor brightness + LightsKbdToggle = "lights_kbd_toggle" // Toggle keyboard backlight on/off + LightsKbdUp = "lights_kbd_up" // Turn up keyboard backlight brightness + LightsKbdDown = "lights_kbd_down" +) + +// Modifier represents a keyboard modifier key (ctrl, alt, shift, cmd). +type Modifier string + +// Modifier key constants. +const ( + ModNone Modifier = "" + ModAlt Modifier = "alt" + ModCtrl Modifier = "ctrl" + ModShift Modifier = "shift" + ModCmd Modifier = "cmd" // macOS Command / Windows Win key +) + +const keyErrMessage = "Invalid key flag specified." +const keyActionErrMessage = "key action failed" + +var ErrKeyActionFailed = errors.New(keyActionErrMessage) + +func keyActionError(op, key string, pid int, code C.int) error { + if code == 0 { + return nil + } + + detail := cErrorDetail(code) + if pid > 0 { + return fmt.Errorf("%w: %s(%q) pid=%d: %s", ErrKeyActionFailed, op, key, pid, detail) + } + return fmt.Errorf("%w: %s(%q): %s", ErrKeyActionFailed, op, key, detail) +} + +func cErrorDetail(code C.int) string { + if code > 0 { + return fmt.Sprintf("%s (code=%d)", syscall.Errno(code), int(code)) + } + return fmt.Sprintf("code=%d", int(code)) +} + +func keyNameMap() map[string]C.MMKeyCode { + return map[string]C.MMKeyCode{ + "backspace": C.K_BACKSPACE, + "delete": C.K_DELETE, + "enter": C.K_RETURN, + "tab": C.K_TAB, + "esc": C.K_ESCAPE, + "escape": C.K_ESCAPE, + "up": C.K_UP, + "down": C.K_DOWN, + "right": C.K_RIGHT, + "left": C.K_LEFT, + "home": C.K_HOME, + "end": C.K_END, + "pageup": C.K_PAGEUP, + "pagedown": C.K_PAGEDOWN, + // + "f1": C.K_F1, + "f2": C.K_F2, + "f3": C.K_F3, + "f4": C.K_F4, + "f5": C.K_F5, + "f6": C.K_F6, + "f7": C.K_F7, + "f8": C.K_F8, + "f9": C.K_F9, + "f10": C.K_F10, + "f11": C.K_F11, + "f12": C.K_F12, + "f13": C.K_F13, + "f14": C.K_F14, + "f15": C.K_F15, + "f16": C.K_F16, + "f17": C.K_F17, + "f18": C.K_F18, + "f19": C.K_F19, + "f20": C.K_F20, + "f21": C.K_F21, + "f22": C.K_F22, + "f23": C.K_F23, + "f24": C.K_F24, + // + "cmd": C.K_META, + "lcmd": C.K_LMETA, + "rcmd": C.K_RMETA, + "command": C.K_META, + "alt": C.K_ALT, + "lalt": C.K_LALT, + "ralt": C.K_RALT, + "ctrl": C.K_CONTROL, + "lctrl": C.K_LCONTROL, + "rctrl": C.K_RCONTROL, + "control": C.K_CONTROL, + "shift": C.K_SHIFT, + "lshift": C.K_LSHIFT, + "rshift": C.K_RSHIFT, + "right_shift": C.K_RSHIFT, + "capslock": C.K_CAPSLOCK, + "space": C.K_SPACE, + "print": C.K_PRINTSCREEN, + "printscreen": C.K_PRINTSCREEN, + "insert": C.K_INSERT, + "menu": C.K_MENU, + + "audio_mute": C.K_AUDIO_VOLUME_MUTE, + "audio_vol_down": C.K_AUDIO_VOLUME_DOWN, + "audio_vol_up": C.K_AUDIO_VOLUME_UP, + "audio_play": C.K_AUDIO_PLAY, + "audio_stop": C.K_AUDIO_STOP, + "audio_pause": C.K_AUDIO_PAUSE, + "audio_prev": C.K_AUDIO_PREV, + "audio_next": C.K_AUDIO_NEXT, + "audio_rewind": C.K_AUDIO_REWIND, + "audio_forward": C.K_AUDIO_FORWARD, + "audio_repeat": C.K_AUDIO_REPEAT, + "audio_random": C.K_AUDIO_RANDOM, + + "num0": C.K_NUMPAD_0, + "num1": C.K_NUMPAD_1, + "num2": C.K_NUMPAD_2, + "num3": C.K_NUMPAD_3, + "num4": C.K_NUMPAD_4, + "num5": C.K_NUMPAD_5, + "num6": C.K_NUMPAD_6, + "num7": C.K_NUMPAD_7, + "num8": C.K_NUMPAD_8, + "num9": C.K_NUMPAD_9, + "num_lock": C.K_NUMPAD_LOCK, + + // todo: removed + "numpad_0": C.K_NUMPAD_0, + "numpad_1": C.K_NUMPAD_1, + "numpad_2": C.K_NUMPAD_2, + "numpad_3": C.K_NUMPAD_3, + "numpad_4": C.K_NUMPAD_4, + "numpad_5": C.K_NUMPAD_5, + "numpad_6": C.K_NUMPAD_6, + "numpad_7": C.K_NUMPAD_7, + "numpad_8": C.K_NUMPAD_8, + "numpad_9": C.K_NUMPAD_9, + "numpad_lock": C.K_NUMPAD_LOCK, + + "num.": C.K_NUMPAD_DECIMAL, + "num+": C.K_NUMPAD_PLUS, + "num-": C.K_NUMPAD_MINUS, + "num*": C.K_NUMPAD_MUL, + "num/": C.K_NUMPAD_DIV, + "num_clear": C.K_NUMPAD_CLEAR, + "num_enter": C.K_NUMPAD_ENTER, + "num_equal": C.K_NUMPAD_EQUAL, + + "lights_mon_up": C.K_LIGHTS_MON_UP, + "lights_mon_down": C.K_LIGHTS_MON_DOWN, + "lights_kbd_toggle": C.K_LIGHTS_KBD_TOGGLE, + "lights_kbd_up": C.K_LIGHTS_KBD_UP, + "lights_kbd_down": C.K_LIGHTS_KBD_DOWN, + } +} + +// CmdCtrl If the operating system is macOS, return the key string "cmd", +// otherwise return the key string "ctrl". +func CmdCtrl() string { + if runtime.GOOS == "darwin" { + return "cmd" + } + return "ctrl" +} + +func checkKeyCodes(k string) (key C.MMKeyCode, err error) { + if k == "" { + return + } + + if len(k) == 1 { + val1 := C.CString(k) + defer C.free(unsafe.Pointer(val1)) + + key = C.keyCodeForChar(*val1) + if key == C.K_NOT_A_KEY { + err = errors.New(keyErrMessage) + return + } + return + } + + if v, ok := keyNameMap()[k]; ok { + key = v + if key == C.K_NOT_A_KEY { + err = errors.New(keyErrMessage) + return + } + } + return +} + +// modifiersToFlags converts Modifier slice to C.MMKeyFlags. +func modifiersToFlags(modifiers []Modifier) C.MMKeyFlags { + var flags C.MMKeyFlags = C.MOD_NONE + for _, m := range modifiers { + switch m { + case ModAlt: + flags |= C.MOD_ALT + case ModCtrl: + flags |= C.MOD_CONTROL + case ModShift: + flags |= C.MOD_SHIFT + case ModCmd: + flags |= C.MOD_META + } + } + return flags +} + +func appendModifier(modifiers []Modifier, modifier Modifier) []Modifier { + for _, m := range modifiers { + if m == modifier { + return modifiers + } + } + return append(modifiers, modifier) +} + +func normalizeKeyAndModifiers(key string, modifiers []Modifier) (string, []Modifier) { + if len(key) == 1 && unicode.IsUpper([]rune(key)[0]) { + modifiers = appendModifier(modifiers, ModShift) + key = strings.ToLower(key) + } + + if replacement, ok := lookupSpecialKey(key); ok { + key = replacement + modifiers = appendModifier(modifiers, ModShift) + } + + return key, modifiers +} + +// KeyTap taps the keyboard code with optional modifier keys (atomic operation). +func KeyTap(key string, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // Atomic operation - C layer handles platform differences. + ret := C.keyTap(keyCode, flags) + + MilliSleep(settings.Sleep) + return keyActionError("keyTap", key, 0, ret) +} + +// KeyTapWithPID taps the keyboard code on a specific process. +func KeyTapWithPID(key string, pid int, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // PID-specific operation. + ret := C.keyTapPid(keyCode, flags, C.uintptr(pid)) + + MilliSleep(settings.Sleep) + return keyActionError("keyTapPid", key, pid, ret) +} + +// KeyToggle toggles a key up or down with optional modifier keys (atomic operation). +func KeyToggle(key string, down bool, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // Atomic operation - C layer handles platform differences. + ret := C.keyToggle(keyCode, C.bool(down), flags) + + MilliSleep(settings.Sleep) + return keyActionError("keyToggle", key, 0, ret) +} + +// KeyToggleWithPID toggles a key on a specific process. +func KeyToggleWithPID(key string, down bool, pid int, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // PID-specific operation. + ret := C.keyTogglePid(keyCode, C.bool(down), flags, C.uintptr(pid)) + + MilliSleep(settings.Sleep) + return keyActionError("keyTogglePid", key, pid, ret) +} + +// KeyPress press and release a key with random delay (more human-like). +func KeyPress(key string, modifiers []Modifier, settings KeyboardSettings) error { + err := KeyToggle(key, true, modifiers, settings) + if err != nil { + return err + } + + MilliSleep(1 + rand.Intn(3)) + return KeyToggle(key, false, modifiers, settings) +} + +// CharCodeAt char code at utf-8. +func CharCodeAt(s string, n int) rune { + i := 0 + for _, r := range s { + if i == n { + return r + } + i++ + } + + return 0 +} + +// UnicodeType tap the uint32 unicode. +func UnicodeType(str uint32, pid int, isPid bool) { + cstr := C.uint(str) + isPidFlag := C.int8_t(0) + if isPid { + isPidFlag = C.int8_t(1) + } + C.unicodeType(cstr, C.uintptr(pid), isPidFlag) +} + +// ToUC trans string to unicode []string. +func ToUC(text string) []string { + var uc []string + + for _, r := range text { + textQ := strconv.QuoteToASCII(string(r)) + textUnQ := textQ[1 : len(textQ)-1] + + st := strings.Replace(textUnQ, "\\u", "U", -1) + if st == "\\\\" { + st = "\\" + } + if st == `\"` { + st = `"` + } + uc = append(uc, st) + } + + return uc +} + +func inputUTF(str string) { + cstr := C.CString(str) + C.input_utf(cstr) + + C.free(unsafe.Pointer(cstr)) +} + +// Type type a string (supported UTF-8). +func Type(str string, pid int, settings KeyboardSettings) { + tm := settings.TypeDelay + tm1 := settings.TypeUTFDelay + + if runtime.GOOS == "linux" { + strUc := ToUC(str) + for i := 0; i < len(strUc); i++ { + ru := []rune(strUc[i]) + if len(ru) <= 1 { + ustr := uint32(CharCodeAt(strUc[i], 0)) + UnicodeType(ustr, pid, false) + } else { + inputUTF(strUc[i]) + MilliSleep(tm1) + } + + MilliSleep(tm) + } + return + } + + for i := 0; i < len([]rune(str)); i++ { + ustr := uint32(CharCodeAt(str, i)) + UnicodeType(ustr, pid, false) + MilliSleep(tm) + } + MilliSleep(settings.Sleep) +} + +// TypeDelay type string with delayed. +func TypeDelay(str string, pid int, delay int, settings KeyboardSettings) { + Type(str, pid, settings) + MilliSleep(delay) +} diff --git a/mouse.go b/mouse.go new file mode 100644 index 0000000..9baed15 --- /dev/null +++ b/mouse.go @@ -0,0 +1,285 @@ +package deskact + +/* +#include "base/os.h" +#include "mouse/mouse_c.h" +*/ +import "C" + +import ( + "fmt" + "runtime" + "syscall" +) + +const defaultMouseButton = "left" +const defaultScrollDirection = "down" + +func normalizeMouseButton(button string) string { + if button == "" { + return defaultMouseButton + } + return button +} + +func normalizeScrollDirection(direction string) string { + if direction == "" { + return defaultScrollDirection + } + return direction +} + +// CheckMouse check the mouse button. +func CheckMouse(btn string) C.MMMouseButton { + m1 := map[string]C.MMMouseButton{ + "left": C.LEFT_BUTTON, + "center": C.CENTER_BUTTON, + "right": C.RIGHT_BUTTON, + "wheelDown": C.WheelDown, + "wheelUp": C.WheelUp, + "wheelLeft": C.WheelLeft, + "wheelRight": C.WheelRight, + } + if v, ok := m1[btn]; ok { + return v + } + + return C.LEFT_BUTTON +} + +// MouseButtonString converts a C.MMMouseButton to a readable name. +func MouseButtonString(btn C.MMMouseButton) string { + m1 := map[C.MMMouseButton]string{ + C.LEFT_BUTTON: "left", + C.CENTER_BUTTON: "center", + C.RIGHT_BUTTON: "right", + C.WheelDown: "wheelDown", + C.WheelUp: "wheelUp", + C.WheelLeft: "wheelLeft", + C.WheelRight: "wheelRight", + } + if v, ok := m1[btn]; ok { + return v + } + + return fmt.Sprintf("button%d", btn) +} + +// Move move the mouse to (x, y) using absolute coordinates. +func Move(x, y int, settings MouseSettings) { + cx := C.int32_t(x) + cy := C.int32_t(y) + C.moveMouse(C.MMPointInt32Make(cx, cy)) + + MilliSleep(settings.Sleep) +} + +// Drag drag the mouse to (x, y). +// It's not valid now, use the DragSmooth(). +func Drag(x, y int, button string, settings MouseSettings) { + cx := C.int32_t(x) + cy := C.int32_t(y) + + btn := CheckMouse(normalizeMouseButton(button)) + C.dragMouse(C.MMPointInt32Make(cx, cy), btn) + MilliSleep(settings.Sleep) +} + +// DragSmooth drag the mouse like smooth to (x, y). +func DragSmooth(x, y int, settings MouseSettings) { + Toggle(defaultMouseButton, true, false, settings) + MilliSleep(50) + MoveSmooth(x, y, settings) + Toggle(defaultMouseButton, false, false, settings) +} + +// MoveSmooth move the mouse smooth. +func MoveSmooth(x, y int, settings MouseSettings) bool { + cx := C.int32_t(x) + cy := C.int32_t(y) + + low := C.double(settings.MoveSmoothLow) + high := C.double(settings.MoveSmoothHigh) + + cbool := C.smoothlyMoveMouse(C.MMPointInt32Make(cx, cy), low, high) + MilliSleep(settings.Sleep + settings.MoveSmoothDelay) + + return bool(cbool) +} + +// MoveArgs get the mouse relative args. +func MoveArgs(x, y int) (int, int) { + mx, my := Location() + mx = mx + x + my = my + y + + return mx, my +} + +// MoveRelative move mouse with relative. +func MoveRelative(x, y int, settings MouseSettings) { + mx, my := MoveArgs(x, y) + Move(mx, my, settings) +} + +// MoveSmoothRelative move mouse smooth with relative. +func MoveSmoothRelative(x, y int, settings MouseSettings) { + mx, my := MoveArgs(x, y) + MoveSmooth(mx, my, settings) +} + +// Location get the mouse location position return x, y. +func Location() (int, int) { + pos := C.location() + return int(pos.x), int(pos.y) +} + +// Click clicks the mouse button and returns error. +func Click(button string, double bool, settings MouseSettings) error { + btn := CheckMouse(normalizeMouseButton(button)) + + defer MilliSleep(settings.Sleep) + + clickCount := 1 + if double { + clickCount = 2 + } + + if code := C.multiClickErr(btn, C.int(clickCount)); code != 0 { + return formatClickError(int(code), btn, clickCount) + } + + return nil +} + +func formatClickError(code int, button C.MMMouseButton, clickCount int) error { + btnName := MouseButtonString(button) + detail := "" + + switch runtime.GOOS { + case "windows": + if code != 0 { + detail = syscall.Errno(code).Error() + } + case "darwin": + cgErrors := map[int]string{ + 0: "kCGErrorSuccess", + 1000: "kCGErrorFailure", + 1001: "kCGErrorIllegalArgument", + 1002: "kCGErrorInvalidConnection", + 1003: "kCGErrorInvalidContext", + 1004: "kCGErrorCannotComplete", + 1005: "kCGErrorNotImplemented", + 1006: "kCGErrorRangeCheck", + 1007: "kCGErrorTypeCheck", + 1008: "kCGErrorNoCurrentPoint", + 1010: "kCGErrorInvalidOperation", + } + if v, ok := cgErrors[code]; ok { + detail = v + } + default: + if code == 1 { + detail = "XTestFakeButtonEvent returned false" + } + } + + if detail != "" { + return fmt.Errorf("click failed (%s, count=%d): %s (code=%d)", btnName, clickCount, detail, code) + } + + return fmt.Errorf("click failed (%s, count=%d), code=%d", btnName, clickCount, code) +} + +// MultiClick performs multiple clicks and returns error. +func MultiClick(button string, clickCount int, settings MouseSettings) error { + if clickCount < 1 { + return nil + } + + btn := CheckMouse(normalizeMouseButton(button)) + defer MilliSleep(settings.Sleep) + + if code := C.multiClickErr(btn, C.int(clickCount)); code != 0 { + return formatClickError(int(code), btn, clickCount) + } + + return nil +} + +// MoveClick move and click the mouse. +func MoveClick(x, y int, button string, double bool, settings MouseSettings) error { + Move(x, y, settings) + MilliSleep(50) + return Click(button, double, settings) +} + +// MovesClick move smooth and click the mouse. +func MovesClick(x, y int, button string, double bool, settings MouseSettings) error { + MoveSmooth(x, y, settings) + MilliSleep(50) + return Click(button, double, settings) +} + +// Toggle toggle the mouse. +func Toggle(button string, down bool, sleepAfter bool, settings MouseSettings) error { + btn := CheckMouse(normalizeMouseButton(button)) + C.toggleMouseErr(C.bool(down), btn) + if sleepAfter { + MilliSleep(settings.Sleep) + } + + return nil +} + +// Scroll scroll the mouse to (x, y). +func Scroll(x, y int, settings MouseSettings) { + cx := C.int(x) + cy := C.int(y) + + C.scrollMouseXY(cx, cy) + MilliSleep(settings.Sleep + settings.ScrollDelay) +} + +// ScrollDir scroll the mouse with direction. +func ScrollDir(x int, direction string, settings MouseSettings) { + d := normalizeScrollDirection(direction) + + if d == "down" { + Scroll(0, -x, settings) + } + if d == "up" { + Scroll(0, x, settings) + } + + if d == "left" { + Scroll(x, 0, settings) + } + if d == "right" { + Scroll(-x, 0, settings) + } +} + +// ScrollSmooth scroll the mouse smooth. +func ScrollSmooth(to int, settings MouseSettings) { + i := 0 + num := settings.ScrollSmoothCount + tm := settings.ScrollSmoothInterval + tox := settings.ScrollSmoothX + + for { + Scroll(tox, to, settings) + MilliSleep(tm) + i++ + if i == num { + break + } + } + MilliSleep(settings.Sleep) +} + +// ScrollRelative scroll mouse with relative. +func ScrollRelative(x, y int, settings MouseSettings) { + mx, my := MoveArgs(x, y) + Scroll(mx, my, settings) +} diff --git a/mouse/mouse.h b/mouse/mouse.h new file mode 100644 index 0000000..52aba72 --- /dev/null +++ b/mouse/mouse.h @@ -0,0 +1,47 @@ +#pragma once +#ifndef MOUSE_H +#define MOUSE_H + +#include "../base/os.h" +#include "../base/types.h" +#include + +#if defined(IS_MACOSX) + #include + + typedef enum { + LEFT_BUTTON = kCGMouseButtonLeft, + RIGHT_BUTTON = kCGMouseButtonRight, + CENTER_BUTTON = kCGMouseButtonCenter, + WheelDown = 4, + WheelUp = 5, + WheelLeft = 6, + WheelRight = 7, + } MMMouseButton; +#elif defined(USE_X11) + enum _MMMouseButton { + LEFT_BUTTON = 1, + CENTER_BUTTON = 2, + RIGHT_BUTTON = 3, + WheelDown = 4, + WheelUp = 5, + WheelLeft = 6, + WheelRight = 7, + }; + typedef unsigned int MMMouseButton; +#elif defined(IS_WINDOWS) + enum _MMMouseButton { + LEFT_BUTTON = 1, + CENTER_BUTTON = 2, + RIGHT_BUTTON = 3, + WheelDown = 4, + WheelUp = 5, + WheelLeft = 6, + WheelRight = 7, + }; + typedef unsigned int MMMouseButton; +#else + #error "No mouse button constants set for platform" +#endif + +#endif /* MOUSE_H */ \ No newline at end of file diff --git a/mouse/mouse_c.h b/mouse/mouse_c.h new file mode 100644 index 0000000..8bcf901 --- /dev/null +++ b/mouse/mouse_c.h @@ -0,0 +1,17 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#if defined(IS_MACOSX) + #include "mouse_c_macos.h" +#elif defined(USE_X11) + #include "mouse_c_x11.h" +#elif defined(IS_WINDOWS) + #include "mouse_c_windows.h" +#endif diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h new file mode 100644 index 0000000..03231c1 --- /dev/null +++ b/mouse/mouse_c_macos.h @@ -0,0 +1,208 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "mouse.h" +#include "../base/deadbeef_rand.h" +#include "../base/microsleep.h" + +#include /* For floor() */ +// #include +#include +// #include + +/* Some convenience macros for converting our enums to the system API types. */ +CGEventType MMMouseDownToCGEventType(MMMouseButton button) { + if (button == LEFT_BUTTON) { + return kCGEventLeftMouseDown; + } + if (button == RIGHT_BUTTON) { + return kCGEventRightMouseDown; + } + return kCGEventOtherMouseDown; +} + +CGEventType MMMouseUpToCGEventType(MMMouseButton button) { + if (button == LEFT_BUTTON) { return kCGEventLeftMouseUp; } + if (button == RIGHT_BUTTON) { return kCGEventRightMouseUp; } + return kCGEventOtherMouseUp; +} + +CGEventType MMMouseDragToCGEventType(MMMouseButton button) { + if (button == LEFT_BUTTON) { return kCGEventLeftMouseDragged; } + if (button == RIGHT_BUTTON) { return kCGEventRightMouseDragged; } + return kCGEventOtherMouseDragged; +} + +CGEventType MMMouseToCGEventType(bool down, MMMouseButton button) { + if (down) { return MMMouseDownToCGEventType(button); } + return MMMouseUpToCGEventType(button); +} + +/* Calculate the delta for a mouse move and add them to the event. */ +void calculateDeltas(CGEventRef *event, MMPointInt32 point) { + /* The next few lines are a workaround for games not detecting mouse moves. */ + CGEventRef get = CGEventCreate(NULL); + CGPoint mouse = CGEventGetLocation(get); + + // Calculate the deltas. + int64_t deltaX = point.x - mouse.x; + int64_t deltaY = point.y - mouse.y; + + CGEventSetIntegerValueField(*event, kCGMouseEventDeltaX, deltaX); + CGEventSetIntegerValueField(*event, kCGMouseEventDeltaY, deltaY); + + CFRelease(get); +} + +/* Move the mouse to a specific point. */ +void moveMouse(MMPointInt32 point){ + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef move = CGEventCreateMouseEvent(source, kCGEventMouseMoved, + CGPointFromMMPointInt32(point), kCGMouseButtonLeft); + + calculateDeltas(&move, point); + + CGEventPost(kCGHIDEventTap, move); + CFRelease(move); + CFRelease(source); +} + +void dragMouse(MMPointInt32 point, const MMMouseButton button){ + const CGEventType dragType = MMMouseDragToCGEventType(button); + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef drag = CGEventCreateMouseEvent(source, dragType, + CGPointFromMMPointInt32(point), (CGMouseButton)button); + + calculateDeltas(&drag, point); + + CGEventPost(kCGHIDEventTap, drag); + CFRelease(drag); + CFRelease(source); +} + +MMPointInt32 location() { + CGEventRef event = CGEventCreate(NULL); + CGPoint point = CGEventGetLocation(event); + CFRelease(event); + + return MMPointInt32FromCGPoint(point); +} + +/* Press down a button, or release it. */ +int toggleMouseErr(bool down, MMMouseButton button) { + const CGPoint currentPos = CGPointFromMMPointInt32(location()); + const CGEventType mouseType = MMMouseToCGEventType(down, button); + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef event = CGEventCreateMouseEvent(source, mouseType, currentPos, (CGMouseButton)button); + + if (event == NULL) { + CFRelease(source); + return (int)kCGErrorCannotComplete; + } + + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + CFRelease(source); + + return 0; +} + +/* Multi-click function supporting any click count (1=single, 2=double, 3=triple, etc.) */ +int multiClickErr(MMMouseButton button, int clickCount){ + if (clickCount < 1) { + return 0; + } + + const CGPoint currentPos = CGPointFromMMPointInt32(location()); + const CGEventType mouseTypeDown = MMMouseToCGEventType(true, button); + const CGEventType mouseTypeUP = MMMouseToCGEventType(false, button); + + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef event = CGEventCreateMouseEvent(source, mouseTypeDown, currentPos, (CGMouseButton)button); + + if (event == NULL) { + CFRelease(source); + return (int)kCGErrorCannotComplete; + } + + CGEventSetIntegerValueField(event, kCGMouseEventClickState, clickCount); + CGEventPost(kCGHIDEventTap, event); + + CGEventSetType(event, mouseTypeUP); + CGEventPost(kCGHIDEventTap, event); + + CFRelease(event); + CFRelease(source); + + return 0; +} + +/* Function used to scroll the screen in the required direction. */ +void scrollMouseXY(int x, int y) { + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGEventRef event = CGEventCreateScrollWheelEvent(source, kCGScrollEventUnitPixel, 2, y, x); + CGEventPost(kCGHIDEventTap, event); + + CFRelease(event); + CFRelease(source); +} + +/* A crude, fast hypot() approximation to get around the fact that hypot() is not a standard ANSI C function. */ +#if !defined(M_SQRT2) + #define M_SQRT2 1.4142135623730950488016887 /* Fix for MSVC. */ +#endif + +static double crude_hypot(double x, double y){ + double big = fabs(x); /* max(|x|, |y|) */ + double small = fabs(y); /* min(|x|, |y|) */ + + if (big > small) { + double temp = big; + big = small; + small = temp; + } + + return ((M_SQRT2 - 1.0) * small) + big; +} + +bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = location(); + // MMSizeInt32 screenSize = getMainDisplaySize(); + double velo_x = 0.0, velo_y = 0.0; + double distance; + + while ((distance =crude_hypot((double)pos.x - endPoint.x, (double)pos.y - endPoint.y)) > 1.0) { + double gravity = DEADBEEF_UNIFORM(5.0, 500.0); + // double gravity = DEADBEEF_UNIFORM(lowSpeed, highSpeed); + double veloDistance; + velo_x += (gravity * ((double)endPoint.x - pos.x)) / distance; + velo_y += (gravity * ((double)endPoint.y - pos.y)) / distance; + + /* Normalize velocity to get a unit vector of length 1. */ + veloDistance = crude_hypot(velo_x, velo_y); + velo_x /= veloDistance; + velo_y /= veloDistance; + + pos.x += floor(velo_x + 0.5); + pos.y += floor(velo_y + 0.5); + + /* Make sure we are in the screen boundaries! (Strange things will happen if we are not.) */ + // if (pos.x >= screenSize.w || pos.y >= screenSize.h) { + // return false; + // } + moveMouse(pos); + + /* Wait 1 - 3 milliseconds. */ + microsleep(DEADBEEF_UNIFORM(lowSpeed, highSpeed)); + // microsleep(DEADBEEF_UNIFORM(1.0, 3.0)); + } + + return true; +} diff --git a/mouse/mouse_c_windows.h b/mouse/mouse_c_windows.h new file mode 100644 index 0000000..25a8406 --- /dev/null +++ b/mouse/mouse_c_windows.h @@ -0,0 +1,167 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "mouse.h" +#include "../base/deadbeef_rand.h" +#include "../base/microsleep.h" + +#include /* For floor() */ + +/* Some convenience macros for converting our enums to the system API types. */ +DWORD MMMouseUpToMEventF(MMMouseButton button) { + if (button == LEFT_BUTTON) { return MOUSEEVENTF_LEFTUP; } + if (button == RIGHT_BUTTON) { return MOUSEEVENTF_RIGHTUP; } + return MOUSEEVENTF_MIDDLEUP; +} + +DWORD MMMouseDownToMEventF(MMMouseButton button) { + if (button == LEFT_BUTTON) { return MOUSEEVENTF_LEFTDOWN; } + if (button == RIGHT_BUTTON) { return MOUSEEVENTF_RIGHTDOWN; } + return MOUSEEVENTF_MIDDLEDOWN; +} + +DWORD MMMouseToMEventF(bool down, MMMouseButton button) { + if (down) { return MMMouseDownToMEventF(button); } + return MMMouseUpToMEventF(button); +} + +/* Move the mouse to a specific point. */ +void moveMouse(MMPointInt32 point){ + SetPhysicalCursorPos(point.x, point.y); +} + +void dragMouse(MMPointInt32 point, const MMMouseButton button){ + moveMouse(point); +} + +MMPointInt32 location() { + POINT point; + GetPhysicalCursorPos(&point); + return MMPointInt32FromPOINT(point); +} + +/* Press down a button, or release it. */ +int toggleMouseErr(bool down, MMMouseButton button) { + // mouse_event(MMMouseToMEventF(down, button), 0, 0, 0, 0); + INPUT mouseInput; + + mouseInput.type = INPUT_MOUSE; + mouseInput.mi.dx = 0; + mouseInput.mi.dy = 0; + mouseInput.mi.dwFlags = MMMouseToMEventF(down, button); + mouseInput.mi.time = 0; + mouseInput.mi.dwExtraInfo = 0; + mouseInput.mi.mouseData = 0; + UINT sent = SendInput(1, &mouseInput, sizeof(mouseInput)); + return sent == 1 ? 0 : (int)GetLastError(); +} + +/* Multi-click function supporting any click count (1=single, 2=double, 3=triple, etc.) */ +int multiClickErr(MMMouseButton button, int clickCount){ + if (clickCount < 1) { + return 0; + } + + int i; + for (i = 0; i < clickCount; i++) { + int err = toggleMouseErr(true, button); + if (err != 0) { + return err; + } + microsleep(5.0); + err = toggleMouseErr(false, button); + if (err != 0) { + return err; + } + if (i < clickCount - 1) { + microsleep(200); + } + } + return 0; +} + +/* Function used to scroll the screen in the required direction. */ +void scrollMouseXY(int x, int y) { + // Fix for #97, C89 needs variables declared on top of functions (mouseScrollInput) + INPUT mouseScrollInputH; + INPUT mouseScrollInputV; + + mouseScrollInputH.type = INPUT_MOUSE; + mouseScrollInputH.mi.dx = 0; + mouseScrollInputH.mi.dy = 0; + mouseScrollInputH.mi.dwFlags = MOUSEEVENTF_WHEEL; + mouseScrollInputH.mi.time = 0; + mouseScrollInputH.mi.dwExtraInfo = 0; + mouseScrollInputH.mi.mouseData = WHEEL_DELTA * x; + + mouseScrollInputV.type = INPUT_MOUSE; + mouseScrollInputV.mi.dx = 0; + mouseScrollInputV.mi.dy = 0; + mouseScrollInputV.mi.dwFlags = MOUSEEVENTF_WHEEL; + mouseScrollInputV.mi.time = 0; + mouseScrollInputV.mi.dwExtraInfo = 0; + mouseScrollInputV.mi.mouseData = WHEEL_DELTA * y; + + SendInput(1, &mouseScrollInputH, sizeof(mouseScrollInputH)); + SendInput(1, &mouseScrollInputV, sizeof(mouseScrollInputV)); +} + +/* A crude, fast hypot() approximation to get around the fact that hypot() is not a standard ANSI C function. */ +#if !defined(M_SQRT2) + #define M_SQRT2 1.4142135623730950488016887 /* Fix for MSVC. */ +#endif + +static double crude_hypot(double x, double y){ + double big = fabs(x); /* max(|x|, |y|) */ + double small = fabs(y); /* min(|x|, |y|) */ + + if (big > small) { + double temp = big; + big = small; + small = temp; + } + + return ((M_SQRT2 - 1.0) * small) + big; +} + +bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = location(); + // MMSizeInt32 screenSize = getMainDisplaySize(); + double velo_x = 0.0, velo_y = 0.0; + double distance; + + while ((distance =crude_hypot((double)pos.x - endPoint.x, (double)pos.y - endPoint.y)) > 1.0) { + double gravity = DEADBEEF_UNIFORM(5.0, 500.0); + // double gravity = DEADBEEF_UNIFORM(lowSpeed, highSpeed); + double veloDistance; + velo_x += (gravity * ((double)endPoint.x - pos.x)) / distance; + velo_y += (gravity * ((double)endPoint.y - pos.y)) / distance; + + /* Normalize velocity to get a unit vector of length 1. */ + veloDistance = crude_hypot(velo_x, velo_y); + velo_x /= veloDistance; + velo_y /= veloDistance; + + pos.x += floor(velo_x + 0.5); + pos.y += floor(velo_y + 0.5); + + /* Make sure we are in the screen boundaries! (Strange things will happen if we are not.) */ + // if (pos.x >= screenSize.w || pos.y >= screenSize.h) { + // return false; + // } + moveMouse(pos); + + /* Wait 1 - 3 milliseconds. */ + microsleep(DEADBEEF_UNIFORM(lowSpeed, highSpeed)); + // microsleep(DEADBEEF_UNIFORM(1.0, 3.0)); + } + + return true; +} diff --git a/mouse/mouse_c_x11.h b/mouse/mouse_c_x11.h new file mode 100644 index 0000000..14643b7 --- /dev/null +++ b/mouse/mouse_c_x11.h @@ -0,0 +1,152 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#include "mouse.h" +#include "../base/deadbeef_rand.h" +#include "../base/microsleep.h" +#include "../base/xdisplay_c.h" + +#include /* For floor() */ +#include +#include +#include + +/* Move the mouse to a specific point. */ +void moveMouse(MMPointInt32 point){ + Display *display = XGetMainDisplay(); + XWarpPointer(display, None, DefaultRootWindow(display), 0, 0, 0, 0, point.x, point.y); + + XSync(display, false); +} + +void dragMouse(MMPointInt32 point, const MMMouseButton button){ + moveMouse(point); +} + +MMPointInt32 location() { + int x, y; /* This is all we care about. Seriously. */ + Window garb1, garb2; /* Why you can't specify NULL as a parameter */ + int garb_x, garb_y; /* is beyond me. */ + unsigned int more_garbage; + + Display *display = XGetMainDisplay(); + XQueryPointer(display, XDefaultRootWindow(display), &garb1, &garb2, &x, &y, + &garb_x, &garb_y, &more_garbage); + + return MMPointInt32Make(x, y); +} + +/* Press down a button, or release it. */ +int toggleMouseErr(bool down, MMMouseButton button) { + Display *display = XGetMainDisplay(); + Status status = XTestFakeButtonEvent(display, button, down ? True : False, CurrentTime); + XSync(display, false); + + return status ? 0 : 1; +} + +/* Multi-click function supporting any click count (1=single, 2=double, 3=triple, etc.) */ +int multiClickErr(MMMouseButton button, int clickCount){ + if (clickCount < 1) { + return 0; + } + + int i; + for (i = 0; i < clickCount; i++) { + int err = toggleMouseErr(true, button); + if (err != 0) { + return err; + } + microsleep(5.0); + err = toggleMouseErr(false, button); + if (err != 0) { + return err; + } + if (i < clickCount - 1) { + microsleep(200); + } + } + return 0; +} + +/* Function used to scroll the screen in the required direction. */ +void scrollMouseXY(int x, int y) { + int ydir = 4; /* Button 4 is up, 5 is down. */ + int xdir = 6; + Display *display = XGetMainDisplay(); + + if (y < 0) { ydir = 5; } + if (x < 0) { xdir = 7; } + + int xi; int yi; + for (xi = 0; xi < abs(x); xi++) { + XTestFakeButtonEvent(display, xdir, 1, CurrentTime); + XTestFakeButtonEvent(display, xdir, 0, CurrentTime); + } + for (yi = 0; yi < abs(y); yi++) { + XTestFakeButtonEvent(display, ydir, 1, CurrentTime); + XTestFakeButtonEvent(display, ydir, 0, CurrentTime); + } + + XSync(display, false); +} + +/* A crude, fast hypot() approximation to get around the fact that hypot() is not a standard ANSI C function. */ +#if !defined(M_SQRT2) + #define M_SQRT2 1.4142135623730950488016887 /* Fix for MSVC. */ +#endif + +static double crude_hypot(double x, double y){ + double big = fabs(x); /* max(|x|, |y|) */ + double small = fabs(y); /* min(|x|, |y|) */ + + if (big > small) { + double temp = big; + big = small; + small = temp; + } + + return ((M_SQRT2 - 1.0) * small) + big; +} + +bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = location(); + // MMSizeInt32 screenSize = getMainDisplaySize(); + double velo_x = 0.0, velo_y = 0.0; + double distance; + + while ((distance =crude_hypot((double)pos.x - endPoint.x, (double)pos.y - endPoint.y)) > 1.0) { + double gravity = DEADBEEF_UNIFORM(5.0, 500.0); + // double gravity = DEADBEEF_UNIFORM(lowSpeed, highSpeed); + double veloDistance; + velo_x += (gravity * ((double)endPoint.x - pos.x)) / distance; + velo_y += (gravity * ((double)endPoint.y - pos.y)) / distance; + + /* Normalize velocity to get a unit vector of length 1. */ + veloDistance = crude_hypot(velo_x, velo_y); + velo_x /= veloDistance; + velo_y /= veloDistance; + + pos.x += floor(velo_x + 0.5); + pos.y += floor(velo_y + 0.5); + + /* Make sure we are in the screen boundaries! (Strange things will happen if we are not.) */ + // if (pos.x >= screenSize.w || pos.y >= screenSize.h) { + // return false; + // } + moveMouse(pos); + + /* Wait 1 - 3 milliseconds. */ + microsleep(DEADBEEF_UNIFORM(lowSpeed, highSpeed)); + // microsleep(DEADBEEF_UNIFORM(1.0, 3.0)); + } + + return true; +} diff --git a/screen/display_c.h b/screen/display_c.h new file mode 100644 index 0000000..2251cdc --- /dev/null +++ b/screen/display_c.h @@ -0,0 +1,24 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#ifndef DISPLAY_C_H +#define DISPLAY_C_H + +#include "../base/os.h" + +#if defined(IS_MACOSX) + #include "display_c_macos.h" +#elif defined(USE_X11) + #include "display_c_x11.h" +#elif defined(IS_WINDOWS) + #include "display_c_windows.h" +#endif + +#endif /* DISPLAY_C_H */ diff --git a/screen/display_c_macos.h b/screen/display_c_macos.h new file mode 100644 index 0000000..8350456 --- /dev/null +++ b/screen/display_c_macos.h @@ -0,0 +1,134 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#ifndef DISPLAY_C_MACOS_H +#define DISPLAY_C_MACOS_H + +#include "../base/types.h" +#include + +// DisplayInfoC contains display information in C struct +typedef struct { + uintptr handle; // CGDirectDisplayID + int32_t index; // Display index + int8_t isMain; // Is main display + int32_t x, y; // Virtual (scaled) coordinates for position + int32_t w, h; // Physical pixel size (not virtual) + double scale; // Scale factor (pixel/virtual) +} DisplayInfoC; + +// Get display count +static int32_t getDisplayCount() { + uint32_t count = 0; + if (CGGetActiveDisplayList(0, NULL, &count) == kCGErrorSuccess) { + return (int32_t)count; + } + return 0; +} + +// Get display info by CGDirectDisplayID +static DisplayInfoC getDisplayInfoById(CGDirectDisplayID displayID, int32_t index) { + DisplayInfoC info = {0}; + + // Get virtual bounds (for position in virtual desktop) + CGRect bounds = CGDisplayBounds(displayID); + + info.handle = (uintptr)displayID; + info.index = index; + info.isMain = (displayID == CGMainDisplayID()) ? 1 : 0; + + // Position uses virtual coordinates (for locating display in virtual desktop) + info.x = (int32_t)bounds.origin.x; + info.y = (int32_t)bounds.origin.y; + + // Get display mode for physical pixel size and scale factor + // Note: CGDisplayPixelsWide/High returns points (not pixels) despite its name + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(displayID); + if (mode != NULL) { + // Physical pixel resolution from display mode + info.w = (int32_t)CGDisplayModeGetPixelWidth(mode); + info.h = (int32_t)CGDisplayModeGetPixelHeight(mode); + + // Calculate scale factor (physical pixels / logical points) + size_t virtualWidth = CGDisplayModeGetWidth(mode); + if (virtualWidth > 0) { + info.scale = (double)info.w / (double)virtualWidth; + } else { + info.scale = 1.0; + } + CGDisplayModeRelease(mode); + } else { + // Fallback: CGDisplayPixelsWide/High returns points (logical), not physical pixels + info.w = (int32_t)CGDisplayPixelsWide(displayID); + info.h = (int32_t)CGDisplayPixelsHigh(displayID); + info.scale = 1.0; + } + + return info; +} + +// Get all displays info +static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { + uint32_t count = 0; + CGDirectDisplayID displayIDs[32]; + + if (CGGetActiveDisplayList(32, displayIDs, &count) != kCGErrorSuccess) { + return 0; + } + + int32_t resultCount = (count < (uint32_t)maxCount) ? (int32_t)count : maxCount; + + for (int32_t i = 0; i < resultCount; i++) { + displays[i] = getDisplayInfoById(displayIDs[i], i); + } + + return resultCount; +} + +// Get main display info +static DisplayInfoC getMainDisplay() { + CGDirectDisplayID mainID = CGMainDisplayID(); + + // Find main display index + uint32_t count = 0; + CGDirectDisplayID displayIDs[32]; + CGGetActiveDisplayList(32, displayIDs, &count); + + int32_t mainIndex = 0; + for (uint32_t i = 0; i < count; i++) { + if (displayIDs[i] == mainID) { + mainIndex = (int32_t)i; + break; + } + } + + return getDisplayInfoById(mainID, mainIndex); +} + +// Get display at index +static DisplayInfoC getDisplayAt(int32_t index) { + uint32_t count = 0; + CGDirectDisplayID displayIDs[32]; + + if (CGGetActiveDisplayList(32, displayIDs, &count) != kCGErrorSuccess) { + DisplayInfoC empty = {0}; + return empty; + } + + if (index >= 0 && index < (int32_t)count) { + return getDisplayInfoById(displayIDs[index], index); + } + + // Index out of range + DisplayInfoC empty = {0}; + return empty; +} + +#endif /* DISPLAY_C_MACOS_H */ diff --git a/screen/display_c_windows.h b/screen/display_c_windows.h new file mode 100644 index 0000000..c1c235a --- /dev/null +++ b/screen/display_c_windows.h @@ -0,0 +1,215 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#ifndef DISPLAY_C_WINDOWS_H +#define DISPLAY_C_WINDOWS_H + +#include "../base/types.h" +#include + +// MDT_EFFECTIVE_DPI for GetDpiForMonitor +#ifndef MDT_EFFECTIVE_DPI +#define MDT_EFFECTIVE_DPI 0 +#endif + +// Function pointer type for GetDpiForMonitor +typedef HRESULT (WINAPI *GetDpiForMonitorFunc)(HMONITOR, int, UINT*, UINT*); + +// Get DPI for a monitor using dynamic loading (Windows 8.1+) +static double getMonitorScale(HMONITOR hMonitor) { + static GetDpiForMonitorFunc pGetDpiForMonitor = NULL; + static BOOL initialized = FALSE; + + if (!initialized) { + initialized = TRUE; + HMODULE hShcore = LoadLibraryW(L"Shcore.dll"); + if (hShcore) { + pGetDpiForMonitor = (GetDpiForMonitorFunc)GetProcAddress(hShcore, "GetDpiForMonitor"); + } + } + + if (pGetDpiForMonitor) { + UINT dpiX = 96, dpiY = 96; + HRESULT hr = pGetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, &dpiX, &dpiY); + if (SUCCEEDED(hr) && dpiX > 0) { + return (double)dpiX / 96.0; + } + } + return 1.0; +} + +// DisplayInfoC contains display information in C struct +typedef struct { + uintptr handle; // HMONITOR handle + int32_t index; // Display index + int8_t isMain; // Is main display + int32_t x, y, w, h; // Physical coordinates and size + int32_t vx, vy; // Virtual (logical) coordinates origin + double scale; // Scale factor (physical/logical) +} DisplayInfoC; + +// EnumDisplayContext is the enumeration context +typedef struct { + int32_t targetIndex; // Target index (-1 means enumerate all) + int32_t currentIndex; // Current index + DisplayInfoC* displays; // Output array + int32_t maxCount; // Max count + int32_t foundCount; // Found count +} EnumDisplayContext; + +// Get monitor real physical size (bypassing DPI virtualization) +// Reference: screenshot library's getMonitorRealSize implementation +static int getMonitorRealSize(HMONITOR hMonitor, RECT* outRect) { + // Step 1: Get device name via GetMonitorInfoW + MONITORINFOEXW info = {0}; + info.cbSize = sizeof(info); + + if (!GetMonitorInfoW(hMonitor, (MONITORINFO*)&info)) { + return 0; // Failed, caller should use logical coordinates as fallback + } + + // Step 2: Get physical resolution via EnumDisplaySettingsW + DEVMODEW devMode = {0}; + devMode.dmSize = sizeof(devMode); + + if (!EnumDisplaySettingsW(info.szDevice, ENUM_CURRENT_SETTINGS, &devMode)) { + return 0; // Failed + } + + // Step 3: Build physical coordinate rect + outRect->left = devMode.dmPosition.x; + outRect->top = devMode.dmPosition.y; + outRect->right = devMode.dmPosition.x + (LONG)devMode.dmPelsWidth; + outRect->bottom = devMode.dmPosition.y + (LONG)devMode.dmPelsHeight; + + return 1; // Success +} + +// Monitor enumeration callback for counting +static BOOL CALLBACK countMonitorCallback(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData) { + int32_t *count = (int32_t*)dwData; + (*count)++; + return TRUE; +} + +// Monitor enumeration callback for getting info +static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData) { + EnumDisplayContext* ctx = (EnumDisplayContext*)dwData; + + if (ctx->currentIndex >= ctx->maxCount) { + return FALSE; // Stop enumeration + } + + // Get physical size + RECT physRect; + int hasPhysical = getMonitorRealSize(hMonitor, &physRect); + + // Get monitor info (to check if main display) + MONITORINFO mi = {0}; + mi.cbSize = sizeof(mi); + GetMonitorInfoW(hMonitor, &mi); + + DisplayInfoC* info = &ctx->displays[ctx->currentIndex]; + info->handle = (uintptr)hMonitor; + info->index = ctx->currentIndex; + info->isMain = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0; + + // Always store virtual (logical) coordinates + info->vx = lprcMonitor->left; + info->vy = lprcMonitor->top; + + if (hasPhysical) { + // Use physical coordinates + info->x = physRect.left; + info->y = physRect.top; + info->w = physRect.right - physRect.left; + info->h = physRect.bottom - physRect.top; + + // Calculate scale based on DPI awareness mode + int32_t logicalW = lprcMonitor->right - lprcMonitor->left; + if (logicalW > 0 && info->w != logicalW) { + // Physical and logical sizes differ (DPI unaware mode) + // Windows virtualizes coordinates, use ratio to calculate scale + info->scale = (double)info->w / (double)logicalW; + } else { + // Physical and logical sizes are same (DPI aware mode) + // Use GetDpiForMonitor to get actual scale + info->scale = getMonitorScale(hMonitor); + } + } else { + // Fallback: use logical coordinates + info->x = lprcMonitor->left; + info->y = lprcMonitor->top; + info->w = lprcMonitor->right - lprcMonitor->left; + info->h = lprcMonitor->bottom - lprcMonitor->top; + info->scale = 1.0; + } + + ctx->currentIndex++; + ctx->foundCount++; + return TRUE; // Continue enumeration +} + +// Get display count +static int32_t getDisplayCount() { + int32_t count = 0; + EnumDisplayMonitors(NULL, NULL, countMonitorCallback, (LPARAM)&count); + return count; +} + +// Get all displays info +static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { + EnumDisplayContext ctx = {0}; + ctx.targetIndex = -1; + ctx.currentIndex = 0; + ctx.displays = displays; + ctx.maxCount = maxCount; + ctx.foundCount = 0; + + EnumDisplayMonitors(NULL, NULL, MonitorInfoEnumProc, (LPARAM)&ctx); + return ctx.foundCount; +} + +// Get main display info +static DisplayInfoC getMainDisplay() { + DisplayInfoC displays[32]; + int32_t count = getAllDisplays(displays, 32); + + for (int32_t i = 0; i < count; i++) { + if (displays[i].isMain) { + return displays[i]; + } + } + + // Fallback: return first display + if (count > 0) { + return displays[0]; + } + + // No display found + DisplayInfoC empty = {0}; + return empty; +} + +// Get display at index +static DisplayInfoC getDisplayAt(int32_t index) { + DisplayInfoC displays[32]; + int32_t count = getAllDisplays(displays, 32); + + if (index >= 0 && index < count) { + return displays[index]; + } + + // Index out of range + DisplayInfoC empty = {0}; + return empty; +} + +#endif /* DISPLAY_C_WINDOWS_H */ diff --git a/screen/display_c_x11.h b/screen/display_c_x11.h new file mode 100644 index 0000000..9fe7d68 --- /dev/null +++ b/screen/display_c_x11.h @@ -0,0 +1,189 @@ +// Copyright (c) 2016-2025 AtomAI, All rights reserved. +// +// See the COPYRIGHT file at the top-level directory of this distribution and at +// +// Licensed under the Apache License, Version 2.0 +// +// This file may not be copied, modified, or distributed +// except according to those terms. + +#ifndef DISPLAY_C_X11_H +#define DISPLAY_C_X11_H + +#include "../base/types.h" +#include "../base/xdisplay_c.h" +#include +#include +#include +#include + +// DisplayInfoC contains display information in C struct +typedef struct { + uintptr handle; // Xinerama screen index + int32_t index; // Display index + int8_t isMain; // Is main display + int32_t x, y, w, h; // Physical coordinates and size + double scale; // Scale factor (from Xft.dpi) +} DisplayInfoC; + +// Get scale factor from Xft.dpi +static double getX11Scale() { + Display *dpy = XOpenDisplay(NULL); + if (!dpy) { + return 1.0; + } + + double scale = 1.0; + char *rms = XResourceManagerString(dpy); + if (rms) { + XrmDatabase db = XrmGetStringDatabase(rms); + if (db) { + XrmValue value; + char *type = NULL; + + if (XrmGetResource(db, "Xft.dpi", "String", &type, &value)) { + if (value.addr) { + double dpi = atof(value.addr); + scale = dpi / 96.0; + } + } + + XrmDestroyDatabase(db); + } + } + XCloseDisplay(dpy); + + return scale; +} + +// Get display count +static int32_t getDisplayCount() { + Display *dpy = XOpenDisplay(NULL); + if (!dpy) { + return 1; + } + + int event_base, error_base; + if (XineramaQueryExtension(dpy, &event_base, &error_base) && + XineramaIsActive(dpy)) { + int count; + XineramaScreenInfo *info = XineramaQueryScreens(dpy, &count); + if (info) { + XFree(info); + XCloseDisplay(dpy); + return (int32_t)count; + } + } + + XCloseDisplay(dpy); + return 1; // Single display fallback +} + +// Get all displays info +static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { + Display *dpy = XOpenDisplay(NULL); + if (!dpy) { + // Fallback: return single display with default screen size + if (maxCount > 0) { + Display *mainDpy = XGetMainDisplay(); + if (mainDpy) { + int screen = DefaultScreen(mainDpy); + displays[0].handle = 0; + displays[0].index = 0; + displays[0].isMain = 1; + displays[0].x = 0; + displays[0].y = 0; + displays[0].w = DisplayWidth(mainDpy, screen); + displays[0].h = DisplayHeight(mainDpy, screen); + displays[0].scale = 1.0; + return 1; + } + } + return 0; + } + + double scale = getX11Scale(); + + int event_base, error_base; + if (XineramaQueryExtension(dpy, &event_base, &error_base) && + XineramaIsActive(dpy)) { + int count; + XineramaScreenInfo *screens = XineramaQueryScreens(dpy, &count); + if (screens) { + int32_t resultCount = (count < maxCount) ? count : maxCount; + + for (int32_t i = 0; i < resultCount; i++) { + displays[i].handle = (uintptr)screens[i].screen_number; + displays[i].index = i; + displays[i].isMain = (i == 0) ? 1 : 0; // First screen is main + displays[i].x = screens[i].x_org; + displays[i].y = screens[i].y_org; + displays[i].w = screens[i].width; + displays[i].h = screens[i].height; + displays[i].scale = scale; + } + + XFree(screens); + XCloseDisplay(dpy); + return resultCount; + } + } + + // Fallback: single display + if (maxCount > 0) { + int screen = DefaultScreen(dpy); + displays[0].handle = 0; + displays[0].index = 0; + displays[0].isMain = 1; + displays[0].x = 0; + displays[0].y = 0; + displays[0].w = DisplayWidth(dpy, screen); + displays[0].h = DisplayHeight(dpy, screen); + displays[0].scale = scale; + XCloseDisplay(dpy); + return 1; + } + + XCloseDisplay(dpy); + return 0; +} + +// Get main display info +static DisplayInfoC getMainDisplay() { + DisplayInfoC displays[32]; + int32_t count = getAllDisplays(displays, 32); + + for (int32_t i = 0; i < count; i++) { + if (displays[i].isMain) { + return displays[i]; + } + } + + // Fallback: return first display + if (count > 0) { + return displays[0]; + } + + // No display found + DisplayInfoC empty = {0}; + empty.scale = 1.0; + return empty; +} + +// Get display at index +static DisplayInfoC getDisplayAt(int32_t index) { + DisplayInfoC displays[32]; + int32_t count = getAllDisplays(displays, 32); + + if (index >= 0 && index < count) { + return displays[index]; + } + + // Index out of range + DisplayInfoC empty = {0}; + empty.scale = 1.0; + return empty; +} + +#endif /* DISPLAY_C_X11_H */ diff --git a/sleep.go b/sleep.go new file mode 100644 index 0000000..5c3c8fd --- /dev/null +++ b/sleep.go @@ -0,0 +1,13 @@ +package deskact + +import "time" + +// MilliSleep sleep tm milli second. +func MilliSleep(tm int) { + time.Sleep(time.Duration(tm) * time.Millisecond) +} + +// Sleep time.Sleep tm second. +func Sleep(tm int) { + time.Sleep(time.Duration(tm) * time.Second) +} diff --git a/special.go b/special.go new file mode 100644 index 0000000..3c432c9 --- /dev/null +++ b/special.go @@ -0,0 +1,77 @@ +package deskact + +// DefaultSpecialKeys returns the default special-key mapping. +func DefaultSpecialKeys() map[string]string { + return map[string]string{ + "~": "`", + "!": "1", + "@": "2", + "#": "3", + "$": "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + "_": "-", + "+": "=", + "{": "[", + "}": "]", + "|": "\\", + ":": ";", + `"`: "'", + "<": ",", + ">": ".", + "?": "/", + } +} + +func lookupSpecialKey(key string) (string, bool) { + switch key { + case "~": + return "`", true + case "!": + return "1", true + case "@": + return "2", true + case "#": + return "3", true + case "$": + return "4", true + case "%": + return "5", true + case "^": + return "6", true + case "&": + return "7", true + case "*": + return "8", true + case "(": + return "9", true + case ")": + return "0", true + case "_": + return "-", true + case "+": + return "=", true + case "{": + return "[", true + case "}": + return "]", true + case "|": + return "\\", true + case ":": + return ";", true + case `"`: + return "'", true + case "<": + return ",", true + case ">": + return ".", true + case "?": + return "/", true + default: + return "", false + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..010b7ec --- /dev/null +++ b/types.go @@ -0,0 +1,19 @@ +package deskact + +// Point is point struct. +type Point struct { + X int + Y int +} + +// Size is size structure. +type Size struct { + W int + H int +} + +// Rect is rect structure. +type Rect struct { + Point + Size +} From 315b0cc5f9433d22b6ba6e3f03f493642e1e82bb Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:33:09 +0800 Subject: [PATCH 02/37] Refactor multiple packages - split display/screenshot packages and move display defaults - move keyboard code into a `keyboard` package with key catalogs, aliases, and errors; re-export APIs - add mouse package with typed buttons/scroll units and platform mappings - return errors from display mouse ops and update tester; extend native backends for scroll units/buttons --- cmd/deskact-tester/main.go | 36 +- defaults.go | 75 --- display/defaults.go | 20 + .../defaults_other.go | 2 +- .../defaults_windows.go | 2 +- display.go => display/display.go | 10 +- {screen => display}/display_c.h | 0 {screen => display}/display_c_macos.h | 0 {screen => display}/display_c_windows.h | 0 {screen => display}/display_c_x11.h | 0 .../display_darwin.go | 53 +- display_linux.go => display/display_linux.go | 53 +- .../display_windows.go | 53 +- dpi_windows.go => display/dpi_windows.go | 2 +- display/mouse_aliases.go | 6 + types.go => display/types.go | 2 +- display_exports.go | 38 ++ display_exports_windows.go | 15 + examples/display/main.go | 7 +- key/keypress.h | 8 + key/keypress_c_macos.h | 40 +- key/keypress_c_windows.h | 79 ++- key/keypress_c_x11.h | 15 +- keyboard.go | 594 ------------------ keyboard/cgo.go | 14 + keyboard/defaults.go | 24 + keyboard/key_aliases.go | 27 + keyboard/key_catalog.go | 45 ++ keyboard/keyboard.go | 467 ++++++++++++++ keyboard/keymap_darwin.go | 103 +++ keyboard/keymap_windows.go | 103 +++ keyboard/keymap_x11.go | 103 +++ keyboard/keys.go | 185 ++++++ keyboard/sleep.go | 7 + special.go => keyboard/special.go | 2 +- keyboard_exports.go | 248 ++++++++ mouse.go | 285 --------- mouse/defaults.go | 36 ++ mouse/errors.go | 131 ++++ mouse/mouse.go | 202 ++++++ mouse/mouse.h | 54 +- mouse/mouse_c.h | 2 + mouse/mouse_c_macos.h | 17 +- mouse/mouse_c_windows.h | 93 +-- mouse/mouse_c_x11.h | 3 +- mouse/platform_darwin.go | 57 ++ mouse/platform_linux.go | 75 +++ mouse/platform_windows.go | 61 ++ mouse/sleep.go | 13 + mouse/types.go | 133 ++++ mouse_exports.go | 127 ++++ {internal/screenshot => screenshot}/darwin.go | 0 .../nix_dbus_available.go | 0 .../screenshot => screenshot}/nix_wayland.go | 0 .../screenshot => screenshot}/nix_xwindow.go | 0 .../screenshot => screenshot}/screenshot.go | 0 .../screenshot => screenshot}/unsupported.go | 0 .../screenshot => screenshot}/windows.go | 0 58 files changed, 2563 insertions(+), 1164 deletions(-) delete mode 100644 defaults.go create mode 100644 display/defaults.go rename defaults_other.go => display/defaults_other.go (91%) rename defaults_windows.go => display/defaults_windows.go (91%) rename display.go => display/display.go (90%) rename {screen => display}/display_c.h (100%) rename {screen => display}/display_c_macos.h (100%) rename {screen => display}/display_c_windows.h (100%) rename {screen => display}/display_c_x11.h (100%) rename display_darwin.go => display/display_darwin.go (82%) rename display_linux.go => display/display_linux.go (75%) rename display_windows.go => display/display_windows.go (84%) rename dpi_windows.go => display/dpi_windows.go (99%) create mode 100644 display/mouse_aliases.go rename types.go => display/types.go (92%) create mode 100644 display_exports.go create mode 100644 display_exports_windows.go delete mode 100644 keyboard.go create mode 100644 keyboard/cgo.go create mode 100644 keyboard/defaults.go create mode 100644 keyboard/key_aliases.go create mode 100644 keyboard/key_catalog.go create mode 100644 keyboard/keyboard.go create mode 100644 keyboard/keymap_darwin.go create mode 100644 keyboard/keymap_windows.go create mode 100644 keyboard/keymap_x11.go create mode 100644 keyboard/keys.go create mode 100644 keyboard/sleep.go rename special.go => keyboard/special.go (98%) create mode 100644 keyboard_exports.go delete mode 100644 mouse.go create mode 100644 mouse/defaults.go create mode 100644 mouse/errors.go create mode 100644 mouse/mouse.go create mode 100644 mouse/platform_darwin.go create mode 100644 mouse/platform_linux.go create mode 100644 mouse/platform_windows.go create mode 100644 mouse/sleep.go create mode 100644 mouse/types.go create mode 100644 mouse_exports.go rename {internal/screenshot => screenshot}/darwin.go (100%) rename {internal/screenshot => screenshot}/nix_dbus_available.go (100%) rename {internal/screenshot => screenshot}/nix_wayland.go (100%) rename {internal/screenshot => screenshot}/nix_xwindow.go (100%) rename {internal/screenshot => screenshot}/screenshot.go (100%) rename {internal/screenshot => screenshot}/unsupported.go (100%) rename {internal/screenshot => screenshot}/windows.go (100%) diff --git a/cmd/deskact-tester/main.go b/cmd/deskact-tester/main.go index d5313f8..ba2ac41 100644 --- a/cmd/deskact-tester/main.go +++ b/cmd/deskact-tester/main.go @@ -2114,9 +2114,9 @@ func runMouseTest(runID string, plan RunPlan, client ClientInfo, display *deskac } var err error if target.Clicks <= 1 { - err = deskact.Click("left", false, settings) + err = deskact.Click(deskact.MouseButtonLeft, false, settings) } else { - err = deskact.MultiClick("left", target.Clicks, settings) + err = deskact.MultiClick(deskact.MouseButtonLeft, target.Clicks, settings) } if err != nil { return err @@ -2133,13 +2133,17 @@ func runMouseTest(runID string, plan RunPlan, client ClientInfo, display *deskac if err := verifyMouseMove(display, dragStart, settings, tolerance); err != nil { return fmt.Errorf("drag start: %w", err) } - _ = deskact.Toggle("left", true, false, settings) - ok := display.MoveSmooth(dragEnd.X, dragEnd.Y, settings) + if err := deskact.Toggle(deskact.MouseButtonLeft, true, false, settings); err != nil { + return err + } + err := display.MoveSmooth(dragEnd.X, dragEnd.Y, settings) time.Sleep(150 * time.Millisecond) - _ = deskact.Toggle("left", false, false, settings) + if toggleErr := deskact.Toggle(deskact.MouseButtonLeft, false, false, settings); toggleErr != nil && err == nil { + err = toggleErr + } time.Sleep(120 * time.Millisecond) - if !ok { - return errors.New("drag move failed") + if err != nil { + return fmt.Errorf("drag move failed: %w", err) } if err := verifyMouseAt(display, dragEnd, tolerance); err != nil { return fmt.Errorf("drag end: %w", err) @@ -2225,7 +2229,9 @@ func absInt(v int) int { } func verifyMouseMove(display *deskact.Display, target deskact.Point, settings deskact.MouseSettings, tolerance int) error { - display.Move(target.X, target.Y, settings) + if err := display.Move(target.X, target.Y, settings); err != nil { + return err + } time.Sleep(140 * time.Millisecond) return verifyMouseAt(display, target, tolerance) } @@ -2443,13 +2449,15 @@ func focusKeyboardInput(client ClientInfo, display *deskact.Display, settings de return errors.New("keyboard input bounds missing") } pt := rectCenterPoint(rect, client.DevicePixelRatio, client.ViewportOffsetX, client.ViewportOffsetY, display) - display.Move(pt.X, pt.Y, settings) + if err := display.Move(pt.X, pt.Y, settings); err != nil { + return err + } time.Sleep(120 * time.Millisecond) - if err := deskact.Click("left", false, settings); err != nil { + if err := deskact.Click(deskact.MouseButtonLeft, false, settings); err != nil { return err } time.Sleep(120 * time.Millisecond) - return deskact.Click("left", false, settings) + return deskact.Click(deskact.MouseButtonLeft, false, settings) } func primeMouseArea(client ClientInfo, display *deskact.Display, settings deskact.MouseSettings) error { @@ -2458,9 +2466,11 @@ func primeMouseArea(client ClientInfo, display *deskact.Display, settings deskac return errors.New("mouse area bounds missing") } pt := targetPointFromNorm(0.5, 0.5, client, display) - display.Move(pt.X, pt.Y, settings) + if err := display.Move(pt.X, pt.Y, settings); err != nil { + return err + } time.Sleep(120 * time.Millisecond) - return deskact.Click("left", false, settings) + return deskact.Click(deskact.MouseButtonLeft, false, settings) } func evaluateKeyboardReport(plan KeyboardPlan, report KeyboardReport) KeyboardReport { diff --git a/defaults.go b/defaults.go deleted file mode 100644 index aa53a8a..0000000 --- a/defaults.go +++ /dev/null @@ -1,75 +0,0 @@ -package deskact - -const ( - DefaultMouseSleep = 0 - DefaultKeySleep = 10 - DefaultTypeDelay = 0 - DefaultTypeUTFDelay = 7 - DefaultMoveSmoothLow = 1.0 - DefaultMoveSmoothHigh = 3.0 - DefaultMoveSmoothDelay = 1 - DefaultScrollDelay = 10 - DefaultScrollSmoothCount = 5 - DefaultScrollSmoothInterval = 100 - DefaultScrollSmoothX = 0 - DefaultDPIAware = false -) - -// MouseSettings defines timing and smoothing parameters for mouse actions. -// Use DefaultMouseSettings() to start from the built-in defaults. -type MouseSettings struct { - Sleep int - MoveSmoothLow float64 - MoveSmoothHigh float64 - MoveSmoothDelay int - ScrollDelay int - ScrollSmoothCount int - ScrollSmoothInterval int - ScrollSmoothX int -} - -// DefaultMouseSettings returns the default mouse settings. -func DefaultMouseSettings() MouseSettings { - return MouseSettings{ - Sleep: DefaultMouseSleep, - MoveSmoothLow: DefaultMoveSmoothLow, - MoveSmoothHigh: DefaultMoveSmoothHigh, - MoveSmoothDelay: DefaultMoveSmoothDelay, - ScrollDelay: DefaultScrollDelay, - ScrollSmoothCount: DefaultScrollSmoothCount, - ScrollSmoothInterval: DefaultScrollSmoothInterval, - ScrollSmoothX: DefaultScrollSmoothX, - } -} - -// KeyboardSettings defines timing parameters for keyboard actions. -// Use DefaultKeyboardSettings() to start from the built-in defaults. -type KeyboardSettings struct { - Sleep int - TypeDelay int - TypeUTFDelay int -} - -// DefaultKeyboardSettings returns the default keyboard settings. -func DefaultKeyboardSettings() KeyboardSettings { - return KeyboardSettings{ - Sleep: DefaultKeySleep, - TypeDelay: DefaultTypeDelay, - TypeUTFDelay: DefaultTypeUTFDelay, - } -} - -// DisplayOptions defines platform-specific display options. -type DisplayOptions struct { - DPIAware bool -} - -// CaptureOptions defines platform-specific capture options. -type CaptureOptions struct { - WaylandToken uint64 -} - -// DefaultCaptureOptions returns the default capture options. -func DefaultCaptureOptions() CaptureOptions { - return CaptureOptions{} -} diff --git a/display/defaults.go b/display/defaults.go new file mode 100644 index 0000000..4a1da84 --- /dev/null +++ b/display/defaults.go @@ -0,0 +1,20 @@ +package display + +const ( + DefaultDPIAware = false +) + +// DisplayOptions defines platform-specific display options. +type DisplayOptions struct { + DPIAware bool +} + +// CaptureOptions defines platform-specific capture options. +type CaptureOptions struct { + WaylandToken uint64 +} + +// DefaultCaptureOptions returns the default capture options. +func DefaultCaptureOptions() CaptureOptions { + return CaptureOptions{} +} diff --git a/defaults_other.go b/display/defaults_other.go similarity index 91% rename from defaults_other.go rename to display/defaults_other.go index a3612b5..b4e34cf 100644 --- a/defaults_other.go +++ b/display/defaults_other.go @@ -1,6 +1,6 @@ //go:build !windows -package deskact +package display // DefaultDisplayOptions returns the default display options. func DefaultDisplayOptions() DisplayOptions { diff --git a/defaults_windows.go b/display/defaults_windows.go similarity index 91% rename from defaults_windows.go rename to display/defaults_windows.go index 4ded041..ef2d7c8 100644 --- a/defaults_windows.go +++ b/display/defaults_windows.go @@ -1,6 +1,6 @@ //go:build windows -package deskact +package display // DefaultDisplayOptions returns the default display options. func DefaultDisplayOptions() DisplayOptions { diff --git a/display.go b/display/display.go similarity index 90% rename from display.go rename to display/display.go index 256f0ec..2ae92fe 100644 --- a/display.go +++ b/display/display.go @@ -1,4 +1,4 @@ -package deskact +package display // PlatformInfo is an interface for platform-specific display information. type PlatformInfo interface { @@ -89,10 +89,10 @@ func (d *Display) Info() DisplayInfo { // - ToAbsolute(x, y int) (absX, absY int) // - ToRelative(absX, absY int) (x, y int, ok bool) // - Contains(absX, absY int) bool -// - Move(x, y int, settings MouseSettings) -// - MoveSmooth(x, y int, settings MouseSettings) bool -// - Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) -// - DragTo(x, y int, button string, settings MouseSettings) +// - Move(x, y int, settings MouseSettings) error +// - MoveSmooth(x, y int, settings MouseSettings) error +// - Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error +// - DragTo(x, y int, button MouseButton, settings MouseSettings) error // - CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) // - MouseLocation() (x, y int, ok bool) // - ContainsMouse() bool diff --git a/screen/display_c.h b/display/display_c.h similarity index 100% rename from screen/display_c.h rename to display/display_c.h diff --git a/screen/display_c_macos.h b/display/display_c_macos.h similarity index 100% rename from screen/display_c_macos.h rename to display/display_c_macos.h diff --git a/screen/display_c_windows.h b/display/display_c_windows.h similarity index 100% rename from screen/display_c_windows.h rename to display/display_c_windows.h diff --git a/screen/display_c_x11.h b/display/display_c_x11.h similarity index 100% rename from screen/display_c_x11.h rename to display/display_c_x11.h diff --git a/display_darwin.go b/display/display_darwin.go similarity index 82% rename from display_darwin.go rename to display/display_darwin.go index 97d69b4..4dca7d0 100644 --- a/display_darwin.go +++ b/display/display_darwin.go @@ -1,7 +1,7 @@ //go:build darwin // +build darwin -package deskact +package display /* #cgo darwin CFLAGS: -x objective-c -Wno-deprecated-declarations @@ -9,14 +9,15 @@ package deskact #cgo darwin LDFLAGS: -framework Carbon -framework OpenGL #cgo darwin LDFLAGS: -weak_framework ScreenCaptureKit -#include "screen/display_c.h" +#include "display_c.h" */ import "C" import ( "image" - "github.com/PekingSpades/DeskAct/internal/screenshot" + "github.com/PekingSpades/DeskAct/mouse" + "github.com/PekingSpades/DeskAct/screenshot" ) // MainDisplay returns the main display. @@ -157,33 +158,45 @@ func (d *Display) Contains(virtAbsX, virtAbsY int) bool { } // Move moves the mouse to the specified physical pixel coordinates relative to this display. -func (d *Display) Move(physX, physY int, settings MouseSettings) { +func (d *Display) Move(physX, physY int, settings MouseSettings) error { virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) - Move(virtAbsX, virtAbsY, settings) + return mouse.Move(virtAbsX, virtAbsY, settings) } // MoveSmooth smoothly moves the mouse to the specified physical pixel coordinates // relative to this display. -func (d *Display) MoveSmooth(physX, physY int, settings MouseSettings) bool { +func (d *Display) MoveSmooth(physX, physY int, settings MouseSettings) error { virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) - return MoveSmooth(virtAbsX, virtAbsY, settings) + return mouse.MoveSmooth(virtAbsX, virtAbsY, settings) } // Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { - d.Move(fromX, fromY, settings) - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(toX, toY, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + if err := d.Move(fromX, fromY, settings); err != nil { + return err + } + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(toX, toY, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(physX, physY int, button string, settings MouseSettings) { - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(physX, physY, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) DragTo(physX, physY int, button MouseButton, settings MouseSettings) error { + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(physX, physY, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // CaptureRect captures a rectangular region of this display. @@ -201,12 +214,12 @@ func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (* // MouseLocation gets the mouse location in physical pixels relative to this display. func (d *Display) MouseLocation() (physX, physY int, ok bool) { - virtAbsX, virtAbsY := Location() + virtAbsX, virtAbsY := mouse.Location() return d.ToRelative(virtAbsX, virtAbsY) } // ContainsMouse checks if the mouse is on this display. func (d *Display) ContainsMouse() bool { - virtAbsX, virtAbsY := Location() + virtAbsX, virtAbsY := mouse.Location() return d.Contains(virtAbsX, virtAbsY) } diff --git a/display_linux.go b/display/display_linux.go similarity index 75% rename from display_linux.go rename to display/display_linux.go index 1002a9e..a570fa7 100644 --- a/display_linux.go +++ b/display/display_linux.go @@ -1,20 +1,21 @@ //go:build linux // +build linux -package deskact +package display /* #cgo linux CFLAGS: -I/usr/src #cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst -lXinerama -#include "screen/display_c.h" +#include "display_c.h" */ import "C" import ( "image" - "github.com/PekingSpades/DeskAct/internal/screenshot" + "github.com/PekingSpades/DeskAct/mouse" + "github.com/PekingSpades/DeskAct/screenshot" ) // MainDisplay returns the main display. @@ -109,32 +110,44 @@ func (d *Display) Contains(absX, absY int) bool { } // Move moves the mouse to the specified coordinates relative to this display. -func (d *Display) Move(x, y int, settings MouseSettings) { +func (d *Display) Move(x, y int, settings MouseSettings) error { absX, absY := d.ToAbsolute(x, y) - Move(absX, absY, settings) + return mouse.Move(absX, absY, settings) } // MoveSmooth smoothly moves the mouse to the specified coordinates relative to this display. -func (d *Display) MoveSmooth(x, y int, settings MouseSettings) bool { +func (d *Display) MoveSmooth(x, y int, settings MouseSettings) error { absX, absY := d.ToAbsolute(x, y) - return MoveSmooth(absX, absY, settings) + return mouse.MoveSmooth(absX, absY, settings) } // Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { - d.Move(fromX, fromY, settings) - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(toX, toY, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + if err := d.Move(fromX, fromY, settings); err != nil { + return err + } + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(toX, toY, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(x, y int, button string, settings MouseSettings) { - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(x, y, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) DragTo(x, y int, button MouseButton, settings MouseSettings) error { + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(x, y, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // CaptureRect captures a rectangular region of this display. @@ -145,12 +158,12 @@ func (d *Display) CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RG // MouseLocation gets the mouse location relative to this display. func (d *Display) MouseLocation() (x, y int, ok bool) { - absX, absY := Location() + absX, absY := mouse.Location() return d.ToRelative(absX, absY) } // ContainsMouse checks if the mouse is on this display. func (d *Display) ContainsMouse() bool { - absX, absY := Location() + absX, absY := mouse.Location() return d.Contains(absX, absY) } diff --git a/display_windows.go b/display/display_windows.go similarity index 84% rename from display_windows.go rename to display/display_windows.go index 01a755e..924b66d 100644 --- a/display_windows.go +++ b/display/display_windows.go @@ -1,12 +1,12 @@ //go:build windows // +build windows -package deskact +package display /* #cgo windows LDFLAGS: -lgdi32 -luser32 -#include "screen/display_c.h" +#include "display_c.h" */ import "C" @@ -14,7 +14,8 @@ import ( "errors" "image" - "github.com/PekingSpades/DeskAct/internal/screenshot" + "github.com/PekingSpades/DeskAct/mouse" + "github.com/PekingSpades/DeskAct/screenshot" ) // WindowsPlatformInfo contains Windows-specific display information. @@ -235,32 +236,44 @@ func (d *Display) Contains(absX, absY int) bool { } // Move moves the mouse to the specified coordinates relative to this display. -func (d *Display) Move(x, y int, settings MouseSettings) { +func (d *Display) Move(x, y int, settings MouseSettings) error { absX, absY := d.ToAbsolute(x, y) - Move(absX, absY, settings) + return mouse.Move(absX, absY, settings) } // MoveSmooth smoothly moves the mouse to the specified coordinates relative to this display. -func (d *Display) MoveSmooth(x, y int, settings MouseSettings) bool { +func (d *Display) MoveSmooth(x, y int, settings MouseSettings) error { absX, absY := d.ToAbsolute(x, y) - return MoveSmooth(absX, absY, settings) + return mouse.MoveSmooth(absX, absY, settings) } // Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button string, settings MouseSettings) { - d.Move(fromX, fromY, settings) - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(toX, toY, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + if err := d.Move(fromX, fromY, settings); err != nil { + return err + } + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(toX, toY, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(x, y int, button string, settings MouseSettings) { - _ = Toggle(button, true, false, settings) - MilliSleep(50) - d.MoveSmooth(x, y, settings) - _ = Toggle(button, false, false, settings) +func (d *Display) DragTo(x, y int, button MouseButton, settings MouseSettings) error { + if err := mouse.Toggle(button, true, false, settings); err != nil { + return err + } + mouse.MilliSleep(50) + if err := d.MoveSmooth(x, y, settings); err != nil { + _ = mouse.Toggle(button, false, false, settings) + return err + } + return mouse.Toggle(button, false, false, settings) } // CaptureRect captures a rectangular region of this display. @@ -276,12 +289,12 @@ func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (* // MouseLocation gets the mouse location relative to this display. func (d *Display) MouseLocation() (x, y int, ok bool) { - absX, absY := Location() + absX, absY := mouse.Location() return d.ToRelative(absX, absY) } // ContainsMouse checks if the mouse is on this display. func (d *Display) ContainsMouse() bool { - absX, absY := Location() + absX, absY := mouse.Location() return d.Contains(absX, absY) } diff --git a/dpi_windows.go b/display/dpi_windows.go similarity index 99% rename from dpi_windows.go rename to display/dpi_windows.go index df496f9..0242005 100644 --- a/dpi_windows.go +++ b/display/dpi_windows.go @@ -1,7 +1,7 @@ //go:build windows // +build windows -package deskact +package display import ( "syscall" diff --git a/display/mouse_aliases.go b/display/mouse_aliases.go new file mode 100644 index 0000000..1eb14b1 --- /dev/null +++ b/display/mouse_aliases.go @@ -0,0 +1,6 @@ +package display + +import "github.com/PekingSpades/DeskAct/mouse" + +type MouseSettings = mouse.MouseSettings +type MouseButton = mouse.MouseButton diff --git a/types.go b/display/types.go similarity index 92% rename from types.go rename to display/types.go index 010b7ec..ba34b40 100644 --- a/types.go +++ b/display/types.go @@ -1,4 +1,4 @@ -package deskact +package display // Point is point struct. type Point struct { diff --git a/display_exports.go b/display_exports.go new file mode 100644 index 0000000..bd2868b --- /dev/null +++ b/display_exports.go @@ -0,0 +1,38 @@ +package deskact + +import "github.com/PekingSpades/DeskAct/display" + +type PlatformInfo = display.PlatformInfo +type Display = display.Display +type DisplayInfo = display.DisplayInfo +type DisplayOptions = display.DisplayOptions +type CaptureOptions = display.CaptureOptions +type Point = display.Point +type Size = display.Size +type Rect = display.Rect + +const DefaultDPIAware = display.DefaultDPIAware + +func DefaultDisplayOptions() DisplayOptions { + return display.DefaultDisplayOptions() +} + +func DefaultCaptureOptions() CaptureOptions { + return display.DefaultCaptureOptions() +} + +func MainDisplay(options DisplayOptions) *Display { + return display.MainDisplay(options) +} + +func AllDisplays(options DisplayOptions) []*Display { + return display.AllDisplays(options) +} + +func DisplayAt(index int, options DisplayOptions) *Display { + return display.DisplayAt(index, options) +} + +func DisplayCount() int { + return display.DisplayCount() +} diff --git a/display_exports_windows.go b/display_exports_windows.go new file mode 100644 index 0000000..80a3330 --- /dev/null +++ b/display_exports_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package deskact + +import "github.com/PekingSpades/DeskAct/display" + +type WindowsPlatformInfo = display.WindowsPlatformInfo + +func IsDPIAware() bool { + return display.IsDPIAware() +} + +func InitDPIAwareness() bool { + return display.InitDPIAwareness() +} diff --git a/examples/display/main.go b/examples/display/main.go index 960ebd4..1893f0c 100644 --- a/examples/display/main.go +++ b/examples/display/main.go @@ -17,6 +17,8 @@ func main() { fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Println("========================================") + deskact.InitDPIAwareness() + displayOptions := deskact.DefaultDisplayOptions() mouseSettings := deskact.DefaultMouseSettings() @@ -67,7 +69,10 @@ func main() { } for _, pos := range positions { - d.Move(pos.x, pos.y, mouseSettings) + if err := d.Move(pos.x, pos.y, mouseSettings); err != nil { + fmt.Printf(" %-12s: move error: %v\n", pos.name, err) + continue + } deskact.MilliSleep(800) absX, absY := deskact.Location() diff --git a/key/keypress.h b/key/keypress.h index 3079d29..4426cfe 100644 --- a/key/keypress.h +++ b/key/keypress.h @@ -38,6 +38,14 @@ typedef unsigned int MMKeyFlags; #endif +enum _MMKeyError { + MM_KEY_OK = 0, + MM_KEY_ERR_EVENT = -1, + MM_KEY_ERR_DISPLAY = -2, + MM_KEY_ERR_WINDOW = -3, + MM_KEY_ERR_POST = -4 +}; + #if defined(IS_WINDOWS) /* Send win32 key event for given key. */ void win32KeyEvent(int key, MMKeyFlags flags, uintptr pid, int8_t isPid); diff --git a/key/keypress_c_macos.h b/key/keypress_c_macos.h index cc5b443..4ddafc7 100644 --- a/key/keypress_c_macos.h +++ b/key/keypress_c_macos.h @@ -67,7 +67,7 @@ static int postMediaKeyEvent(MMKeyCode code, bool down) { kr = IOHIDPostEvent(_getAuxiliaryKeyDriver(), NX_SYSDEFINED, loc, &event, kNXEventDataVersion, 0, FALSE); - return (kr == KERN_SUCCESS) ? 0 : -1; + return (kr == KERN_SUCCESS) ? MM_KEY_OK : MM_KEY_ERR_EVENT; } /* @@ -80,20 +80,23 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { /* The media keys all have 1000 added to them to help us detect them. */ if (code >= 1000) { code = code - 1000; /* Get the real keycode. */ - postMediaKeyEvent(code, true); + int err = postMediaKeyEvent(code, true); + if (err != MM_KEY_OK) { return err; } microsleep(5.0); - postMediaKeyEvent(code, false); - return 0; + return postMediaKeyEvent(code, false); } /* macOS: CGEventFlags makes it atomic - modifiers are set on the event itself */ CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } /* Press */ CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, true); if (keyDown == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } if (flags != 0) { CGEventSetFlags(keyDown, (CGEventFlags)flags); @@ -105,7 +108,7 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, false); if (keyUp == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } if (flags != 0) { CGEventSetFlags(keyUp, (CGEventFlags)flags); @@ -114,7 +117,7 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { CFRelease(keyUp); CFRelease(source); - return 0; + return MM_KEY_OK; } /* @@ -132,11 +135,14 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { /* macOS: CGEventFlags makes it atomic */ CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, down); if (keyEvent == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); @@ -147,7 +153,7 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { CGEventPost(kCGHIDEventTap, keyEvent); CFRelease(keyEvent); CFRelease(source); - return 0; + return MM_KEY_OK; } /* @@ -156,11 +162,14 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { /* macOS: supports PID natively */ CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, true); if (keyDown == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } if (flags != 0) { CGEventSetFlags(keyDown, (CGEventFlags)flags); @@ -171,7 +180,7 @@ int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, false); if (keyUp == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } if (flags != 0) { CGEventSetFlags(keyUp, (CGEventFlags)flags); @@ -180,7 +189,7 @@ int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { CFRelease(keyUp); CFRelease(source); - return 0; + return MM_KEY_OK; } /* @@ -188,11 +197,14 @@ int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { */ int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) { CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, down); if (keyEvent == NULL) { CFRelease(source); - return -1; + return MM_KEY_ERR_EVENT; } CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); @@ -203,7 +215,7 @@ int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) CGEventPostToPid(pid, keyEvent); CFRelease(keyEvent); CFRelease(source); - return 0; + return MM_KEY_OK; } /* diff --git a/key/keypress_c_windows.h b/key/keypress_c_windows.h index b86c0f7..bf84596 100644 --- a/key/keypress_c_windows.h +++ b/key/keypress_c_windows.h @@ -80,10 +80,16 @@ static inline void addKeyInput(INPUT *input, int key, DWORD flags) { } /* Send key event to a specific window via PostMessage */ -void keyEventToWindow(int key, DWORD flags, uintptr pid, int8_t isPid) { - HWND hwnd = getHwnd(pid, isPid); +static int postMessageChecked(HWND hwnd, int msg, WPARAM wParam, LPARAM lParam) { + if (!PostMessageW(hwnd, msg, wParam, lParam)) { + return MM_KEY_ERR_POST; + } + return MM_KEY_OK; +} + +static int keyEventToHwnd(HWND hwnd, int key, DWORD flags) { int msg = (flags & KEYEVENTF_KEYUP) ? WM_KEYUP : WM_KEYDOWN; - PostMessageW(hwnd, msg, key, 0); + return postMessageChecked(hwnd, msg, key, 0); } /* @@ -110,7 +116,7 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { if (flags & MOD_ALT) { addKeyInput(&inputs[count++], K_ALT, KEYEVENTF_KEYUP); } if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, KEYEVENTF_KEYUP); } - return SendInput(count, inputs, sizeof(INPUT)) == count ? 0 : GetLastError(); + return SendInput(count, inputs, sizeof(INPUT)) == count ? MM_KEY_OK : GetLastError(); } /* @@ -140,7 +146,7 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { if (flags & MOD_META) { addKeyInput(&inputs[count++], K_META, dwFlags); } } - return SendInput(count, inputs, sizeof(INPUT)) == count ? 0 : GetLastError(); + return SendInput(count, inputs, sizeof(INPUT)) == count ? MM_KEY_OK : GetLastError(); } /* @@ -148,18 +154,27 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { */ int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { /* Windows: use PostMessage (non-atomic) */ - if (flags & MOD_META) { keyEventToWindow(K_META, 0, pid, 0); } - if (flags & MOD_ALT) { keyEventToWindow(K_ALT, 0, pid, 0); } - if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, 0, pid, 0); } - if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, 0, pid, 0); } - keyEventToWindow(code, 0, pid, 0); - - keyEventToWindow(code, KEYEVENTF_KEYUP, pid, 0); - if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, KEYEVENTF_KEYUP, pid, 0); } - if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, KEYEVENTF_KEYUP, pid, 0); } - if (flags & MOD_ALT) { keyEventToWindow(K_ALT, KEYEVENTF_KEYUP, pid, 0); } - if (flags & MOD_META) { keyEventToWindow(K_META, KEYEVENTF_KEYUP, pid, 0); } - return 0; + HWND hwnd = getHwnd(pid, 0); + int err = MM_KEY_OK; + + if (hwnd == NULL) { + return MM_KEY_ERR_WINDOW; + } + + if (flags & MOD_META) { err = keyEventToHwnd(hwnd, K_META, 0); if (err != MM_KEY_OK) return err; } + if (flags & MOD_ALT) { err = keyEventToHwnd(hwnd, K_ALT, 0); if (err != MM_KEY_OK) return err; } + if (flags & MOD_CONTROL) { err = keyEventToHwnd(hwnd, K_CONTROL, 0); if (err != MM_KEY_OK) return err; } + if (flags & MOD_SHIFT) { err = keyEventToHwnd(hwnd, K_SHIFT, 0); if (err != MM_KEY_OK) return err; } + err = keyEventToHwnd(hwnd, code, 0); + if (err != MM_KEY_OK) return err; + + err = keyEventToHwnd(hwnd, code, KEYEVENTF_KEYUP); + if (err != MM_KEY_OK) return err; + if (flags & MOD_SHIFT) { err = keyEventToHwnd(hwnd, K_SHIFT, KEYEVENTF_KEYUP); if (err != MM_KEY_OK) return err; } + if (flags & MOD_CONTROL) { err = keyEventToHwnd(hwnd, K_CONTROL, KEYEVENTF_KEYUP); if (err != MM_KEY_OK) return err; } + if (flags & MOD_ALT) { err = keyEventToHwnd(hwnd, K_ALT, KEYEVENTF_KEYUP); if (err != MM_KEY_OK) return err; } + if (flags & MOD_META) { err = keyEventToHwnd(hwnd, K_META, KEYEVENTF_KEYUP); if (err != MM_KEY_OK) return err; } + return MM_KEY_OK; } /* @@ -167,21 +182,29 @@ int keyTapPid(MMKeyCode code, MMKeyFlags flags, uintptr pid) { */ int keyTogglePid(MMKeyCode code, const bool down, MMKeyFlags flags, uintptr pid) { DWORD dwFlags = down ? 0 : KEYEVENTF_KEYUP; + HWND hwnd = getHwnd(pid, 0); + int err = MM_KEY_OK; + + if (hwnd == NULL) { + return MM_KEY_ERR_WINDOW; + } if (down) { - if (flags & MOD_META) { keyEventToWindow(K_META, dwFlags, pid, 0); } - if (flags & MOD_ALT) { keyEventToWindow(K_ALT, dwFlags, pid, 0); } - if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, dwFlags, pid, 0); } - if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, dwFlags, pid, 0); } - keyEventToWindow(code, dwFlags, pid, 0); + if (flags & MOD_META) { err = keyEventToHwnd(hwnd, K_META, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_ALT) { err = keyEventToHwnd(hwnd, K_ALT, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_CONTROL) { err = keyEventToHwnd(hwnd, K_CONTROL, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_SHIFT) { err = keyEventToHwnd(hwnd, K_SHIFT, dwFlags); if (err != MM_KEY_OK) return err; } + err = keyEventToHwnd(hwnd, code, dwFlags); + if (err != MM_KEY_OK) return err; } else { - keyEventToWindow(code, dwFlags, pid, 0); - if (flags & MOD_SHIFT) { keyEventToWindow(K_SHIFT, dwFlags, pid, 0); } - if (flags & MOD_CONTROL) { keyEventToWindow(K_CONTROL, dwFlags, pid, 0); } - if (flags & MOD_ALT) { keyEventToWindow(K_ALT, dwFlags, pid, 0); } - if (flags & MOD_META) { keyEventToWindow(K_META, dwFlags, pid, 0); } + err = keyEventToHwnd(hwnd, code, dwFlags); + if (err != MM_KEY_OK) return err; + if (flags & MOD_SHIFT) { err = keyEventToHwnd(hwnd, K_SHIFT, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_CONTROL) { err = keyEventToHwnd(hwnd, K_CONTROL, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_ALT) { err = keyEventToHwnd(hwnd, K_ALT, dwFlags); if (err != MM_KEY_OK) return err; } + if (flags & MOD_META) { err = keyEventToHwnd(hwnd, K_META, dwFlags); if (err != MM_KEY_OK) return err; } } - return 0; + return MM_KEY_OK; } /* diff --git a/key/keypress_c_x11.h b/key/keypress_c_x11.h index a168f00..79800d5 100644 --- a/key/keypress_c_x11.h +++ b/key/keypress_c_x11.h @@ -25,6 +25,9 @@ */ int keyTap(MMKeyCode code, MMKeyFlags flags) { Display *display = XGetMainDisplay(); + if (display == NULL) { + return MM_KEY_ERR_DISPLAY; + } /* Press: modifiers -> main key */ if (flags & MOD_META) { @@ -57,7 +60,7 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { } XSync(display, false); - return 0; + return MM_KEY_OK; } /* @@ -68,6 +71,9 @@ int keyTap(MMKeyCode code, MMKeyFlags flags) { */ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { Display *display = XGetMainDisplay(); + if (display == NULL) { + return MM_KEY_ERR_DISPLAY; + } const Bool is_press = down ? True : False; if (down) { @@ -103,7 +109,7 @@ int keyToggle(MMKeyCode code, const bool down, MMKeyFlags flags) { } XSync(display, false); - return 0; + return MM_KEY_OK; } /* @@ -162,6 +168,9 @@ void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { int input_utf(const char *utf) { Display *dpy = XOpenDisplay(NULL); + if (dpy == NULL) { + return MM_KEY_ERR_DISPLAY; + } KeySym sym = XStringToKeysym(utf); int min, max, numcodes; @@ -179,5 +188,5 @@ int input_utf(const char *utf) { XFlush(dpy); XCloseDisplay(dpy); - return 0; + return MM_KEY_OK; } diff --git a/keyboard.go b/keyboard.go deleted file mode 100644 index 04d5838..0000000 --- a/keyboard.go +++ /dev/null @@ -1,594 +0,0 @@ -package deskact - -/* -#include "base/types.h" -#include "base/pubs.h" -#include "key/keypress_c.h" -*/ -import "C" - -import ( - "errors" - "fmt" - "math/rand" - "runtime" - "strconv" - "strings" - "syscall" - "unicode" - "unsafe" -) - -// Defining a bunch of constants. -const ( - // KeyA define key "a" - KeyA = "a" - KeyB = "b" - KeyC = "c" - KeyD = "d" - KeyE = "e" - KeyF = "f" - KeyG = "g" - KeyH = "h" - KeyI = "i" - KeyJ = "j" - KeyK = "k" - KeyL = "l" - KeyM = "m" - KeyN = "n" - KeyO = "o" - KeyP = "p" - KeyQ = "q" - KeyR = "r" - KeyS = "s" - KeyT = "t" - KeyU = "u" - KeyV = "v" - KeyW = "w" - KeyX = "x" - KeyY = "y" - KeyZ = "z" - // - CapA = "A" - CapB = "B" - CapC = "C" - CapD = "D" - CapE = "E" - CapF = "F" - CapG = "G" - CapH = "H" - CapI = "I" - CapJ = "J" - CapK = "K" - CapL = "L" - CapM = "M" - CapN = "N" - CapO = "O" - CapP = "P" - CapQ = "Q" - CapR = "R" - CapS = "S" - CapT = "T" - CapU = "U" - CapV = "V" - CapW = "W" - CapX = "X" - CapY = "Y" - CapZ = "Z" - // - Key0 = "0" - Key1 = "1" - Key2 = "2" - Key3 = "3" - Key4 = "4" - Key5 = "5" - Key6 = "6" - Key7 = "7" - Key8 = "8" - Key9 = "9" - - // Backspace backspace key string - Backspace = "backspace" - Delete = "delete" - Enter = "enter" - Tab = "tab" - Esc = "esc" - Escape = "escape" - Up = "up" // Up arrow key - Down = "down" // Down arrow key - Right = "right" // Right arrow key - Left = "left" // Left arrow key - Home = "home" - End = "end" - Pageup = "pageup" - Pagedown = "pagedown" - - F1 = "f1" - F2 = "f2" - F3 = "f3" - F4 = "f4" - F5 = "f5" - F6 = "f6" - F7 = "f7" - F8 = "f8" - F9 = "f9" - F10 = "f10" - F11 = "f11" - F12 = "f12" - F13 = "f13" - F14 = "f14" - F15 = "f15" - F16 = "f16" - F17 = "f17" - F18 = "f18" - F19 = "f19" - F20 = "f20" - F21 = "f21" - F22 = "f22" - F23 = "f23" - F24 = "f24" - - Cmd = "cmd" // is the "win" key for windows - Lcmd = "lcmd" // left command - Rcmd = "rcmd" // right command - // "command" - Alt = "alt" - Lalt = "lalt" // left alt - Ralt = "ralt" // right alt - Ctrl = "ctrl" - Lctrl = "lctrl" // left ctrl - Rctrl = "rctrl" // right ctrl - Control = "control" - Shift = "shift" - Lshift = "lshift" // left shift - Rshift = "rshift" // right shift - // "right_shift" - Capslock = "capslock" - Space = "space" - Print = "print" - Printscreen = "printscreen" // No Mac support - Insert = "insert" - Menu = "menu" // Windows only - - AudioMute = "audio_mute" // Mute the volume - AudioVolDown = "audio_vol_down" // Lower the volume - AudioVolUp = "audio_vol_up" // Increase the volume - AudioPlay = "audio_play" - AudioStop = "audio_stop" - AudioPause = "audio_pause" - AudioPrev = "audio_prev" // Previous Track - AudioNext = "audio_next" // Next Track - AudioRewind = "audio_rewind" // Linux only - AudioForward = "audio_forward" // Linux only - AudioRepeat = "audio_repeat" // Linux only - AudioRandom = "audio_random" // Linux only - - Num0 = "num0" // numpad 0 - Num1 = "num1" - Num2 = "num2" - Num3 = "num3" - Num4 = "num4" - Num5 = "num5" - Num6 = "num6" - Num7 = "num7" - Num8 = "num8" - Num9 = "num9" - NumLock = "num_lock" - - NumDecimal = "num." - NumPlus = "num+" - NumMinus = "num-" - NumMul = "num*" - NumDiv = "num/" - NumClear = "num_clear" - NumEnter = "num_enter" - NumEqual = "num_equal" - - LightsMonUp = "lights_mon_up" // Turn up monitor brightness - LightsMonDown = "lights_mon_down" // Turn down monitor brightness - LightsKbdToggle = "lights_kbd_toggle" // Toggle keyboard backlight on/off - LightsKbdUp = "lights_kbd_up" // Turn up keyboard backlight brightness - LightsKbdDown = "lights_kbd_down" -) - -// Modifier represents a keyboard modifier key (ctrl, alt, shift, cmd). -type Modifier string - -// Modifier key constants. -const ( - ModNone Modifier = "" - ModAlt Modifier = "alt" - ModCtrl Modifier = "ctrl" - ModShift Modifier = "shift" - ModCmd Modifier = "cmd" // macOS Command / Windows Win key -) - -const keyErrMessage = "Invalid key flag specified." -const keyActionErrMessage = "key action failed" - -var ErrKeyActionFailed = errors.New(keyActionErrMessage) - -func keyActionError(op, key string, pid int, code C.int) error { - if code == 0 { - return nil - } - - detail := cErrorDetail(code) - if pid > 0 { - return fmt.Errorf("%w: %s(%q) pid=%d: %s", ErrKeyActionFailed, op, key, pid, detail) - } - return fmt.Errorf("%w: %s(%q): %s", ErrKeyActionFailed, op, key, detail) -} - -func cErrorDetail(code C.int) string { - if code > 0 { - return fmt.Sprintf("%s (code=%d)", syscall.Errno(code), int(code)) - } - return fmt.Sprintf("code=%d", int(code)) -} - -func keyNameMap() map[string]C.MMKeyCode { - return map[string]C.MMKeyCode{ - "backspace": C.K_BACKSPACE, - "delete": C.K_DELETE, - "enter": C.K_RETURN, - "tab": C.K_TAB, - "esc": C.K_ESCAPE, - "escape": C.K_ESCAPE, - "up": C.K_UP, - "down": C.K_DOWN, - "right": C.K_RIGHT, - "left": C.K_LEFT, - "home": C.K_HOME, - "end": C.K_END, - "pageup": C.K_PAGEUP, - "pagedown": C.K_PAGEDOWN, - // - "f1": C.K_F1, - "f2": C.K_F2, - "f3": C.K_F3, - "f4": C.K_F4, - "f5": C.K_F5, - "f6": C.K_F6, - "f7": C.K_F7, - "f8": C.K_F8, - "f9": C.K_F9, - "f10": C.K_F10, - "f11": C.K_F11, - "f12": C.K_F12, - "f13": C.K_F13, - "f14": C.K_F14, - "f15": C.K_F15, - "f16": C.K_F16, - "f17": C.K_F17, - "f18": C.K_F18, - "f19": C.K_F19, - "f20": C.K_F20, - "f21": C.K_F21, - "f22": C.K_F22, - "f23": C.K_F23, - "f24": C.K_F24, - // - "cmd": C.K_META, - "lcmd": C.K_LMETA, - "rcmd": C.K_RMETA, - "command": C.K_META, - "alt": C.K_ALT, - "lalt": C.K_LALT, - "ralt": C.K_RALT, - "ctrl": C.K_CONTROL, - "lctrl": C.K_LCONTROL, - "rctrl": C.K_RCONTROL, - "control": C.K_CONTROL, - "shift": C.K_SHIFT, - "lshift": C.K_LSHIFT, - "rshift": C.K_RSHIFT, - "right_shift": C.K_RSHIFT, - "capslock": C.K_CAPSLOCK, - "space": C.K_SPACE, - "print": C.K_PRINTSCREEN, - "printscreen": C.K_PRINTSCREEN, - "insert": C.K_INSERT, - "menu": C.K_MENU, - - "audio_mute": C.K_AUDIO_VOLUME_MUTE, - "audio_vol_down": C.K_AUDIO_VOLUME_DOWN, - "audio_vol_up": C.K_AUDIO_VOLUME_UP, - "audio_play": C.K_AUDIO_PLAY, - "audio_stop": C.K_AUDIO_STOP, - "audio_pause": C.K_AUDIO_PAUSE, - "audio_prev": C.K_AUDIO_PREV, - "audio_next": C.K_AUDIO_NEXT, - "audio_rewind": C.K_AUDIO_REWIND, - "audio_forward": C.K_AUDIO_FORWARD, - "audio_repeat": C.K_AUDIO_REPEAT, - "audio_random": C.K_AUDIO_RANDOM, - - "num0": C.K_NUMPAD_0, - "num1": C.K_NUMPAD_1, - "num2": C.K_NUMPAD_2, - "num3": C.K_NUMPAD_3, - "num4": C.K_NUMPAD_4, - "num5": C.K_NUMPAD_5, - "num6": C.K_NUMPAD_6, - "num7": C.K_NUMPAD_7, - "num8": C.K_NUMPAD_8, - "num9": C.K_NUMPAD_9, - "num_lock": C.K_NUMPAD_LOCK, - - // todo: removed - "numpad_0": C.K_NUMPAD_0, - "numpad_1": C.K_NUMPAD_1, - "numpad_2": C.K_NUMPAD_2, - "numpad_3": C.K_NUMPAD_3, - "numpad_4": C.K_NUMPAD_4, - "numpad_5": C.K_NUMPAD_5, - "numpad_6": C.K_NUMPAD_6, - "numpad_7": C.K_NUMPAD_7, - "numpad_8": C.K_NUMPAD_8, - "numpad_9": C.K_NUMPAD_9, - "numpad_lock": C.K_NUMPAD_LOCK, - - "num.": C.K_NUMPAD_DECIMAL, - "num+": C.K_NUMPAD_PLUS, - "num-": C.K_NUMPAD_MINUS, - "num*": C.K_NUMPAD_MUL, - "num/": C.K_NUMPAD_DIV, - "num_clear": C.K_NUMPAD_CLEAR, - "num_enter": C.K_NUMPAD_ENTER, - "num_equal": C.K_NUMPAD_EQUAL, - - "lights_mon_up": C.K_LIGHTS_MON_UP, - "lights_mon_down": C.K_LIGHTS_MON_DOWN, - "lights_kbd_toggle": C.K_LIGHTS_KBD_TOGGLE, - "lights_kbd_up": C.K_LIGHTS_KBD_UP, - "lights_kbd_down": C.K_LIGHTS_KBD_DOWN, - } -} - -// CmdCtrl If the operating system is macOS, return the key string "cmd", -// otherwise return the key string "ctrl". -func CmdCtrl() string { - if runtime.GOOS == "darwin" { - return "cmd" - } - return "ctrl" -} - -func checkKeyCodes(k string) (key C.MMKeyCode, err error) { - if k == "" { - return - } - - if len(k) == 1 { - val1 := C.CString(k) - defer C.free(unsafe.Pointer(val1)) - - key = C.keyCodeForChar(*val1) - if key == C.K_NOT_A_KEY { - err = errors.New(keyErrMessage) - return - } - return - } - - if v, ok := keyNameMap()[k]; ok { - key = v - if key == C.K_NOT_A_KEY { - err = errors.New(keyErrMessage) - return - } - } - return -} - -// modifiersToFlags converts Modifier slice to C.MMKeyFlags. -func modifiersToFlags(modifiers []Modifier) C.MMKeyFlags { - var flags C.MMKeyFlags = C.MOD_NONE - for _, m := range modifiers { - switch m { - case ModAlt: - flags |= C.MOD_ALT - case ModCtrl: - flags |= C.MOD_CONTROL - case ModShift: - flags |= C.MOD_SHIFT - case ModCmd: - flags |= C.MOD_META - } - } - return flags -} - -func appendModifier(modifiers []Modifier, modifier Modifier) []Modifier { - for _, m := range modifiers { - if m == modifier { - return modifiers - } - } - return append(modifiers, modifier) -} - -func normalizeKeyAndModifiers(key string, modifiers []Modifier) (string, []Modifier) { - if len(key) == 1 && unicode.IsUpper([]rune(key)[0]) { - modifiers = appendModifier(modifiers, ModShift) - key = strings.ToLower(key) - } - - if replacement, ok := lookupSpecialKey(key); ok { - key = replacement - modifiers = appendModifier(modifiers, ModShift) - } - - return key, modifiers -} - -// KeyTap taps the keyboard code with optional modifier keys (atomic operation). -func KeyTap(key string, modifiers []Modifier, settings KeyboardSettings) error { - key, modifiers = normalizeKeyAndModifiers(key, modifiers) - - keyCode, err := checkKeyCodes(key) - if err != nil { - return err - } - - flags := modifiersToFlags(modifiers) - - // Atomic operation - C layer handles platform differences. - ret := C.keyTap(keyCode, flags) - - MilliSleep(settings.Sleep) - return keyActionError("keyTap", key, 0, ret) -} - -// KeyTapWithPID taps the keyboard code on a specific process. -func KeyTapWithPID(key string, pid int, modifiers []Modifier, settings KeyboardSettings) error { - key, modifiers = normalizeKeyAndModifiers(key, modifiers) - - keyCode, err := checkKeyCodes(key) - if err != nil { - return err - } - - flags := modifiersToFlags(modifiers) - - // PID-specific operation. - ret := C.keyTapPid(keyCode, flags, C.uintptr(pid)) - - MilliSleep(settings.Sleep) - return keyActionError("keyTapPid", key, pid, ret) -} - -// KeyToggle toggles a key up or down with optional modifier keys (atomic operation). -func KeyToggle(key string, down bool, modifiers []Modifier, settings KeyboardSettings) error { - key, modifiers = normalizeKeyAndModifiers(key, modifiers) - - keyCode, err := checkKeyCodes(key) - if err != nil { - return err - } - - flags := modifiersToFlags(modifiers) - - // Atomic operation - C layer handles platform differences. - ret := C.keyToggle(keyCode, C.bool(down), flags) - - MilliSleep(settings.Sleep) - return keyActionError("keyToggle", key, 0, ret) -} - -// KeyToggleWithPID toggles a key on a specific process. -func KeyToggleWithPID(key string, down bool, pid int, modifiers []Modifier, settings KeyboardSettings) error { - key, modifiers = normalizeKeyAndModifiers(key, modifiers) - - keyCode, err := checkKeyCodes(key) - if err != nil { - return err - } - - flags := modifiersToFlags(modifiers) - - // PID-specific operation. - ret := C.keyTogglePid(keyCode, C.bool(down), flags, C.uintptr(pid)) - - MilliSleep(settings.Sleep) - return keyActionError("keyTogglePid", key, pid, ret) -} - -// KeyPress press and release a key with random delay (more human-like). -func KeyPress(key string, modifiers []Modifier, settings KeyboardSettings) error { - err := KeyToggle(key, true, modifiers, settings) - if err != nil { - return err - } - - MilliSleep(1 + rand.Intn(3)) - return KeyToggle(key, false, modifiers, settings) -} - -// CharCodeAt char code at utf-8. -func CharCodeAt(s string, n int) rune { - i := 0 - for _, r := range s { - if i == n { - return r - } - i++ - } - - return 0 -} - -// UnicodeType tap the uint32 unicode. -func UnicodeType(str uint32, pid int, isPid bool) { - cstr := C.uint(str) - isPidFlag := C.int8_t(0) - if isPid { - isPidFlag = C.int8_t(1) - } - C.unicodeType(cstr, C.uintptr(pid), isPidFlag) -} - -// ToUC trans string to unicode []string. -func ToUC(text string) []string { - var uc []string - - for _, r := range text { - textQ := strconv.QuoteToASCII(string(r)) - textUnQ := textQ[1 : len(textQ)-1] - - st := strings.Replace(textUnQ, "\\u", "U", -1) - if st == "\\\\" { - st = "\\" - } - if st == `\"` { - st = `"` - } - uc = append(uc, st) - } - - return uc -} - -func inputUTF(str string) { - cstr := C.CString(str) - C.input_utf(cstr) - - C.free(unsafe.Pointer(cstr)) -} - -// Type type a string (supported UTF-8). -func Type(str string, pid int, settings KeyboardSettings) { - tm := settings.TypeDelay - tm1 := settings.TypeUTFDelay - - if runtime.GOOS == "linux" { - strUc := ToUC(str) - for i := 0; i < len(strUc); i++ { - ru := []rune(strUc[i]) - if len(ru) <= 1 { - ustr := uint32(CharCodeAt(strUc[i], 0)) - UnicodeType(ustr, pid, false) - } else { - inputUTF(strUc[i]) - MilliSleep(tm1) - } - - MilliSleep(tm) - } - return - } - - for i := 0; i < len([]rune(str)); i++ { - ustr := uint32(CharCodeAt(str, i)) - UnicodeType(ustr, pid, false) - MilliSleep(tm) - } - MilliSleep(settings.Sleep) -} - -// TypeDelay type string with delayed. -func TypeDelay(str string, pid int, delay int, settings KeyboardSettings) { - Type(str, pid, settings) - MilliSleep(delay) -} diff --git a/keyboard/cgo.go b/keyboard/cgo.go new file mode 100644 index 0000000..e19331d --- /dev/null +++ b/keyboard/cgo.go @@ -0,0 +1,14 @@ +package keyboard + +/* +#cgo darwin CFLAGS: -x objective-c -Wno-deprecated-declarations +#cgo darwin LDFLAGS: -framework Cocoa -framework CoreFoundation -framework IOKit +#cgo darwin LDFLAGS: -framework Carbon -framework OpenGL +#cgo darwin LDFLAGS: -weak_framework ScreenCaptureKit + +#cgo linux CFLAGS: -I/usr/src +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst + +#cgo windows LDFLAGS: -lgdi32 -luser32 +*/ +import "C" diff --git a/keyboard/defaults.go b/keyboard/defaults.go new file mode 100644 index 0000000..5e139e3 --- /dev/null +++ b/keyboard/defaults.go @@ -0,0 +1,24 @@ +package keyboard + +const ( + DefaultKeySleep = 10 + DefaultTypeDelay = 0 + DefaultTypeUTFDelay = 7 +) + +// KeyboardSettings defines timing parameters for keyboard actions. +// Use DefaultKeyboardSettings() to start from the built-in defaults. +type KeyboardSettings struct { + Sleep int + TypeDelay int + TypeUTFDelay int +} + +// DefaultKeyboardSettings returns the default keyboard settings. +func DefaultKeyboardSettings() KeyboardSettings { + return KeyboardSettings{ + Sleep: DefaultKeySleep, + TypeDelay: DefaultTypeDelay, + TypeUTFDelay: DefaultTypeUTFDelay, + } +} diff --git a/keyboard/key_aliases.go b/keyboard/key_aliases.go new file mode 100644 index 0000000..a81dc59 --- /dev/null +++ b/keyboard/key_aliases.go @@ -0,0 +1,27 @@ +package keyboard + +var keyAliases = map[string]string{ + Escape: Esc, + Control: Ctrl, + Printscreen: Print, + "command": Cmd, + "right_shift": Rshift, + "numpad_0": Num0, + "numpad_1": Num1, + "numpad_2": Num2, + "numpad_3": Num3, + "numpad_4": Num4, + "numpad_5": Num5, + "numpad_6": Num6, + "numpad_7": Num7, + "numpad_8": Num8, + "numpad_9": Num9, + "numpad_lock": NumLock, +} + +func canonicalizeKeyName(key string) string { + if replacement, ok := keyAliases[key]; ok { + return replacement + } + return key +} diff --git a/keyboard/key_catalog.go b/keyboard/key_catalog.go new file mode 100644 index 0000000..73437d3 --- /dev/null +++ b/keyboard/key_catalog.go @@ -0,0 +1,45 @@ +package keyboard + +var keyNames = []string{ + KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, + KeyN, KeyO, KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, + CapA, CapB, CapC, CapD, CapE, CapF, CapG, CapH, CapI, CapJ, CapK, CapL, CapM, + CapN, CapO, CapP, CapQ, CapR, CapS, CapT, CapU, CapV, CapW, CapX, CapY, CapZ, + Key0, Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, + Backspace, Delete, Enter, Tab, Esc, Escape, Up, Down, Right, Left, Home, End, + Pageup, Pagedown, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, + F18, F19, F20, F21, F22, F23, F24, + Cmd, Lcmd, Rcmd, Alt, Lalt, Ralt, Ctrl, Lctrl, Rctrl, Control, Shift, Lshift, + Rshift, Capslock, Space, Print, Printscreen, Insert, Menu, + AudioMute, AudioVolDown, AudioVolUp, AudioPlay, AudioStop, AudioPause, AudioPrev, + AudioNext, AudioRewind, AudioForward, AudioRepeat, AudioRandom, + Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9, NumLock, + NumDecimal, NumPlus, NumMinus, NumMul, NumDiv, NumClear, NumEnter, NumEqual, + LightsMonUp, LightsMonDown, LightsKbdToggle, LightsKbdUp, LightsKbdDown, +} + +var modifierNames = []Modifier{ModNone, ModAlt, ModCtrl, ModShift, ModCmd} + +// KeyNames returns a copy of the exported key name constants. +func KeyNames() []string { + names := make([]string, len(keyNames)) + copy(names, keyNames) + return names +} + +// ModifierNames returns a copy of the supported modifier names. +func ModifierNames() []Modifier { + names := make([]Modifier, len(modifierNames)) + copy(names, modifierNames) + return names +} + +// KeyAliases returns a copy of the supported key aliases. +func KeyAliases() map[string]string { + aliases := make(map[string]string, len(keyAliases)) + for k, v := range keyAliases { + aliases[k] = v + } + return aliases +} diff --git a/keyboard/keyboard.go b/keyboard/keyboard.go new file mode 100644 index 0000000..2e83c72 --- /dev/null +++ b/keyboard/keyboard.go @@ -0,0 +1,467 @@ +package keyboard + +/* +#include "../base/types.h" +#include "../base/pubs.h" +#include "../key/keypress_c.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "math/rand" + "runtime" + "strconv" + "strings" + "syscall" + "unicode" + "unsafe" +) + +const keyErrMessage = "Invalid key flag specified." +const keyActionErrMessage = "key action failed" + +var ( + ErrInvalidKey = errors.New(keyErrMessage) + ErrUnsupportedKey = errors.New("key not supported on this platform") + ErrKeyActionFailed = errors.New(keyActionErrMessage) + ErrKeyEventFailed = errors.New("keyboard event creation failed") + ErrKeyDisplayUnavailable = errors.New("display not available") + ErrKeyWindowNotFound = errors.New("window not found") + ErrKeyPostFailed = errors.New("key event post failed") +) + +type mmKeyCode = C.MMKeyCode + +const ( + mmKeyBackspace mmKeyCode = C.K_BACKSPACE + mmKeyDelete mmKeyCode = C.K_DELETE + mmKeyReturn mmKeyCode = C.K_RETURN + mmKeyTab mmKeyCode = C.K_TAB + mmKeyEscape mmKeyCode = C.K_ESCAPE + mmKeyUp mmKeyCode = C.K_UP + mmKeyDown mmKeyCode = C.K_DOWN + mmKeyRight mmKeyCode = C.K_RIGHT + mmKeyLeft mmKeyCode = C.K_LEFT + mmKeyHome mmKeyCode = C.K_HOME + mmKeyEnd mmKeyCode = C.K_END + mmKeyPageUp mmKeyCode = C.K_PAGEUP + mmKeyPageDown mmKeyCode = C.K_PAGEDOWN + mmKeyF1 mmKeyCode = C.K_F1 + mmKeyF2 mmKeyCode = C.K_F2 + mmKeyF3 mmKeyCode = C.K_F3 + mmKeyF4 mmKeyCode = C.K_F4 + mmKeyF5 mmKeyCode = C.K_F5 + mmKeyF6 mmKeyCode = C.K_F6 + mmKeyF7 mmKeyCode = C.K_F7 + mmKeyF8 mmKeyCode = C.K_F8 + mmKeyF9 mmKeyCode = C.K_F9 + mmKeyF10 mmKeyCode = C.K_F10 + mmKeyF11 mmKeyCode = C.K_F11 + mmKeyF12 mmKeyCode = C.K_F12 + mmKeyF13 mmKeyCode = C.K_F13 + mmKeyF14 mmKeyCode = C.K_F14 + mmKeyF15 mmKeyCode = C.K_F15 + mmKeyF16 mmKeyCode = C.K_F16 + mmKeyF17 mmKeyCode = C.K_F17 + mmKeyF18 mmKeyCode = C.K_F18 + mmKeyF19 mmKeyCode = C.K_F19 + mmKeyF20 mmKeyCode = C.K_F20 + mmKeyF21 mmKeyCode = C.K_F21 + mmKeyF22 mmKeyCode = C.K_F22 + mmKeyF23 mmKeyCode = C.K_F23 + mmKeyF24 mmKeyCode = C.K_F24 + mmKeyMeta mmKeyCode = C.K_META + mmKeyLMeta mmKeyCode = C.K_LMETA + mmKeyRMeta mmKeyCode = C.K_RMETA + mmKeyAlt mmKeyCode = C.K_ALT + mmKeyLAlt mmKeyCode = C.K_LALT + mmKeyRAlt mmKeyCode = C.K_RALT + mmKeyControl mmKeyCode = C.K_CONTROL + mmKeyLControl mmKeyCode = C.K_LCONTROL + mmKeyRControl mmKeyCode = C.K_RCONTROL + mmKeyShift mmKeyCode = C.K_SHIFT + mmKeyLShift mmKeyCode = C.K_LSHIFT + mmKeyRShift mmKeyCode = C.K_RSHIFT + mmKeyCapsLock mmKeyCode = C.K_CAPSLOCK + mmKeySpace mmKeyCode = C.K_SPACE + mmKeyPrint mmKeyCode = C.K_PRINTSCREEN + mmKeyInsert mmKeyCode = C.K_INSERT + mmKeyMenu mmKeyCode = C.K_MENU + mmKeyAudioMute mmKeyCode = C.K_AUDIO_VOLUME_MUTE + mmKeyAudioDown mmKeyCode = C.K_AUDIO_VOLUME_DOWN + mmKeyAudioUp mmKeyCode = C.K_AUDIO_VOLUME_UP + mmKeyAudioPlay mmKeyCode = C.K_AUDIO_PLAY + mmKeyAudioStop mmKeyCode = C.K_AUDIO_STOP + mmKeyAudioPause mmKeyCode = C.K_AUDIO_PAUSE + mmKeyAudioPrev mmKeyCode = C.K_AUDIO_PREV + mmKeyAudioNext mmKeyCode = C.K_AUDIO_NEXT + mmKeyAudioRew mmKeyCode = C.K_AUDIO_REWIND + mmKeyAudioFwd mmKeyCode = C.K_AUDIO_FORWARD + mmKeyAudioRep mmKeyCode = C.K_AUDIO_REPEAT + mmKeyAudioRand mmKeyCode = C.K_AUDIO_RANDOM + mmKeyNum0 mmKeyCode = C.K_NUMPAD_0 + mmKeyNum1 mmKeyCode = C.K_NUMPAD_1 + mmKeyNum2 mmKeyCode = C.K_NUMPAD_2 + mmKeyNum3 mmKeyCode = C.K_NUMPAD_3 + mmKeyNum4 mmKeyCode = C.K_NUMPAD_4 + mmKeyNum5 mmKeyCode = C.K_NUMPAD_5 + mmKeyNum6 mmKeyCode = C.K_NUMPAD_6 + mmKeyNum7 mmKeyCode = C.K_NUMPAD_7 + mmKeyNum8 mmKeyCode = C.K_NUMPAD_8 + mmKeyNum9 mmKeyCode = C.K_NUMPAD_9 + mmKeyNumLock mmKeyCode = C.K_NUMPAD_LOCK + mmKeyNumDecimal mmKeyCode = C.K_NUMPAD_DECIMAL + mmKeyNumPlus mmKeyCode = C.K_NUMPAD_PLUS + mmKeyNumMinus mmKeyCode = C.K_NUMPAD_MINUS + mmKeyNumMul mmKeyCode = C.K_NUMPAD_MUL + mmKeyNumDiv mmKeyCode = C.K_NUMPAD_DIV + mmKeyNumClear mmKeyCode = C.K_NUMPAD_CLEAR + mmKeyNumEnter mmKeyCode = C.K_NUMPAD_ENTER + mmKeyNumEqual mmKeyCode = C.K_NUMPAD_EQUAL + mmKeyMonUp mmKeyCode = C.K_LIGHTS_MON_UP + mmKeyMonDown mmKeyCode = C.K_LIGHTS_MON_DOWN + mmKeyKbdToggle mmKeyCode = C.K_LIGHTS_KBD_TOGGLE + mmKeyKbdUp mmKeyCode = C.K_LIGHTS_KBD_UP + mmKeyKbdDown mmKeyCode = C.K_LIGHTS_KBD_DOWN +) + +type KeyError struct { + Key string + OS string + Kind error +} + +func (e *KeyError) Error() string { + if e.OS != "" { + return fmt.Sprintf("%s: %q (os=%s)", e.Kind, e.Key, e.OS) + } + return fmt.Sprintf("%s: %q", e.Kind, e.Key) +} + +func (e *KeyError) Unwrap() error { + return e.Kind +} + +func keyLookupError(kind error, key string) error { + return &KeyError{ + Key: key, + OS: runtime.GOOS, + Kind: kind, + } +} + +type KeyActionError struct { + Op string + Key string + PID int + Code int + Kind error +} + +func (e *KeyActionError) Error() string { + detail := cErrorDetail(C.int(e.Code)) + if e.PID > 0 { + return fmt.Sprintf("%s(%q) pid=%d: %s", e.Op, e.Key, e.PID, detail) + } + return fmt.Sprintf("%s(%q): %s", e.Op, e.Key, detail) +} + +func (e *KeyActionError) Unwrap() error { + return e.Kind +} + +func keyActionError(op, key string, pid int, code C.int) error { + if code == 0 { + return nil + } + + kind := ErrKeyActionFailed + if specific := cErrorKind(code); specific != nil { + kind = errors.Join(ErrKeyActionFailed, specific) + } + + return &KeyActionError{ + Op: op, + Key: key, + PID: pid, + Code: int(code), + Kind: kind, + } +} + +func cErrorDetail(code C.int) string { + if code < 0 { + if kind := cErrorKind(code); kind != nil { + return kind.Error() + } + return fmt.Sprintf("code=%d", int(code)) + } + if code > 0 { + return fmt.Sprintf("%s (code=%d)", syscall.Errno(code), int(code)) + } + return fmt.Sprintf("code=%d", int(code)) +} + +func cErrorKind(code C.int) error { + switch code { + case C.MM_KEY_ERR_EVENT: + return ErrKeyEventFailed + case C.MM_KEY_ERR_DISPLAY: + return ErrKeyDisplayUnavailable + case C.MM_KEY_ERR_WINDOW: + return ErrKeyWindowNotFound + case C.MM_KEY_ERR_POST: + return ErrKeyPostFailed + default: + return nil + } +} + +// CmdCtrl If the operating system is macOS, return the key string "cmd", +// otherwise return the key string "ctrl". +func CmdCtrl() string { + if runtime.GOOS == "darwin" { + return "cmd" + } + return "ctrl" +} + +func checkKeyCodes(k string) (key C.MMKeyCode, err error) { + if k == "" { + return 0, keyLookupError(ErrInvalidKey, k) + } + + if len(k) == 1 { + val1 := C.CString(k) + defer C.free(unsafe.Pointer(val1)) + + key = C.keyCodeForChar(*val1) + if key == C.K_NOT_A_KEY { + return 0, keyLookupError(ErrInvalidKey, k) + } + return + } + + keyName := canonicalizeKeyName(k) + if v, ok := keyNameMap[keyName]; ok { + key = v + if key == C.K_NOT_A_KEY { + return 0, keyLookupError(ErrUnsupportedKey, k) + } + return + } + return 0, keyLookupError(ErrInvalidKey, k) +} + +// modifiersToFlags converts Modifier slice to C.MMKeyFlags. +func modifiersToFlags(modifiers []Modifier) C.MMKeyFlags { + var flags C.MMKeyFlags = C.MOD_NONE + for _, m := range modifiers { + switch m { + case ModAlt: + flags |= C.MOD_ALT + case ModCtrl: + flags |= C.MOD_CONTROL + case ModShift: + flags |= C.MOD_SHIFT + case ModCmd: + flags |= C.MOD_META + } + } + return flags +} + +func appendModifier(modifiers []Modifier, modifier Modifier) []Modifier { + for _, m := range modifiers { + if m == modifier { + return modifiers + } + } + return append(modifiers, modifier) +} + +func normalizeKeyAndModifiers(key string, modifiers []Modifier) (string, []Modifier) { + if len(key) == 1 && unicode.IsUpper([]rune(key)[0]) { + modifiers = appendModifier(modifiers, ModShift) + key = strings.ToLower(key) + } + + if replacement, ok := lookupSpecialKey(key); ok { + key = replacement + modifiers = appendModifier(modifiers, ModShift) + } + + return key, modifiers +} + +// KeyTap taps the keyboard code with optional modifier keys (atomic operation). +func KeyTap(key string, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // Atomic operation - C layer handles platform differences. + ret := C.keyTap(keyCode, flags) + + milliSleep(settings.Sleep) + return keyActionError("keyTap", key, 0, ret) +} + +// KeyTapWithPID taps the keyboard code on a specific process. +func KeyTapWithPID(key string, pid int, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // PID-specific operation. + ret := C.keyTapPid(keyCode, flags, C.uintptr(pid)) + + milliSleep(settings.Sleep) + return keyActionError("keyTapPid", key, pid, ret) +} + +// KeyToggle toggles a key up or down with optional modifier keys (atomic operation). +func KeyToggle(key string, down bool, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // Atomic operation - C layer handles platform differences. + ret := C.keyToggle(keyCode, C.bool(down), flags) + + milliSleep(settings.Sleep) + return keyActionError("keyToggle", key, 0, ret) +} + +// KeyToggleWithPID toggles a key on a specific process. +func KeyToggleWithPID(key string, down bool, pid int, modifiers []Modifier, settings KeyboardSettings) error { + key, modifiers = normalizeKeyAndModifiers(key, modifiers) + + keyCode, err := checkKeyCodes(key) + if err != nil { + return err + } + + flags := modifiersToFlags(modifiers) + + // PID-specific operation. + ret := C.keyTogglePid(keyCode, C.bool(down), flags, C.uintptr(pid)) + + milliSleep(settings.Sleep) + return keyActionError("keyTogglePid", key, pid, ret) +} + +// KeyPress press and release a key with random delay (more human-like). +func KeyPress(key string, modifiers []Modifier, settings KeyboardSettings) error { + err := KeyToggle(key, true, modifiers, settings) + if err != nil { + return err + } + + milliSleep(1 + rand.Intn(3)) + return KeyToggle(key, false, modifiers, settings) +} + +// CharCodeAt char code at utf-8. +func CharCodeAt(s string, n int) rune { + i := 0 + for _, r := range s { + if i == n { + return r + } + i++ + } + + return 0 +} + +// UnicodeType tap the uint32 unicode. +func UnicodeType(str uint32, pid int, isPid bool) { + cstr := C.uint(str) + isPidFlag := C.int8_t(0) + if isPid { + isPidFlag = C.int8_t(1) + } + C.unicodeType(cstr, C.uintptr(pid), isPidFlag) +} + +// ToUC trans string to unicode []string. +func ToUC(text string) []string { + var uc []string + + for _, r := range text { + textQ := strconv.QuoteToASCII(string(r)) + textUnQ := textQ[1 : len(textQ)-1] + + st := strings.Replace(textUnQ, "\\u", "U", -1) + if st == "\\\\" { + st = "\\" + } + if st == `\"` { + st = `"` + } + uc = append(uc, st) + } + + return uc +} + +func inputUTF(str string) { + cstr := C.CString(str) + C.input_utf(cstr) + + C.free(unsafe.Pointer(cstr)) +} + +// Type type a string (supported UTF-8). +func Type(str string, pid int, settings KeyboardSettings) { + tm := settings.TypeDelay + tm1 := settings.TypeUTFDelay + + if runtime.GOOS == "linux" { + strUc := ToUC(str) + for i := 0; i < len(strUc); i++ { + ru := []rune(strUc[i]) + if len(ru) <= 1 { + ustr := uint32(CharCodeAt(strUc[i], 0)) + UnicodeType(ustr, pid, false) + } else { + inputUTF(strUc[i]) + milliSleep(tm1) + } + + milliSleep(tm) + } + return + } + + for i := 0; i < len([]rune(str)); i++ { + ustr := uint32(CharCodeAt(str, i)) + UnicodeType(ustr, pid, false) + milliSleep(tm) + } + milliSleep(settings.Sleep) +} + +// TypeDelay type string with delayed. +func TypeDelay(str string, pid int, delay int, settings KeyboardSettings) { + Type(str, pid, settings) + milliSleep(delay) +} diff --git a/keyboard/keymap_darwin.go b/keyboard/keymap_darwin.go new file mode 100644 index 0000000..dd99d6d --- /dev/null +++ b/keyboard/keymap_darwin.go @@ -0,0 +1,103 @@ +//go:build darwin +// +build darwin + +package keyboard + +var keyNameMap = map[string]mmKeyCode{ + Backspace: mmKeyBackspace, + Delete: mmKeyDelete, + Enter: mmKeyReturn, + Tab: mmKeyTab, + Esc: mmKeyEscape, + Up: mmKeyUp, + Down: mmKeyDown, + Right: mmKeyRight, + Left: mmKeyLeft, + Home: mmKeyHome, + End: mmKeyEnd, + Pageup: mmKeyPageUp, + Pagedown: mmKeyPageDown, + // + F1: mmKeyF1, + F2: mmKeyF2, + F3: mmKeyF3, + F4: mmKeyF4, + F5: mmKeyF5, + F6: mmKeyF6, + F7: mmKeyF7, + F8: mmKeyF8, + F9: mmKeyF9, + F10: mmKeyF10, + F11: mmKeyF11, + F12: mmKeyF12, + F13: mmKeyF13, + F14: mmKeyF14, + F15: mmKeyF15, + F16: mmKeyF16, + F17: mmKeyF17, + F18: mmKeyF18, + F19: mmKeyF19, + F20: mmKeyF20, + F21: mmKeyF21, + F22: mmKeyF22, + F23: mmKeyF23, + F24: mmKeyF24, + // + Cmd: mmKeyMeta, + Lcmd: mmKeyLMeta, + Rcmd: mmKeyRMeta, + Alt: mmKeyAlt, + Lalt: mmKeyLAlt, + Ralt: mmKeyRAlt, + Ctrl: mmKeyControl, + Lctrl: mmKeyLControl, + Rctrl: mmKeyRControl, + Shift: mmKeyShift, + Lshift: mmKeyLShift, + Rshift: mmKeyRShift, + Capslock: mmKeyCapsLock, + Space: mmKeySpace, + Print: mmKeyPrint, + Insert: mmKeyInsert, + Menu: mmKeyMenu, + + AudioMute: mmKeyAudioMute, + AudioVolDown: mmKeyAudioDown, + AudioVolUp: mmKeyAudioUp, + AudioPlay: mmKeyAudioPlay, + AudioStop: mmKeyAudioStop, + AudioPause: mmKeyAudioPause, + AudioPrev: mmKeyAudioPrev, + AudioNext: mmKeyAudioNext, + AudioRewind: mmKeyAudioRew, + AudioForward: mmKeyAudioFwd, + AudioRepeat: mmKeyAudioRep, + AudioRandom: mmKeyAudioRand, + + Num0: mmKeyNum0, + Num1: mmKeyNum1, + Num2: mmKeyNum2, + Num3: mmKeyNum3, + Num4: mmKeyNum4, + Num5: mmKeyNum5, + Num6: mmKeyNum6, + Num7: mmKeyNum7, + Num8: mmKeyNum8, + Num9: mmKeyNum9, + NumLock: mmKeyNumLock, + // + NumDecimal: mmKeyNumDecimal, + NumPlus: mmKeyNumPlus, + NumMinus: mmKeyNumMinus, + NumMul: mmKeyNumMul, + NumDiv: mmKeyNumDiv, + NumClear: mmKeyNumClear, + NumEnter: mmKeyNumEnter, + NumEqual: mmKeyNumEqual, + + LightsMonUp: mmKeyMonUp, + LightsMonDown: mmKeyMonDown, + LightsKbdToggle: mmKeyKbdToggle, + LightsKbdUp: mmKeyKbdUp, + LightsKbdDown: mmKeyKbdDown, +} diff --git a/keyboard/keymap_windows.go b/keyboard/keymap_windows.go new file mode 100644 index 0000000..97b7e99 --- /dev/null +++ b/keyboard/keymap_windows.go @@ -0,0 +1,103 @@ +//go:build windows +// +build windows + +package keyboard + +var keyNameMap = map[string]mmKeyCode{ + Backspace: mmKeyBackspace, + Delete: mmKeyDelete, + Enter: mmKeyReturn, + Tab: mmKeyTab, + Esc: mmKeyEscape, + Up: mmKeyUp, + Down: mmKeyDown, + Right: mmKeyRight, + Left: mmKeyLeft, + Home: mmKeyHome, + End: mmKeyEnd, + Pageup: mmKeyPageUp, + Pagedown: mmKeyPageDown, + // + F1: mmKeyF1, + F2: mmKeyF2, + F3: mmKeyF3, + F4: mmKeyF4, + F5: mmKeyF5, + F6: mmKeyF6, + F7: mmKeyF7, + F8: mmKeyF8, + F9: mmKeyF9, + F10: mmKeyF10, + F11: mmKeyF11, + F12: mmKeyF12, + F13: mmKeyF13, + F14: mmKeyF14, + F15: mmKeyF15, + F16: mmKeyF16, + F17: mmKeyF17, + F18: mmKeyF18, + F19: mmKeyF19, + F20: mmKeyF20, + F21: mmKeyF21, + F22: mmKeyF22, + F23: mmKeyF23, + F24: mmKeyF24, + // + Cmd: mmKeyMeta, + Lcmd: mmKeyLMeta, + Rcmd: mmKeyRMeta, + Alt: mmKeyAlt, + Lalt: mmKeyLAlt, + Ralt: mmKeyRAlt, + Ctrl: mmKeyControl, + Lctrl: mmKeyLControl, + Rctrl: mmKeyRControl, + Shift: mmKeyShift, + Lshift: mmKeyLShift, + Rshift: mmKeyRShift, + Capslock: mmKeyCapsLock, + Space: mmKeySpace, + Print: mmKeyPrint, + Insert: mmKeyInsert, + Menu: mmKeyMenu, + + AudioMute: mmKeyAudioMute, + AudioVolDown: mmKeyAudioDown, + AudioVolUp: mmKeyAudioUp, + AudioPlay: mmKeyAudioPlay, + AudioStop: mmKeyAudioStop, + AudioPause: mmKeyAudioPause, + AudioPrev: mmKeyAudioPrev, + AudioNext: mmKeyAudioNext, + AudioRewind: mmKeyAudioRew, + AudioForward: mmKeyAudioFwd, + AudioRepeat: mmKeyAudioRep, + AudioRandom: mmKeyAudioRand, + + Num0: mmKeyNum0, + Num1: mmKeyNum1, + Num2: mmKeyNum2, + Num3: mmKeyNum3, + Num4: mmKeyNum4, + Num5: mmKeyNum5, + Num6: mmKeyNum6, + Num7: mmKeyNum7, + Num8: mmKeyNum8, + Num9: mmKeyNum9, + NumLock: mmKeyNumLock, + // + NumDecimal: mmKeyNumDecimal, + NumPlus: mmKeyNumPlus, + NumMinus: mmKeyNumMinus, + NumMul: mmKeyNumMul, + NumDiv: mmKeyNumDiv, + NumClear: mmKeyNumClear, + NumEnter: mmKeyNumEnter, + NumEqual: mmKeyNumEqual, + + LightsMonUp: mmKeyMonUp, + LightsMonDown: mmKeyMonDown, + LightsKbdToggle: mmKeyKbdToggle, + LightsKbdUp: mmKeyKbdUp, + LightsKbdDown: mmKeyKbdDown, +} diff --git a/keyboard/keymap_x11.go b/keyboard/keymap_x11.go new file mode 100644 index 0000000..fb72016 --- /dev/null +++ b/keyboard/keymap_x11.go @@ -0,0 +1,103 @@ +//go:build !windows && !darwin +// +build !windows,!darwin + +package keyboard + +var keyNameMap = map[string]mmKeyCode{ + Backspace: mmKeyBackspace, + Delete: mmKeyDelete, + Enter: mmKeyReturn, + Tab: mmKeyTab, + Esc: mmKeyEscape, + Up: mmKeyUp, + Down: mmKeyDown, + Right: mmKeyRight, + Left: mmKeyLeft, + Home: mmKeyHome, + End: mmKeyEnd, + Pageup: mmKeyPageUp, + Pagedown: mmKeyPageDown, + // + F1: mmKeyF1, + F2: mmKeyF2, + F3: mmKeyF3, + F4: mmKeyF4, + F5: mmKeyF5, + F6: mmKeyF6, + F7: mmKeyF7, + F8: mmKeyF8, + F9: mmKeyF9, + F10: mmKeyF10, + F11: mmKeyF11, + F12: mmKeyF12, + F13: mmKeyF13, + F14: mmKeyF14, + F15: mmKeyF15, + F16: mmKeyF16, + F17: mmKeyF17, + F18: mmKeyF18, + F19: mmKeyF19, + F20: mmKeyF20, + F21: mmKeyF21, + F22: mmKeyF22, + F23: mmKeyF23, + F24: mmKeyF24, + // + Cmd: mmKeyMeta, + Lcmd: mmKeyLMeta, + Rcmd: mmKeyRMeta, + Alt: mmKeyAlt, + Lalt: mmKeyLAlt, + Ralt: mmKeyRAlt, + Ctrl: mmKeyControl, + Lctrl: mmKeyLControl, + Rctrl: mmKeyRControl, + Shift: mmKeyShift, + Lshift: mmKeyLShift, + Rshift: mmKeyRShift, + Capslock: mmKeyCapsLock, + Space: mmKeySpace, + Print: mmKeyPrint, + Insert: mmKeyInsert, + Menu: mmKeyMenu, + + AudioMute: mmKeyAudioMute, + AudioVolDown: mmKeyAudioDown, + AudioVolUp: mmKeyAudioUp, + AudioPlay: mmKeyAudioPlay, + AudioStop: mmKeyAudioStop, + AudioPause: mmKeyAudioPause, + AudioPrev: mmKeyAudioPrev, + AudioNext: mmKeyAudioNext, + AudioRewind: mmKeyAudioRew, + AudioForward: mmKeyAudioFwd, + AudioRepeat: mmKeyAudioRep, + AudioRandom: mmKeyAudioRand, + + Num0: mmKeyNum0, + Num1: mmKeyNum1, + Num2: mmKeyNum2, + Num3: mmKeyNum3, + Num4: mmKeyNum4, + Num5: mmKeyNum5, + Num6: mmKeyNum6, + Num7: mmKeyNum7, + Num8: mmKeyNum8, + Num9: mmKeyNum9, + NumLock: mmKeyNumLock, + // + NumDecimal: mmKeyNumDecimal, + NumPlus: mmKeyNumPlus, + NumMinus: mmKeyNumMinus, + NumMul: mmKeyNumMul, + NumDiv: mmKeyNumDiv, + NumClear: mmKeyNumClear, + NumEnter: mmKeyNumEnter, + NumEqual: mmKeyNumEqual, + + LightsMonUp: mmKeyMonUp, + LightsMonDown: mmKeyMonDown, + LightsKbdToggle: mmKeyKbdToggle, + LightsKbdUp: mmKeyKbdUp, + LightsKbdDown: mmKeyKbdDown, +} diff --git a/keyboard/keys.go b/keyboard/keys.go new file mode 100644 index 0000000..b8371fc --- /dev/null +++ b/keyboard/keys.go @@ -0,0 +1,185 @@ +package keyboard + +// Defining a bunch of constants. +const ( + // KeyA define key "a" + KeyA = "a" + KeyB = "b" + KeyC = "c" + KeyD = "d" + KeyE = "e" + KeyF = "f" + KeyG = "g" + KeyH = "h" + KeyI = "i" + KeyJ = "j" + KeyK = "k" + KeyL = "l" + KeyM = "m" + KeyN = "n" + KeyO = "o" + KeyP = "p" + KeyQ = "q" + KeyR = "r" + KeyS = "s" + KeyT = "t" + KeyU = "u" + KeyV = "v" + KeyW = "w" + KeyX = "x" + KeyY = "y" + KeyZ = "z" + // + CapA = "A" + CapB = "B" + CapC = "C" + CapD = "D" + CapE = "E" + CapF = "F" + CapG = "G" + CapH = "H" + CapI = "I" + CapJ = "J" + CapK = "K" + CapL = "L" + CapM = "M" + CapN = "N" + CapO = "O" + CapP = "P" + CapQ = "Q" + CapR = "R" + CapS = "S" + CapT = "T" + CapU = "U" + CapV = "V" + CapW = "W" + CapX = "X" + CapY = "Y" + CapZ = "Z" + // + Key0 = "0" + Key1 = "1" + Key2 = "2" + Key3 = "3" + Key4 = "4" + Key5 = "5" + Key6 = "6" + Key7 = "7" + Key8 = "8" + Key9 = "9" + + // Backspace backspace key string + Backspace = "backspace" + Delete = "delete" + Enter = "enter" + Tab = "tab" + Esc = "esc" + Escape = "escape" + Up = "up" // Up arrow key + Down = "down" // Down arrow key + Right = "right" // Right arrow key + Left = "left" // Left arrow key + Home = "home" + End = "end" + Pageup = "pageup" + Pagedown = "pagedown" + + F1 = "f1" + F2 = "f2" + F3 = "f3" + F4 = "f4" + F5 = "f5" + F6 = "f6" + F7 = "f7" + F8 = "f8" + F9 = "f9" + F10 = "f10" + F11 = "f11" + F12 = "f12" + F13 = "f13" + F14 = "f14" + F15 = "f15" + F16 = "f16" + F17 = "f17" + F18 = "f18" + F19 = "f19" + F20 = "f20" + F21 = "f21" + F22 = "f22" + F23 = "f23" + F24 = "f24" + + Cmd = "cmd" // is the "win" key for windows + Lcmd = "lcmd" // left command + Rcmd = "rcmd" // right command + // "command" + Alt = "alt" + Lalt = "lalt" // left alt + Ralt = "ralt" // right alt + Ctrl = "ctrl" + Lctrl = "lctrl" // left ctrl + Rctrl = "rctrl" // right ctrl + Control = "control" + Shift = "shift" + Lshift = "lshift" // left shift + Rshift = "rshift" // right shift + // "right_shift" + Capslock = "capslock" + Space = "space" + Print = "print" + Printscreen = "printscreen" // No Mac support + Insert = "insert" + Menu = "menu" // Windows only + + AudioMute = "audio_mute" // Mute the volume + AudioVolDown = "audio_vol_down" // Lower the volume + AudioVolUp = "audio_vol_up" // Increase the volume + AudioPlay = "audio_play" + AudioStop = "audio_stop" + AudioPause = "audio_pause" + AudioPrev = "audio_prev" // Previous Track + AudioNext = "audio_next" // Next Track + AudioRewind = "audio_rewind" // Linux only + AudioForward = "audio_forward" // Linux only + AudioRepeat = "audio_repeat" // Linux only + AudioRandom = "audio_random" // Linux only + + Num0 = "num0" // numpad 0 + Num1 = "num1" + Num2 = "num2" + Num3 = "num3" + Num4 = "num4" + Num5 = "num5" + Num6 = "num6" + Num7 = "num7" + Num8 = "num8" + Num9 = "num9" + NumLock = "num_lock" + + NumDecimal = "num." + NumPlus = "num+" + NumMinus = "num-" + NumMul = "num*" + NumDiv = "num/" + NumClear = "num_clear" + NumEnter = "num_enter" + NumEqual = "num_equal" + + LightsMonUp = "lights_mon_up" // Turn up monitor brightness + LightsMonDown = "lights_mon_down" // Turn down monitor brightness + LightsKbdToggle = "lights_kbd_toggle" // Toggle keyboard backlight on/off + LightsKbdUp = "lights_kbd_up" // Turn up keyboard backlight brightness + LightsKbdDown = "lights_kbd_down" +) + +// Modifier represents a keyboard modifier key (ctrl, alt, shift, cmd). +type Modifier string + +// Modifier key constants. +const ( + ModNone Modifier = "" + ModAlt Modifier = "alt" + ModCtrl Modifier = "ctrl" + ModShift Modifier = "shift" + ModCmd Modifier = "cmd" // macOS Command / Windows Win key +) diff --git a/keyboard/sleep.go b/keyboard/sleep.go new file mode 100644 index 0000000..721e149 --- /dev/null +++ b/keyboard/sleep.go @@ -0,0 +1,7 @@ +package keyboard + +import "time" + +func milliSleep(ms int) { + time.Sleep(time.Duration(ms) * time.Millisecond) +} diff --git a/special.go b/keyboard/special.go similarity index 98% rename from special.go rename to keyboard/special.go index 3c432c9..e91beee 100644 --- a/special.go +++ b/keyboard/special.go @@ -1,4 +1,4 @@ -package deskact +package keyboard // DefaultSpecialKeys returns the default special-key mapping. func DefaultSpecialKeys() map[string]string { diff --git a/keyboard_exports.go b/keyboard_exports.go new file mode 100644 index 0000000..6a0c913 --- /dev/null +++ b/keyboard_exports.go @@ -0,0 +1,248 @@ +package deskact + +import kbd "github.com/PekingSpades/DeskAct/keyboard" + +type KeyboardSettings = kbd.KeyboardSettings +type Modifier = kbd.Modifier +type KeyError = kbd.KeyError +type KeyActionError = kbd.KeyActionError + +var ( + ErrInvalidKey = kbd.ErrInvalidKey + ErrUnsupportedKey = kbd.ErrUnsupportedKey + ErrKeyActionFailed = kbd.ErrKeyActionFailed + ErrKeyEventFailed = kbd.ErrKeyEventFailed + ErrKeyDisplayUnavailable = kbd.ErrKeyDisplayUnavailable + ErrKeyWindowNotFound = kbd.ErrKeyWindowNotFound + ErrKeyPostFailed = kbd.ErrKeyPostFailed +) + +const ( + DefaultKeySleep = kbd.DefaultKeySleep + DefaultTypeDelay = kbd.DefaultTypeDelay + DefaultTypeUTFDelay = kbd.DefaultTypeUTFDelay + KeyA = kbd.KeyA + KeyB = kbd.KeyB + KeyC = kbd.KeyC + KeyD = kbd.KeyD + KeyE = kbd.KeyE + KeyF = kbd.KeyF + KeyG = kbd.KeyG + KeyH = kbd.KeyH + KeyI = kbd.KeyI + KeyJ = kbd.KeyJ + KeyK = kbd.KeyK + KeyL = kbd.KeyL + KeyM = kbd.KeyM + KeyN = kbd.KeyN + KeyO = kbd.KeyO + KeyP = kbd.KeyP + KeyQ = kbd.KeyQ + KeyR = kbd.KeyR + KeyS = kbd.KeyS + KeyT = kbd.KeyT + KeyU = kbd.KeyU + KeyV = kbd.KeyV + KeyW = kbd.KeyW + KeyX = kbd.KeyX + KeyY = kbd.KeyY + KeyZ = kbd.KeyZ + CapA = kbd.CapA + CapB = kbd.CapB + CapC = kbd.CapC + CapD = kbd.CapD + CapE = kbd.CapE + CapF = kbd.CapF + CapG = kbd.CapG + CapH = kbd.CapH + CapI = kbd.CapI + CapJ = kbd.CapJ + CapK = kbd.CapK + CapL = kbd.CapL + CapM = kbd.CapM + CapN = kbd.CapN + CapO = kbd.CapO + CapP = kbd.CapP + CapQ = kbd.CapQ + CapR = kbd.CapR + CapS = kbd.CapS + CapT = kbd.CapT + CapU = kbd.CapU + CapV = kbd.CapV + CapW = kbd.CapW + CapX = kbd.CapX + CapY = kbd.CapY + CapZ = kbd.CapZ + Key0 = kbd.Key0 + Key1 = kbd.Key1 + Key2 = kbd.Key2 + Key3 = kbd.Key3 + Key4 = kbd.Key4 + Key5 = kbd.Key5 + Key6 = kbd.Key6 + Key7 = kbd.Key7 + Key8 = kbd.Key8 + Key9 = kbd.Key9 + Backspace = kbd.Backspace + Delete = kbd.Delete + Enter = kbd.Enter + Tab = kbd.Tab + Esc = kbd.Esc + Escape = kbd.Escape + Up = kbd.Up + Down = kbd.Down + Right = kbd.Right + Left = kbd.Left + Home = kbd.Home + End = kbd.End + Pageup = kbd.Pageup + Pagedown = kbd.Pagedown + F1 = kbd.F1 + F2 = kbd.F2 + F3 = kbd.F3 + F4 = kbd.F4 + F5 = kbd.F5 + F6 = kbd.F6 + F7 = kbd.F7 + F8 = kbd.F8 + F9 = kbd.F9 + F10 = kbd.F10 + F11 = kbd.F11 + F12 = kbd.F12 + F13 = kbd.F13 + F14 = kbd.F14 + F15 = kbd.F15 + F16 = kbd.F16 + F17 = kbd.F17 + F18 = kbd.F18 + F19 = kbd.F19 + F20 = kbd.F20 + F21 = kbd.F21 + F22 = kbd.F22 + F23 = kbd.F23 + F24 = kbd.F24 + Cmd = kbd.Cmd + Lcmd = kbd.Lcmd + Rcmd = kbd.Rcmd + Alt = kbd.Alt + Lalt = kbd.Lalt + Ralt = kbd.Ralt + Ctrl = kbd.Ctrl + Lctrl = kbd.Lctrl + Rctrl = kbd.Rctrl + Control = kbd.Control + Shift = kbd.Shift + Lshift = kbd.Lshift + Rshift = kbd.Rshift + Capslock = kbd.Capslock + Space = kbd.Space + Print = kbd.Print + Printscreen = kbd.Printscreen + Insert = kbd.Insert + Menu = kbd.Menu + AudioMute = kbd.AudioMute + AudioVolDown = kbd.AudioVolDown + AudioVolUp = kbd.AudioVolUp + AudioPlay = kbd.AudioPlay + AudioStop = kbd.AudioStop + AudioPause = kbd.AudioPause + AudioPrev = kbd.AudioPrev + AudioNext = kbd.AudioNext + AudioRewind = kbd.AudioRewind + AudioForward = kbd.AudioForward + AudioRepeat = kbd.AudioRepeat + AudioRandom = kbd.AudioRandom + Num0 = kbd.Num0 + Num1 = kbd.Num1 + Num2 = kbd.Num2 + Num3 = kbd.Num3 + Num4 = kbd.Num4 + Num5 = kbd.Num5 + Num6 = kbd.Num6 + Num7 = kbd.Num7 + Num8 = kbd.Num8 + Num9 = kbd.Num9 + NumLock = kbd.NumLock + NumDecimal = kbd.NumDecimal + NumPlus = kbd.NumPlus + NumMinus = kbd.NumMinus + NumMul = kbd.NumMul + NumDiv = kbd.NumDiv + NumClear = kbd.NumClear + NumEnter = kbd.NumEnter + NumEqual = kbd.NumEqual + LightsMonUp = kbd.LightsMonUp + LightsMonDown = kbd.LightsMonDown + LightsKbdToggle = kbd.LightsKbdToggle + LightsKbdUp = kbd.LightsKbdUp + LightsKbdDown = kbd.LightsKbdDown + ModNone = kbd.ModNone + ModAlt = kbd.ModAlt + ModCtrl = kbd.ModCtrl + ModShift = kbd.ModShift + ModCmd = kbd.ModCmd +) + +func DefaultKeyboardSettings() KeyboardSettings { + return kbd.DefaultKeyboardSettings() +} + +func KeyNames() []string { + return kbd.KeyNames() +} + +func ModifierNames() []Modifier { + return kbd.ModifierNames() +} + +func KeyAliases() map[string]string { + return kbd.KeyAliases() +} + +func DefaultSpecialKeys() map[string]string { + return kbd.DefaultSpecialKeys() +} + +func CmdCtrl() string { + return kbd.CmdCtrl() +} + +func KeyTap(key string, modifiers []Modifier, settings KeyboardSettings) error { + return kbd.KeyTap(key, modifiers, settings) +} + +func KeyTapWithPID(key string, pid int, modifiers []Modifier, settings KeyboardSettings) error { + return kbd.KeyTapWithPID(key, pid, modifiers, settings) +} + +func KeyToggle(key string, down bool, modifiers []Modifier, settings KeyboardSettings) error { + return kbd.KeyToggle(key, down, modifiers, settings) +} + +func KeyToggleWithPID(key string, down bool, pid int, modifiers []Modifier, settings KeyboardSettings) error { + return kbd.KeyToggleWithPID(key, down, pid, modifiers, settings) +} + +func KeyPress(key string, modifiers []Modifier, settings KeyboardSettings) error { + return kbd.KeyPress(key, modifiers, settings) +} + +func CharCodeAt(s string, n int) rune { + return kbd.CharCodeAt(s, n) +} + +func UnicodeType(str uint32, pid int, isPid bool) { + kbd.UnicodeType(str, pid, isPid) +} + +func ToUC(text string) []string { + return kbd.ToUC(text) +} + +func Type(str string, pid int, settings KeyboardSettings) { + kbd.Type(str, pid, settings) +} + +func TypeDelay(str string, pid int, delay int, settings KeyboardSettings) { + kbd.TypeDelay(str, pid, delay, settings) +} diff --git a/mouse.go b/mouse.go deleted file mode 100644 index 9baed15..0000000 --- a/mouse.go +++ /dev/null @@ -1,285 +0,0 @@ -package deskact - -/* -#include "base/os.h" -#include "mouse/mouse_c.h" -*/ -import "C" - -import ( - "fmt" - "runtime" - "syscall" -) - -const defaultMouseButton = "left" -const defaultScrollDirection = "down" - -func normalizeMouseButton(button string) string { - if button == "" { - return defaultMouseButton - } - return button -} - -func normalizeScrollDirection(direction string) string { - if direction == "" { - return defaultScrollDirection - } - return direction -} - -// CheckMouse check the mouse button. -func CheckMouse(btn string) C.MMMouseButton { - m1 := map[string]C.MMMouseButton{ - "left": C.LEFT_BUTTON, - "center": C.CENTER_BUTTON, - "right": C.RIGHT_BUTTON, - "wheelDown": C.WheelDown, - "wheelUp": C.WheelUp, - "wheelLeft": C.WheelLeft, - "wheelRight": C.WheelRight, - } - if v, ok := m1[btn]; ok { - return v - } - - return C.LEFT_BUTTON -} - -// MouseButtonString converts a C.MMMouseButton to a readable name. -func MouseButtonString(btn C.MMMouseButton) string { - m1 := map[C.MMMouseButton]string{ - C.LEFT_BUTTON: "left", - C.CENTER_BUTTON: "center", - C.RIGHT_BUTTON: "right", - C.WheelDown: "wheelDown", - C.WheelUp: "wheelUp", - C.WheelLeft: "wheelLeft", - C.WheelRight: "wheelRight", - } - if v, ok := m1[btn]; ok { - return v - } - - return fmt.Sprintf("button%d", btn) -} - -// Move move the mouse to (x, y) using absolute coordinates. -func Move(x, y int, settings MouseSettings) { - cx := C.int32_t(x) - cy := C.int32_t(y) - C.moveMouse(C.MMPointInt32Make(cx, cy)) - - MilliSleep(settings.Sleep) -} - -// Drag drag the mouse to (x, y). -// It's not valid now, use the DragSmooth(). -func Drag(x, y int, button string, settings MouseSettings) { - cx := C.int32_t(x) - cy := C.int32_t(y) - - btn := CheckMouse(normalizeMouseButton(button)) - C.dragMouse(C.MMPointInt32Make(cx, cy), btn) - MilliSleep(settings.Sleep) -} - -// DragSmooth drag the mouse like smooth to (x, y). -func DragSmooth(x, y int, settings MouseSettings) { - Toggle(defaultMouseButton, true, false, settings) - MilliSleep(50) - MoveSmooth(x, y, settings) - Toggle(defaultMouseButton, false, false, settings) -} - -// MoveSmooth move the mouse smooth. -func MoveSmooth(x, y int, settings MouseSettings) bool { - cx := C.int32_t(x) - cy := C.int32_t(y) - - low := C.double(settings.MoveSmoothLow) - high := C.double(settings.MoveSmoothHigh) - - cbool := C.smoothlyMoveMouse(C.MMPointInt32Make(cx, cy), low, high) - MilliSleep(settings.Sleep + settings.MoveSmoothDelay) - - return bool(cbool) -} - -// MoveArgs get the mouse relative args. -func MoveArgs(x, y int) (int, int) { - mx, my := Location() - mx = mx + x - my = my + y - - return mx, my -} - -// MoveRelative move mouse with relative. -func MoveRelative(x, y int, settings MouseSettings) { - mx, my := MoveArgs(x, y) - Move(mx, my, settings) -} - -// MoveSmoothRelative move mouse smooth with relative. -func MoveSmoothRelative(x, y int, settings MouseSettings) { - mx, my := MoveArgs(x, y) - MoveSmooth(mx, my, settings) -} - -// Location get the mouse location position return x, y. -func Location() (int, int) { - pos := C.location() - return int(pos.x), int(pos.y) -} - -// Click clicks the mouse button and returns error. -func Click(button string, double bool, settings MouseSettings) error { - btn := CheckMouse(normalizeMouseButton(button)) - - defer MilliSleep(settings.Sleep) - - clickCount := 1 - if double { - clickCount = 2 - } - - if code := C.multiClickErr(btn, C.int(clickCount)); code != 0 { - return formatClickError(int(code), btn, clickCount) - } - - return nil -} - -func formatClickError(code int, button C.MMMouseButton, clickCount int) error { - btnName := MouseButtonString(button) - detail := "" - - switch runtime.GOOS { - case "windows": - if code != 0 { - detail = syscall.Errno(code).Error() - } - case "darwin": - cgErrors := map[int]string{ - 0: "kCGErrorSuccess", - 1000: "kCGErrorFailure", - 1001: "kCGErrorIllegalArgument", - 1002: "kCGErrorInvalidConnection", - 1003: "kCGErrorInvalidContext", - 1004: "kCGErrorCannotComplete", - 1005: "kCGErrorNotImplemented", - 1006: "kCGErrorRangeCheck", - 1007: "kCGErrorTypeCheck", - 1008: "kCGErrorNoCurrentPoint", - 1010: "kCGErrorInvalidOperation", - } - if v, ok := cgErrors[code]; ok { - detail = v - } - default: - if code == 1 { - detail = "XTestFakeButtonEvent returned false" - } - } - - if detail != "" { - return fmt.Errorf("click failed (%s, count=%d): %s (code=%d)", btnName, clickCount, detail, code) - } - - return fmt.Errorf("click failed (%s, count=%d), code=%d", btnName, clickCount, code) -} - -// MultiClick performs multiple clicks and returns error. -func MultiClick(button string, clickCount int, settings MouseSettings) error { - if clickCount < 1 { - return nil - } - - btn := CheckMouse(normalizeMouseButton(button)) - defer MilliSleep(settings.Sleep) - - if code := C.multiClickErr(btn, C.int(clickCount)); code != 0 { - return formatClickError(int(code), btn, clickCount) - } - - return nil -} - -// MoveClick move and click the mouse. -func MoveClick(x, y int, button string, double bool, settings MouseSettings) error { - Move(x, y, settings) - MilliSleep(50) - return Click(button, double, settings) -} - -// MovesClick move smooth and click the mouse. -func MovesClick(x, y int, button string, double bool, settings MouseSettings) error { - MoveSmooth(x, y, settings) - MilliSleep(50) - return Click(button, double, settings) -} - -// Toggle toggle the mouse. -func Toggle(button string, down bool, sleepAfter bool, settings MouseSettings) error { - btn := CheckMouse(normalizeMouseButton(button)) - C.toggleMouseErr(C.bool(down), btn) - if sleepAfter { - MilliSleep(settings.Sleep) - } - - return nil -} - -// Scroll scroll the mouse to (x, y). -func Scroll(x, y int, settings MouseSettings) { - cx := C.int(x) - cy := C.int(y) - - C.scrollMouseXY(cx, cy) - MilliSleep(settings.Sleep + settings.ScrollDelay) -} - -// ScrollDir scroll the mouse with direction. -func ScrollDir(x int, direction string, settings MouseSettings) { - d := normalizeScrollDirection(direction) - - if d == "down" { - Scroll(0, -x, settings) - } - if d == "up" { - Scroll(0, x, settings) - } - - if d == "left" { - Scroll(x, 0, settings) - } - if d == "right" { - Scroll(-x, 0, settings) - } -} - -// ScrollSmooth scroll the mouse smooth. -func ScrollSmooth(to int, settings MouseSettings) { - i := 0 - num := settings.ScrollSmoothCount - tm := settings.ScrollSmoothInterval - tox := settings.ScrollSmoothX - - for { - Scroll(tox, to, settings) - MilliSleep(tm) - i++ - if i == num { - break - } - } - MilliSleep(settings.Sleep) -} - -// ScrollRelative scroll mouse with relative. -func ScrollRelative(x, y int, settings MouseSettings) { - mx, my := MoveArgs(x, y) - Scroll(mx, my, settings) -} diff --git a/mouse/defaults.go b/mouse/defaults.go new file mode 100644 index 0000000..3fe5730 --- /dev/null +++ b/mouse/defaults.go @@ -0,0 +1,36 @@ +package mouse + +const ( + DefaultMouseSleep = 0 + DefaultMoveSmoothLow = 1.0 + DefaultMoveSmoothHigh = 3.0 + DefaultMoveSmoothDelay = 1 + DefaultScrollDelay = 10 + DefaultScrollSmoothCount = 5 + DefaultScrollSmoothInterval = 100 +) + +// MouseSettings defines timing and smoothing parameters for mouse actions. +// Use DefaultMouseSettings() to start from the built-in defaults. +type MouseSettings struct { + Sleep int + MoveSmoothLow float64 + MoveSmoothHigh float64 + MoveSmoothDelay int + ScrollDelay int + ScrollSmoothCount int + ScrollSmoothInterval int +} + +// DefaultMouseSettings returns the default mouse settings. +func DefaultMouseSettings() MouseSettings { + return MouseSettings{ + Sleep: DefaultMouseSleep, + MoveSmoothLow: DefaultMoveSmoothLow, + MoveSmoothHigh: DefaultMoveSmoothHigh, + MoveSmoothDelay: DefaultMoveSmoothDelay, + ScrollDelay: DefaultScrollDelay, + ScrollSmoothCount: DefaultScrollSmoothCount, + ScrollSmoothInterval: DefaultScrollSmoothInterval, + } +} diff --git a/mouse/errors.go b/mouse/errors.go new file mode 100644 index 0000000..2450842 --- /dev/null +++ b/mouse/errors.go @@ -0,0 +1,131 @@ +package mouse + +import ( + "errors" + "fmt" + "runtime" + "strings" + "syscall" +) + +var ( + ErrMouseInvalidButton = errors.New("invalid mouse button") + ErrMouseUnsupportedButton = errors.New("mouse button not supported") + ErrMouseInvalidScrollUnit = errors.New("invalid scroll unit") + ErrMouseUnsupportedScrollUnit = errors.New("scroll unit not supported") + ErrMouseActionFailed = errors.New("mouse action failed") +) + +// MouseOp describes a mouse operation for error reporting. +type MouseOp string + +const ( + MouseOpClick MouseOp = "click" + MouseOpMultiClick MouseOp = "multiClick" + MouseOpToggle MouseOp = "toggle" + MouseOpMove MouseOp = "move" + MouseOpMoveSmooth MouseOp = "moveSmooth" + MouseOpDrag MouseOp = "drag" + MouseOpScroll MouseOp = "scroll" +) + +// MouseError wraps mouse operation failures with context. +type MouseError struct { + Op MouseOp + Button MouseButton + ClickCount int + Unit ScrollUnit + Code int + Detail string + Cause error +} + +func (e *MouseError) Error() string { + if e == nil { + return "" + } + var b strings.Builder + b.WriteString("mouse ") + if e.Op != "" { + b.WriteString(string(e.Op)) + } else { + b.WriteString("error") + } + if e.Button != 0 { + b.WriteString(" ") + b.WriteString(e.Button.String()) + } + if e.ClickCount > 0 { + b.WriteString(fmt.Sprintf(" count=%d", e.ClickCount)) + } + if e.Op == MouseOpScroll { + b.WriteString(" unit=") + b.WriteString(e.Unit.String()) + } + detail := e.Detail + if detail == "" && e.Cause != nil { + detail = e.Cause.Error() + } + if detail != "" { + b.WriteString(": ") + b.WriteString(detail) + } + if e.Code != 0 { + b.WriteString(fmt.Sprintf(" (code=%d)", e.Code)) + } + return b.String() +} + +func (e *MouseError) Unwrap() error { + return e.Cause +} + +func wrapMouseError(op MouseOp, cause error, button MouseButton, unit ScrollUnit, clickCount int, detail string, code int) error { + return &MouseError{ + Op: op, + Button: button, + ClickCount: clickCount, + Unit: unit, + Code: code, + Detail: detail, + Cause: cause, + } +} + +func mouseActionError(op MouseOp, button MouseButton, clickCount int, code int) error { + return wrapMouseError(op, ErrMouseActionFailed, button, 0, clickCount, mouseErrorDetail(code), code) +} + +func mouseErrorDetail(code int) string { + if code == 0 { + return "" + } + + switch runtime.GOOS { + case "windows": + return syscall.Errno(code).Error() + case "darwin": + cgErrors := map[int]string{ + 0: "kCGErrorSuccess", + 1000: "kCGErrorFailure", + 1001: "kCGErrorIllegalArgument", + 1002: "kCGErrorInvalidConnection", + 1003: "kCGErrorInvalidContext", + 1004: "kCGErrorCannotComplete", + 1005: "kCGErrorNotImplemented", + 1006: "kCGErrorRangeCheck", + 1007: "kCGErrorTypeCheck", + 1008: "kCGErrorNoCurrentPoint", + 1010: "kCGErrorInvalidOperation", + } + if v, ok := cgErrors[code]; ok { + return v + } + default: + if code == 1 { + return "XTestFakeButtonEvent returned false" + } + } + + return fmt.Sprintf("code=%d", code) +} diff --git a/mouse/mouse.go b/mouse/mouse.go new file mode 100644 index 0000000..d82e1ed --- /dev/null +++ b/mouse/mouse.go @@ -0,0 +1,202 @@ +package mouse + +/* +#include "mouse_c.h" +*/ +import "C" + +// Move moves the mouse to (x, y) using absolute coordinates. +func Move(x, y int, settings MouseSettings) error { + cx := C.int32_t(x) + cy := C.int32_t(y) + C.moveMouse(C.MMPointInt32Make(cx, cy)) + + MilliSleep(settings.Sleep) + return nil +} + +// Drag drags the mouse to (x, y) from the current position. +func Drag(x, y int, button MouseButton, settings MouseSettings) error { + if err := Toggle(button, true, false, settings); err != nil { + return err + } + MilliSleep(50) + if err := Move(x, y, settings); err != nil { + _ = Toggle(button, false, false, settings) + return err + } + return Toggle(button, false, false, settings) +} + +// DragSmooth drags the mouse smoothly to (x, y) from the current position. +func DragSmooth(x, y int, button MouseButton, settings MouseSettings) error { + if err := Toggle(button, true, false, settings); err != nil { + return err + } + MilliSleep(50) + if err := MoveSmooth(x, y, settings); err != nil { + _ = Toggle(button, false, false, settings) + return err + } + return Toggle(button, false, false, settings) +} + +// MoveSmooth smoothly moves the mouse to (x, y). +func MoveSmooth(x, y int, settings MouseSettings) error { + cx := C.int32_t(x) + cy := C.int32_t(y) + + low := C.double(settings.MoveSmoothLow) + high := C.double(settings.MoveSmoothHigh) + + cbool := C.smoothlyMoveMouse(C.MMPointInt32Make(cx, cy), low, high) + MilliSleep(settings.Sleep + settings.MoveSmoothDelay) + if !bool(cbool) { + return wrapMouseError(MouseOpMoveSmooth, ErrMouseActionFailed, 0, 0, 0, "smooth move returned false", 0) + } + return nil +} + +// MoveArgs get the mouse relative args. +func MoveArgs(x, y int) (int, int) { + mx, my := Location() + mx = mx + x + my = my + y + + return mx, my +} + +// MoveRelative moves the mouse with relative coordinates. +func MoveRelative(x, y int, settings MouseSettings) error { + mx, my := MoveArgs(x, y) + return Move(mx, my, settings) +} + +// MoveSmoothRelative moves the mouse smoothly with relative coordinates. +func MoveSmoothRelative(x, y int, settings MouseSettings) error { + mx, my := MoveArgs(x, y) + return MoveSmooth(mx, my, settings) +} + +// Location returns the mouse location position. +func Location() (int, int) { + pos := C.location() + return int(pos.x), int(pos.y) +} + +// Click clicks the mouse button and returns error. +func Click(button MouseButton, double bool, settings MouseSettings) error { + defer MilliSleep(settings.Sleep) + + clickCount := 1 + if double { + clickCount = 2 + } + + cbtn, err := mouseButtonToC(button) + if err != nil { + return wrapMouseError(MouseOpClick, err, button, 0, clickCount, "", 0) + } + + if code := C.multiClickErr(cbtn, C.int(clickCount)); code != 0 { + return mouseActionError(MouseOpClick, button, clickCount, int(code)) + } + + return nil +} + +// MultiClick performs multiple clicks and returns error. +func MultiClick(button MouseButton, clickCount int, settings MouseSettings) error { + if clickCount < 1 { + return nil + } + + defer MilliSleep(settings.Sleep) + + cbtn, err := mouseButtonToC(button) + if err != nil { + return wrapMouseError(MouseOpMultiClick, err, button, 0, clickCount, "", 0) + } + + if code := C.multiClickErr(cbtn, C.int(clickCount)); code != 0 { + return mouseActionError(MouseOpMultiClick, button, clickCount, int(code)) + } + + return nil +} + +// MoveClick moves and clicks the mouse. +func MoveClick(x, y int, button MouseButton, double bool, settings MouseSettings) error { + if err := Move(x, y, settings); err != nil { + return err + } + MilliSleep(50) + return Click(button, double, settings) +} + +// MovesClick moves smoothly and clicks the mouse. +func MovesClick(x, y int, button MouseButton, double bool, settings MouseSettings) error { + if err := MoveSmooth(x, y, settings); err != nil { + return err + } + MilliSleep(50) + return Click(button, double, settings) +} + +// Toggle toggles a mouse button up or down. +func Toggle(button MouseButton, down bool, sleepAfter bool, settings MouseSettings) error { + cbtn, err := mouseButtonToC(button) + if err != nil { + return wrapMouseError(MouseOpToggle, err, button, 0, 0, "", 0) + } + if code := C.toggleMouseErr(C.bool(down), cbtn); code != 0 { + return mouseActionError(MouseOpToggle, button, 0, int(code)) + } + if sleepAfter { + MilliSleep(settings.Sleep) + } + + return nil +} + +// Scroll scrolls the mouse by a delta. +func Scroll(delta ScrollDelta, settings MouseSettings) error { + caps := CurrentMouseCapabilities() + if !caps.SupportsScrollUnit(delta.Unit) { + return wrapMouseError(MouseOpScroll, ErrMouseUnsupportedScrollUnit, 0, delta.Unit, 0, "", 0) + } + cx, cy, unit, err := scrollDeltaToC(delta) + if err != nil { + return wrapMouseError(MouseOpScroll, err, 0, delta.Unit, 0, "", 0) + } + if cx != 0 || cy != 0 { + C.scrollMouseXY(cx, cy, unit) + } + MilliSleep(settings.Sleep + settings.ScrollDelay) + return nil +} + +// ScrollLines scrolls using line-based deltas. +func ScrollLines(x, y int, settings MouseSettings) error { + return Scroll(ScrollDeltaLines(x, y), settings) +} + +// ScrollPixels scrolls using pixel-based deltas (best-effort on some platforms). +func ScrollPixels(x, y int, settings MouseSettings) error { + return Scroll(ScrollDeltaPixels(x, y), settings) +} + +// ScrollSmooth scrolls smoothly using repeated deltas. +func ScrollSmooth(delta ScrollDelta, settings MouseSettings) error { + if settings.ScrollSmoothCount <= 0 { + return nil + } + for i := 0; i < settings.ScrollSmoothCount; i++ { + if err := Scroll(delta, settings); err != nil { + return err + } + MilliSleep(settings.ScrollSmoothInterval) + } + MilliSleep(settings.Sleep) + return nil +} diff --git a/mouse/mouse.h b/mouse/mouse.h index 52aba72..1918916 100644 --- a/mouse/mouse.h +++ b/mouse/mouse.h @@ -5,43 +5,37 @@ #include "../base/os.h" #include "../base/types.h" #include +#include + +typedef int32_t MMMouseButton; + +typedef enum { + MM_SCROLL_UNIT_LINE = 0, + MM_SCROLL_UNIT_PIXEL = 1, +} MMScrollUnit; #if defined(IS_MACOSX) #include - typedef enum { - LEFT_BUTTON = kCGMouseButtonLeft, - RIGHT_BUTTON = kCGMouseButtonRight, - CENTER_BUTTON = kCGMouseButtonCenter, - WheelDown = 4, - WheelUp = 5, - WheelLeft = 6, - WheelRight = 7, - } MMMouseButton; + #define MM_BUTTON_LEFT kCGMouseButtonLeft + #define MM_BUTTON_RIGHT kCGMouseButtonRight + #define MM_BUTTON_MIDDLE kCGMouseButtonCenter + #define MM_BUTTON_BACK 3 + #define MM_BUTTON_FORWARD 4 #elif defined(USE_X11) - enum _MMMouseButton { - LEFT_BUTTON = 1, - CENTER_BUTTON = 2, - RIGHT_BUTTON = 3, - WheelDown = 4, - WheelUp = 5, - WheelLeft = 6, - WheelRight = 7, - }; - typedef unsigned int MMMouseButton; + #define MM_BUTTON_LEFT 1 + #define MM_BUTTON_MIDDLE 2 + #define MM_BUTTON_RIGHT 3 + #define MM_BUTTON_BACK 8 + #define MM_BUTTON_FORWARD 9 #elif defined(IS_WINDOWS) - enum _MMMouseButton { - LEFT_BUTTON = 1, - CENTER_BUTTON = 2, - RIGHT_BUTTON = 3, - WheelDown = 4, - WheelUp = 5, - WheelLeft = 6, - WheelRight = 7, - }; - typedef unsigned int MMMouseButton; + #define MM_BUTTON_LEFT 1 + #define MM_BUTTON_MIDDLE 2 + #define MM_BUTTON_RIGHT 3 + #define MM_BUTTON_BACK 4 + #define MM_BUTTON_FORWARD 5 #else #error "No mouse button constants set for platform" #endif -#endif /* MOUSE_H */ \ No newline at end of file +#endif /* MOUSE_H */ diff --git a/mouse/mouse_c.h b/mouse/mouse_c.h index 8bcf901..fd33648 100644 --- a/mouse/mouse_c.h +++ b/mouse/mouse_c.h @@ -8,6 +8,8 @@ // This file may not be copied, modified, or distributed // except according to those terms. +#include "../base/os.h" + #if defined(IS_MACOSX) #include "mouse_c_macos.h" #elif defined(USE_X11) diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index 03231c1..b06abc1 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -19,24 +19,24 @@ /* Some convenience macros for converting our enums to the system API types. */ CGEventType MMMouseDownToCGEventType(MMMouseButton button) { - if (button == LEFT_BUTTON) { + if (button == MM_BUTTON_LEFT) { return kCGEventLeftMouseDown; } - if (button == RIGHT_BUTTON) { + if (button == MM_BUTTON_RIGHT) { return kCGEventRightMouseDown; } return kCGEventOtherMouseDown; } CGEventType MMMouseUpToCGEventType(MMMouseButton button) { - if (button == LEFT_BUTTON) { return kCGEventLeftMouseUp; } - if (button == RIGHT_BUTTON) { return kCGEventRightMouseUp; } + if (button == MM_BUTTON_LEFT) { return kCGEventLeftMouseUp; } + if (button == MM_BUTTON_RIGHT) { return kCGEventRightMouseUp; } return kCGEventOtherMouseUp; } CGEventType MMMouseDragToCGEventType(MMMouseButton button) { - if (button == LEFT_BUTTON) { return kCGEventLeftMouseDragged; } - if (button == RIGHT_BUTTON) { return kCGEventRightMouseDragged; } + if (button == MM_BUTTON_LEFT) { return kCGEventLeftMouseDragged; } + if (button == MM_BUTTON_RIGHT) { return kCGEventRightMouseDragged; } return kCGEventOtherMouseDragged; } @@ -145,9 +145,10 @@ int multiClickErr(MMMouseButton button, int clickCount){ } /* Function used to scroll the screen in the required direction. */ -void scrollMouseXY(int x, int y) { +void scrollMouseXY(int x, int y, MMScrollUnit unit) { CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); - CGEventRef event = CGEventCreateScrollWheelEvent(source, kCGScrollEventUnitPixel, 2, y, x); + CGScrollEventUnit cgUnit = unit == MM_SCROLL_UNIT_LINE ? kCGScrollEventUnitLine : kCGScrollEventUnitPixel; + CGEventRef event = CGEventCreateScrollWheelEvent(source, cgUnit, 2, y, x); CGEventPost(kCGHIDEventTap, event); CFRelease(event); diff --git a/mouse/mouse_c_windows.h b/mouse/mouse_c_windows.h index 25a8406..f55221f 100644 --- a/mouse/mouse_c_windows.h +++ b/mouse/mouse_c_windows.h @@ -14,22 +14,31 @@ #include /* For floor() */ -/* Some convenience macros for converting our enums to the system API types. */ -DWORD MMMouseUpToMEventF(MMMouseButton button) { - if (button == LEFT_BUTTON) { return MOUSEEVENTF_LEFTUP; } - if (button == RIGHT_BUTTON) { return MOUSEEVENTF_RIGHTUP; } - return MOUSEEVENTF_MIDDLEUP; -} - -DWORD MMMouseDownToMEventF(MMMouseButton button) { - if (button == LEFT_BUTTON) { return MOUSEEVENTF_LEFTDOWN; } - if (button == RIGHT_BUTTON) { return MOUSEEVENTF_RIGHTDOWN; } - return MOUSEEVENTF_MIDDLEDOWN; -} +static int fillMouseInput(bool down, MMMouseButton button, INPUT *input) { + DWORD flags = 0; + DWORD data = 0; + + if (button == MM_BUTTON_LEFT) { + flags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP; + } else if (button == MM_BUTTON_RIGHT) { + flags = down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP; + } else if (button == MM_BUTTON_MIDDLE) { + flags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP; + } else if (button == MM_BUTTON_BACK || button == MM_BUTTON_FORWARD) { + flags = down ? MOUSEEVENTF_XDOWN : MOUSEEVENTF_XUP; + data = (button == MM_BUTTON_BACK) ? XBUTTON1 : XBUTTON2; + } else { + return ERROR_INVALID_PARAMETER; + } -DWORD MMMouseToMEventF(bool down, MMMouseButton button) { - if (down) { return MMMouseDownToMEventF(button); } - return MMMouseUpToMEventF(button); + input->type = INPUT_MOUSE; + input->mi.dx = 0; + input->mi.dy = 0; + input->mi.dwFlags = flags; + input->mi.time = 0; + input->mi.dwExtraInfo = 0; + input->mi.mouseData = data; + return 0; } /* Move the mouse to a specific point. */ @@ -51,14 +60,10 @@ MMPointInt32 location() { int toggleMouseErr(bool down, MMMouseButton button) { // mouse_event(MMMouseToMEventF(down, button), 0, 0, 0, 0); INPUT mouseInput; - - mouseInput.type = INPUT_MOUSE; - mouseInput.mi.dx = 0; - mouseInput.mi.dy = 0; - mouseInput.mi.dwFlags = MMMouseToMEventF(down, button); - mouseInput.mi.time = 0; - mouseInput.mi.dwExtraInfo = 0; - mouseInput.mi.mouseData = 0; + int err = fillMouseInput(down, button, &mouseInput); + if (err != 0) { + return err; + } UINT sent = SendInput(1, &mouseInput, sizeof(mouseInput)); return sent == 1 ? 0 : (int)GetLastError(); } @@ -88,29 +93,33 @@ int multiClickErr(MMMouseButton button, int clickCount){ } /* Function used to scroll the screen in the required direction. */ -void scrollMouseXY(int x, int y) { +void scrollMouseXY(int x, int y, MMScrollUnit unit) { // Fix for #97, C89 needs variables declared on top of functions (mouseScrollInput) INPUT mouseScrollInputH; INPUT mouseScrollInputV; + int scale = unit == MM_SCROLL_UNIT_LINE ? WHEEL_DELTA : 1; + + if (x != 0) { + mouseScrollInputH.type = INPUT_MOUSE; + mouseScrollInputH.mi.dx = 0; + mouseScrollInputH.mi.dy = 0; + mouseScrollInputH.mi.dwFlags = MOUSEEVENTF_HWHEEL; + mouseScrollInputH.mi.time = 0; + mouseScrollInputH.mi.dwExtraInfo = 0; + mouseScrollInputH.mi.mouseData = (DWORD)(x * scale); + SendInput(1, &mouseScrollInputH, sizeof(mouseScrollInputH)); + } - mouseScrollInputH.type = INPUT_MOUSE; - mouseScrollInputH.mi.dx = 0; - mouseScrollInputH.mi.dy = 0; - mouseScrollInputH.mi.dwFlags = MOUSEEVENTF_WHEEL; - mouseScrollInputH.mi.time = 0; - mouseScrollInputH.mi.dwExtraInfo = 0; - mouseScrollInputH.mi.mouseData = WHEEL_DELTA * x; - - mouseScrollInputV.type = INPUT_MOUSE; - mouseScrollInputV.mi.dx = 0; - mouseScrollInputV.mi.dy = 0; - mouseScrollInputV.mi.dwFlags = MOUSEEVENTF_WHEEL; - mouseScrollInputV.mi.time = 0; - mouseScrollInputV.mi.dwExtraInfo = 0; - mouseScrollInputV.mi.mouseData = WHEEL_DELTA * y; - - SendInput(1, &mouseScrollInputH, sizeof(mouseScrollInputH)); - SendInput(1, &mouseScrollInputV, sizeof(mouseScrollInputV)); + if (y != 0) { + mouseScrollInputV.type = INPUT_MOUSE; + mouseScrollInputV.mi.dx = 0; + mouseScrollInputV.mi.dy = 0; + mouseScrollInputV.mi.dwFlags = MOUSEEVENTF_WHEEL; + mouseScrollInputV.mi.time = 0; + mouseScrollInputV.mi.dwExtraInfo = 0; + mouseScrollInputV.mi.mouseData = (DWORD)(y * scale); + SendInput(1, &mouseScrollInputV, sizeof(mouseScrollInputV)); + } } /* A crude, fast hypot() approximation to get around the fact that hypot() is not a standard ANSI C function. */ diff --git a/mouse/mouse_c_x11.h b/mouse/mouse_c_x11.h index 14643b7..7c8cd87 100644 --- a/mouse/mouse_c_x11.h +++ b/mouse/mouse_c_x11.h @@ -77,7 +77,8 @@ int multiClickErr(MMMouseButton button, int clickCount){ } /* Function used to scroll the screen in the required direction. */ -void scrollMouseXY(int x, int y) { +void scrollMouseXY(int x, int y, MMScrollUnit unit) { + (void)unit; int ydir = 4; /* Button 4 is up, 5 is down. */ int xdir = 6; Display *display = XGetMainDisplay(); diff --git a/mouse/platform_darwin.go b/mouse/platform_darwin.go new file mode 100644 index 0000000..0ac57f5 --- /dev/null +++ b/mouse/platform_darwin.go @@ -0,0 +1,57 @@ +//go:build darwin +// +build darwin + +package mouse + +/* +#include "mouse.h" +*/ +import "C" + +func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { + switch button { + case MouseButtonLeft: + return C.MMMouseButton(C.MM_BUTTON_LEFT), nil + case MouseButtonRight: + return C.MMMouseButton(C.MM_BUTTON_RIGHT), nil + case MouseButtonMiddle: + return C.MMMouseButton(C.MM_BUTTON_MIDDLE), nil + case MouseButtonBack: + return C.MMMouseButton(C.MM_BUTTON_BACK), nil + case MouseButtonForward: + return C.MMMouseButton(C.MM_BUTTON_FORWARD), nil + } + if idx, ok := button.OtherIndex(); ok { + if idx < 1 { + return 0, ErrMouseInvalidButton + } + return C.MMMouseButton(2 + idx), nil + } + return 0, ErrMouseInvalidButton +} + +func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { + switch delta.Unit { + case ScrollUnitLine: + return C.int(delta.X), C.int(delta.Y), C.MM_SCROLL_UNIT_LINE, nil + case ScrollUnitPixel: + return C.int(delta.X), C.int(delta.Y), C.MM_SCROLL_UNIT_PIXEL, nil + default: + return 0, 0, 0, ErrMouseInvalidScrollUnit + } +} + +func mouseCapabilities() MouseCapabilities { + return MouseCapabilities{ + Buttons: []MouseButton{ + MouseButtonLeft, + MouseButtonRight, + MouseButtonMiddle, + MouseButtonBack, + MouseButtonForward, + }, + MaxOtherButtons: -1, + ScrollUnits: []ScrollUnit{ScrollUnitLine, ScrollUnitPixel}, + PixelScrollEmulated: false, + } +} diff --git a/mouse/platform_linux.go b/mouse/platform_linux.go new file mode 100644 index 0000000..4f8ad38 --- /dev/null +++ b/mouse/platform_linux.go @@ -0,0 +1,75 @@ +//go:build linux +// +build linux + +package mouse + +/* +#include "mouse.h" +*/ +import "C" + +import "sync" + +const scrollPixelsPerLine = 120 + +var scrollAccum struct { + mu sync.Mutex + x int + y int +} + +func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { + switch button { + case MouseButtonLeft: + return C.MMMouseButton(C.MM_BUTTON_LEFT), nil + case MouseButtonRight: + return C.MMMouseButton(C.MM_BUTTON_RIGHT), nil + case MouseButtonMiddle: + return C.MMMouseButton(C.MM_BUTTON_MIDDLE), nil + case MouseButtonBack: + return C.MMMouseButton(C.MM_BUTTON_BACK), nil + case MouseButtonForward: + return C.MMMouseButton(C.MM_BUTTON_FORWARD), nil + } + if idx, ok := button.OtherIndex(); ok { + if idx < 1 { + return 0, ErrMouseInvalidButton + } + return C.MMMouseButton(7 + idx), nil + } + return 0, ErrMouseInvalidButton +} + +func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { + switch delta.Unit { + case ScrollUnitLine: + return C.int(-delta.X), C.int(delta.Y), C.MM_SCROLL_UNIT_LINE, nil + case ScrollUnitPixel: + scrollAccum.mu.Lock() + defer scrollAccum.mu.Unlock() + scrollAccum.x += delta.X + scrollAccum.y += delta.Y + xTicks := scrollAccum.x / scrollPixelsPerLine + yTicks := scrollAccum.y / scrollPixelsPerLine + scrollAccum.x -= xTicks * scrollPixelsPerLine + scrollAccum.y -= yTicks * scrollPixelsPerLine + return C.int(-xTicks), C.int(yTicks), C.MM_SCROLL_UNIT_LINE, nil + default: + return 0, 0, 0, ErrMouseInvalidScrollUnit + } +} + +func mouseCapabilities() MouseCapabilities { + return MouseCapabilities{ + Buttons: []MouseButton{ + MouseButtonLeft, + MouseButtonRight, + MouseButtonMiddle, + MouseButtonBack, + MouseButtonForward, + }, + MaxOtherButtons: -1, + ScrollUnits: []ScrollUnit{ScrollUnitLine, ScrollUnitPixel}, + PixelScrollEmulated: true, + } +} diff --git a/mouse/platform_windows.go b/mouse/platform_windows.go new file mode 100644 index 0000000..18b089d --- /dev/null +++ b/mouse/platform_windows.go @@ -0,0 +1,61 @@ +//go:build windows +// +build windows + +package mouse + +/* +#include "mouse.h" +*/ +import "C" + +func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { + switch button { + case MouseButtonLeft: + return C.MMMouseButton(C.MM_BUTTON_LEFT), nil + case MouseButtonRight: + return C.MMMouseButton(C.MM_BUTTON_RIGHT), nil + case MouseButtonMiddle: + return C.MMMouseButton(C.MM_BUTTON_MIDDLE), nil + case MouseButtonBack: + return C.MMMouseButton(C.MM_BUTTON_BACK), nil + case MouseButtonForward: + return C.MMMouseButton(C.MM_BUTTON_FORWARD), nil + } + if idx, ok := button.OtherIndex(); ok { + switch idx { + case 1: + return C.MMMouseButton(C.MM_BUTTON_BACK), nil + case 2: + return C.MMMouseButton(C.MM_BUTTON_FORWARD), nil + default: + return 0, ErrMouseUnsupportedButton + } + } + return 0, ErrMouseInvalidButton +} + +func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { + switch delta.Unit { + case ScrollUnitLine: + return C.int(delta.X), C.int(delta.Y), C.MM_SCROLL_UNIT_LINE, nil + case ScrollUnitPixel: + return C.int(delta.X), C.int(delta.Y), C.MM_SCROLL_UNIT_PIXEL, nil + default: + return 0, 0, 0, ErrMouseInvalidScrollUnit + } +} + +func mouseCapabilities() MouseCapabilities { + return MouseCapabilities{ + Buttons: []MouseButton{ + MouseButtonLeft, + MouseButtonRight, + MouseButtonMiddle, + MouseButtonBack, + MouseButtonForward, + }, + MaxOtherButtons: 2, + ScrollUnits: []ScrollUnit{ScrollUnitLine, ScrollUnitPixel}, + PixelScrollEmulated: true, + } +} diff --git a/mouse/sleep.go b/mouse/sleep.go new file mode 100644 index 0000000..c769a71 --- /dev/null +++ b/mouse/sleep.go @@ -0,0 +1,13 @@ +package mouse + +import "time" + +// MilliSleep sleep tm milli second. +func MilliSleep(tm int) { + time.Sleep(time.Duration(tm) * time.Millisecond) +} + +// Sleep time.Sleep tm second. +func Sleep(tm int) { + time.Sleep(time.Duration(tm) * time.Second) +} diff --git a/mouse/types.go b/mouse/types.go new file mode 100644 index 0000000..2f8546f --- /dev/null +++ b/mouse/types.go @@ -0,0 +1,133 @@ +package mouse + +import "fmt" + +// MouseButton identifies a logical mouse button. +type MouseButton int + +const ( + MouseButtonLeft MouseButton = iota + 1 + MouseButtonRight + MouseButtonMiddle + MouseButtonBack + MouseButtonForward +) + +// MouseButtonCenter is an alias for MouseButtonMiddle. +const MouseButtonCenter MouseButton = MouseButtonMiddle + +const mouseButtonOtherBase MouseButton = 1000 + +// MouseButtonOther returns an extra mouse button by index (1-based). +func MouseButtonOther(index int) MouseButton { + return MouseButton(mouseButtonOtherBase + MouseButton(index)) +} + +// OtherIndex returns the 1-based index for extra mouse buttons. +func (b MouseButton) OtherIndex() (int, bool) { + if b >= mouseButtonOtherBase { + return int(b - mouseButtonOtherBase), true + } + return 0, false +} + +func (b MouseButton) String() string { + switch b { + case MouseButtonLeft: + return "left" + case MouseButtonRight: + return "right" + case MouseButtonMiddle: + return "middle" + case MouseButtonBack: + return "back" + case MouseButtonForward: + return "forward" + } + if idx, ok := b.OtherIndex(); ok { + return fmt.Sprintf("other(%d)", idx) + } + return fmt.Sprintf("button(%d)", int(b)) +} + +// ScrollUnit defines the scroll measurement unit. +type ScrollUnit int + +const ( + ScrollUnitLine ScrollUnit = iota + ScrollUnitPixel +) + +func (u ScrollUnit) String() string { + switch u { + case ScrollUnitLine: + return "line" + case ScrollUnitPixel: + return "pixel" + default: + return fmt.Sprintf("scrollUnit(%d)", int(u)) + } +} + +// ScrollDelta represents a scroll amount in a specific unit. +// Positive X scrolls right; positive Y scrolls up. +type ScrollDelta struct { + X int + Y int + Unit ScrollUnit +} + +// ScrollDeltaLines builds a line-based scroll delta. +func ScrollDeltaLines(x, y int) ScrollDelta { + return ScrollDelta{X: x, Y: y, Unit: ScrollUnitLine} +} + +// ScrollDeltaPixels builds a pixel-based scroll delta. +func ScrollDeltaPixels(x, y int) ScrollDelta { + return ScrollDelta{X: x, Y: y, Unit: ScrollUnitPixel} +} + +// MouseCapabilities describes supported mouse features for the platform. +type MouseCapabilities struct { + Buttons []MouseButton + MaxOtherButtons int + ScrollUnits []ScrollUnit + PixelScrollEmulated bool +} + +// CurrentMouseCapabilities returns the current platform's mouse capabilities. +func CurrentMouseCapabilities() MouseCapabilities { + return mouseCapabilities() +} + +// SupportsButton reports whether the platform supports the provided button. +func (c MouseCapabilities) SupportsButton(btn MouseButton) bool { + if btn == 0 { + return false + } + if idx, ok := btn.OtherIndex(); ok { + if idx < 1 { + return false + } + if c.MaxOtherButtons < 0 { + return true + } + return idx <= c.MaxOtherButtons + } + for _, b := range c.Buttons { + if b == btn { + return true + } + } + return false +} + +// SupportsScrollUnit reports whether the platform supports the scroll unit. +func (c MouseCapabilities) SupportsScrollUnit(unit ScrollUnit) bool { + for _, u := range c.ScrollUnits { + if u == unit { + return true + } + } + return false +} diff --git a/mouse_exports.go b/mouse_exports.go new file mode 100644 index 0000000..2932823 --- /dev/null +++ b/mouse_exports.go @@ -0,0 +1,127 @@ +package deskact + +import m "github.com/PekingSpades/DeskAct/mouse" + +type MouseSettings = m.MouseSettings +type MouseButton = m.MouseButton +type ScrollUnit = m.ScrollUnit +type ScrollDelta = m.ScrollDelta +type MouseCapabilities = m.MouseCapabilities +type MouseOp = m.MouseOp +type MouseError = m.MouseError + +var ( + ErrMouseInvalidButton = m.ErrMouseInvalidButton + ErrMouseUnsupportedButton = m.ErrMouseUnsupportedButton + ErrMouseInvalidScrollUnit = m.ErrMouseInvalidScrollUnit + ErrMouseUnsupportedScrollUnit = m.ErrMouseUnsupportedScrollUnit + ErrMouseActionFailed = m.ErrMouseActionFailed +) + +const ( + DefaultMouseSleep = m.DefaultMouseSleep + DefaultMoveSmoothLow = m.DefaultMoveSmoothLow + DefaultMoveSmoothHigh = m.DefaultMoveSmoothHigh + DefaultMoveSmoothDelay = m.DefaultMoveSmoothDelay + DefaultScrollDelay = m.DefaultScrollDelay + DefaultScrollSmoothCount = m.DefaultScrollSmoothCount + DefaultScrollSmoothInterval = m.DefaultScrollSmoothInterval + + MouseButtonLeft = m.MouseButtonLeft + MouseButtonRight = m.MouseButtonRight + MouseButtonMiddle = m.MouseButtonMiddle + MouseButtonBack = m.MouseButtonBack + MouseButtonForward = m.MouseButtonForward + MouseButtonCenter = m.MouseButtonCenter + + ScrollUnitLine = m.ScrollUnitLine + ScrollUnitPixel = m.ScrollUnitPixel +) + +func DefaultMouseSettings() MouseSettings { + return m.DefaultMouseSettings() +} + +func MouseButtonOther(index int) MouseButton { + return m.MouseButtonOther(index) +} + +func CurrentMouseCapabilities() MouseCapabilities { + return m.CurrentMouseCapabilities() +} + +func ScrollDeltaLines(x, y int) ScrollDelta { + return m.ScrollDeltaLines(x, y) +} + +func ScrollDeltaPixels(x, y int) ScrollDelta { + return m.ScrollDeltaPixels(x, y) +} + +func Move(x, y int, settings MouseSettings) error { + return m.Move(x, y, settings) +} + +func Drag(x, y int, button MouseButton, settings MouseSettings) error { + return m.Drag(x, y, button, settings) +} + +func DragSmooth(x, y int, button MouseButton, settings MouseSettings) error { + return m.DragSmooth(x, y, button, settings) +} + +func MoveSmooth(x, y int, settings MouseSettings) error { + return m.MoveSmooth(x, y, settings) +} + +func MoveArgs(x, y int) (int, int) { + return m.MoveArgs(x, y) +} + +func MoveRelative(x, y int, settings MouseSettings) error { + return m.MoveRelative(x, y, settings) +} + +func MoveSmoothRelative(x, y int, settings MouseSettings) error { + return m.MoveSmoothRelative(x, y, settings) +} + +func Location() (int, int) { + return m.Location() +} + +func Click(button MouseButton, double bool, settings MouseSettings) error { + return m.Click(button, double, settings) +} + +func MultiClick(button MouseButton, clickCount int, settings MouseSettings) error { + return m.MultiClick(button, clickCount, settings) +} + +func MoveClick(x, y int, button MouseButton, double bool, settings MouseSettings) error { + return m.MoveClick(x, y, button, double, settings) +} + +func MovesClick(x, y int, button MouseButton, double bool, settings MouseSettings) error { + return m.MovesClick(x, y, button, double, settings) +} + +func Toggle(button MouseButton, down bool, sleepAfter bool, settings MouseSettings) error { + return m.Toggle(button, down, sleepAfter, settings) +} + +func Scroll(delta ScrollDelta, settings MouseSettings) error { + return m.Scroll(delta, settings) +} + +func ScrollLines(x, y int, settings MouseSettings) error { + return m.ScrollLines(x, y, settings) +} + +func ScrollPixels(x, y int, settings MouseSettings) error { + return m.ScrollPixels(x, y, settings) +} + +func ScrollSmooth(delta ScrollDelta, settings MouseSettings) error { + return m.ScrollSmooth(delta, settings) +} diff --git a/internal/screenshot/darwin.go b/screenshot/darwin.go similarity index 100% rename from internal/screenshot/darwin.go rename to screenshot/darwin.go diff --git a/internal/screenshot/nix_dbus_available.go b/screenshot/nix_dbus_available.go similarity index 100% rename from internal/screenshot/nix_dbus_available.go rename to screenshot/nix_dbus_available.go diff --git a/internal/screenshot/nix_wayland.go b/screenshot/nix_wayland.go similarity index 100% rename from internal/screenshot/nix_wayland.go rename to screenshot/nix_wayland.go diff --git a/internal/screenshot/nix_xwindow.go b/screenshot/nix_xwindow.go similarity index 100% rename from internal/screenshot/nix_xwindow.go rename to screenshot/nix_xwindow.go diff --git a/internal/screenshot/screenshot.go b/screenshot/screenshot.go similarity index 100% rename from internal/screenshot/screenshot.go rename to screenshot/screenshot.go diff --git a/internal/screenshot/unsupported.go b/screenshot/unsupported.go similarity index 100% rename from internal/screenshot/unsupported.go rename to screenshot/unsupported.go diff --git a/internal/screenshot/windows.go b/screenshot/windows.go similarity index 100% rename from internal/screenshot/windows.go rename to screenshot/windows.go From 7bf9b387c9e9d1371cfab8e6bd1466640c6c1540 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:16:49 +0800 Subject: [PATCH 03/37] Add non-Windows DPI awareness stubs Provide !windows no-op IsDPIAware/InitDPIAwareness so examples compile off Windows. --- display_exports_other.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 display_exports_other.go diff --git a/display_exports_other.go b/display_exports_other.go new file mode 100644 index 0000000..14d8fc5 --- /dev/null +++ b/display_exports_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package deskact + +// IsDPIAware reports whether the process is DPI aware. +// DPI awareness is a Windows-specific concept, so this returns false on other platforms. +func IsDPIAware() bool { + return false +} + +// InitDPIAwareness is a no-op on non-Windows platforms and returns false. +func InitDPIAwareness() bool { + return false +} From 8a0ce1fe59e464d777dbb5cf1a3f6e1688670902 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:14:28 +0800 Subject: [PATCH 04/37] feat(examples): add mouse demo and DPI init prompt - prompt for DPI init and log the choice in capture/display examples - add interactive mouse example with scroll/move/click actions and ms delay - build and upload mouse example artifacts in CI --- .github/workflows/build-examples.yml | 2 + examples/capture/main.go | 24 ++- examples/display/main.go | 24 ++- examples/mouse/main.go | 269 +++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 examples/mouse/main.go diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index c0fbbc8..bc533ed 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -64,6 +64,7 @@ jobs: fi go build -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture go build -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display + go build -v -o "mouse-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/mouse - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -72,4 +73,5 @@ jobs: path: | capture-${{ matrix.goos }}-${{ matrix.goarch }}* display-${{ matrix.goos }}-${{ matrix.goarch }}* + mouse-${{ matrix.goos }}-${{ matrix.goarch }}* retention-days: 30 diff --git a/examples/capture/main.go b/examples/capture/main.go index 5f88254..ce0e255 100644 --- a/examples/capture/main.go +++ b/examples/capture/main.go @@ -24,7 +24,9 @@ func main() { fmt.Printf("Go version: %s\n", runtime.Version()) fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Println("========================================") - // deskact.InitDPIAwareness() + dpiSelected, dpiResult := promptDPIInit() + fmt.Printf("DPI init selected: %v\n", dpiSelected) + fmt.Printf("DPI init result: %s\n", dpiResult) displayOptions := deskact.DefaultDisplayOptions() captureOptions := deskact.DefaultCaptureOptions() @@ -140,6 +142,8 @@ func main() { logBuilder.WriteString("DeskAct Capture Example\n") logBuilder.WriteString(fmt.Sprintf("Go version: %s\n", runtime.Version())) logBuilder.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) + logBuilder.WriteString(fmt.Sprintf("DPI init selected: %v\n", dpiSelected)) + logBuilder.WriteString(fmt.Sprintf("DPI init result: %s\n", dpiResult)) logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) for _, d := range displays { info := d.Info() @@ -167,6 +171,24 @@ func main() { } } +func promptDPIInit() (bool, string) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("\nInitialize DPI awareness? (y/n): ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "y", "yes": + result := deskact.InitDPIAwareness() + return true, fmt.Sprintf("%v", result) + case "n", "no": + return false, "skipped" + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} + func savePNG(img image.Image, path string) error { file, err := os.Create(path) if err != nil { diff --git a/examples/display/main.go b/examples/display/main.go index 1893f0c..f7b2ce0 100644 --- a/examples/display/main.go +++ b/examples/display/main.go @@ -17,7 +17,9 @@ func main() { fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Println("========================================") - deskact.InitDPIAwareness() + dpiSelected, dpiResult := promptDPIInit() + fmt.Printf("DPI init selected: %v\n", dpiSelected) + fmt.Printf("DPI init result: %s\n", dpiResult) displayOptions := deskact.DefaultDisplayOptions() mouseSettings := deskact.DefaultMouseSettings() @@ -96,6 +98,8 @@ func main() { logBuilder.WriteString("DeskAct Display Example\n") logBuilder.WriteString(fmt.Sprintf("Go version: %s\n", runtime.Version())) logBuilder.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) + logBuilder.WriteString(fmt.Sprintf("DPI init selected: %v\n", dpiSelected)) + logBuilder.WriteString(fmt.Sprintf("DPI init result: %s\n", dpiResult)) logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) for _, d := range displays { info := d.Info() @@ -121,3 +125,21 @@ func main() { fmt.Println("Press 's' to save log and exit, or 'e' to exit directly:") } } + +func promptDPIInit() (bool, string) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("\nInitialize DPI awareness? (y/n): ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "y", "yes": + result := deskact.InitDPIAwareness() + return true, fmt.Sprintf("%v", result) + case "n", "no": + return false, "skipped" + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} diff --git a/examples/mouse/main.go b/examples/mouse/main.go new file mode 100644 index 0000000..ad7ed15 --- /dev/null +++ b/examples/mouse/main.go @@ -0,0 +1,269 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strconv" + "strings" + + deskact "github.com/PekingSpades/DeskAct" +) + +const defaultScrollAmount = 3 + +var commandCleaner = strings.NewReplacer(" ", "", "-", "", "_", "") + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Mouse Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + fmt.Println("Note: all delays are in milliseconds (ms).") + + settings := deskact.DefaultMouseSettings() + reader := bufio.NewReader(os.Stdin) + + for { + printMenu() + fmt.Print("Select command: ") + input := readLine(reader) + cmd := canonicalize(input) + if cmd == "" { + continue + } + if cmd == "q" || cmd == "quit" || cmd == "exit" { + break + } + + action, ok := normalizeCommand(cmd) + if !ok { + fmt.Println("Unknown command.") + continue + } + + delay := readNonNegativeIntDefault(reader, "Delay before action (milliseconds, default 0): ", 0) + + switch action { + case "scroll": + runScroll(reader, delay, settings) + case "location": + runLocation(delay) + case "move": + runMove(reader, delay, settings) + case "left_click": + runClick(delay, settings, deskact.MouseButtonLeft, 1) + case "left_double": + runClick(delay, settings, deskact.MouseButtonLeft, 2) + case "left_triple": + runClick(delay, settings, deskact.MouseButtonLeft, 3) + case "right_click": + runClick(delay, settings, deskact.MouseButtonRight, 1) + case "middle_click": + runClick(delay, settings, deskact.MouseButtonMiddle, 1) + case "forward_click": + runClick(delay, settings, deskact.MouseButtonForward, 1) + case "back_click": + runClick(delay, settings, deskact.MouseButtonBack, 1) + default: + fmt.Println("Unknown command.") + } + } + + fmt.Println("Done.") +} + +func printMenu() { + fmt.Println("\nCommands:") + fmt.Println(" 1) scroll") + fmt.Println(" 2) location") + fmt.Println(" 3) move") + fmt.Println(" 4) left click") + fmt.Println(" 5) left double click") + fmt.Println(" 6) left triple click") + fmt.Println(" 7) right click") + fmt.Println(" 8) middle click") + fmt.Println(" 9) forward click") + fmt.Println(" 10) back click") + fmt.Println(" q) exit") +} + +func canonicalize(input string) string { + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return "" + } + return commandCleaner.Replace(input) +} + +func normalizeCommand(cmd string) (string, bool) { + switch cmd { + case "1", "scroll": + return "scroll", true + case "2", "location", "loc", "pos", "position": + return "location", true + case "3", "move": + return "move", true + case "4", "left", "leftclick", "left1": + return "left_click", true + case "5", "left2", "leftdouble", "leftdoubleclick", "double": + return "left_double", true + case "6", "left3", "lefttriple", "lefttripleclick", "triple": + return "left_triple", true + case "7", "right", "rightclick", "right1": + return "right_click", true + case "8", "middle", "middleclick", "center", "centre": + return "middle_click", true + case "9", "forward", "forwardclick", "fwd": + return "forward_click", true + case "10", "back", "backclick", "backward", "bwd": + return "back_click", true + default: + return "", false + } +} + +func readLine(reader *bufio.Reader) string { + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +func readStringDefault(reader *bufio.Reader, prompt, defaultVal string) string { + for { + fmt.Print(prompt) + line := readLine(reader) + if line == "" { + return defaultVal + } + return line + } +} + +func readNonNegativeIntDefault(reader *bufio.Reader, prompt string, defaultVal int) int { + for { + fmt.Print(prompt) + line := readLine(reader) + if line == "" { + return defaultVal + } + value, err := strconv.Atoi(line) + if err != nil { + fmt.Println("Please enter a valid integer.") + continue + } + if value < 0 { + fmt.Println("Please enter a non-negative integer.") + continue + } + return value + } +} + +func readInt(reader *bufio.Reader, prompt string) int { + for { + fmt.Print(prompt) + line := readLine(reader) + if line == "" { + fmt.Println("Please enter a value.") + continue + } + value, err := strconv.Atoi(line) + if err != nil { + fmt.Println("Please enter a valid integer.") + continue + } + return value + } +} + +func waitDelay(delay int) { + if delay <= 0 { + return + } + fmt.Printf("Waiting %d ms...\n", delay) + deskact.MilliSleep(delay) +} + +func readScrollUnit(reader *bufio.Reader) (deskact.ScrollUnit, string) { + for { + unit := strings.ToLower(readStringDefault(reader, "Scroll unit (line/pixel, default line): ", "line")) + switch unit { + case "line", "lines", "l": + return deskact.ScrollUnitLine, "line" + case "pixel", "pixels", "p": + return deskact.ScrollUnitPixel, "pixel" + default: + fmt.Println("Please enter line or pixel.") + } + } +} + +func readScrollDirection(reader *bufio.Reader) (string, int, int) { + for { + direction := strings.ToLower(readStringDefault(reader, "Direction (up/down/left/right, default down): ", "down")) + switch direction { + case "up", "u": + return "up", 0, 1 + case "down", "d": + return "down", 0, -1 + case "left", "l": + return "left", -1, 0 + case "right", "r": + return "right", 1, 0 + default: + fmt.Println("Please enter up, down, left, or right.") + } + } +} + +func runScroll(reader *bufio.Reader, delay int, settings deskact.MouseSettings) { + unit, unitLabel := readScrollUnit(reader) + direction, dx, dy := readScrollDirection(reader) + amount := readNonNegativeIntDefault(reader, "Amount per unit (default 3): ", defaultScrollAmount) + if amount == 0 { + fmt.Println("Scroll amount is 0; skipping.") + return + } + + dx *= amount + dy *= amount + + waitDelay(delay) + err := deskact.Scroll(deskact.ScrollDelta{X: dx, Y: dy, Unit: unit}, settings) + if err != nil { + fmt.Printf("Scroll error: %v\n", err) + return + } + fmt.Printf("Scrolled %s by %d %s(s).\n", direction, amount, unitLabel) +} + +func runLocation(delay int) { + waitDelay(delay) + x, y := deskact.Location() + fmt.Printf("Mouse location: (%d, %d)\n", x, y) +} + +func runMove(reader *bufio.Reader, delay int, settings deskact.MouseSettings) { + x := readInt(reader, "X: ") + y := readInt(reader, "Y: ") + waitDelay(delay) + if err := deskact.Move(x, y, settings); err != nil { + fmt.Printf("Move error: %v\n", err) + return + } + fmt.Printf("Moved to (%d, %d)\n", x, y) +} + +func runClick(delay int, settings deskact.MouseSettings, button deskact.MouseButton, count int) { + if count < 1 { + count = 1 + } + waitDelay(delay) + if err := deskact.MultiClick(button, count, settings); err != nil { + fmt.Printf("Click error: %v\n", err) + return + } + fmt.Printf("Clicked %s x%d\n", button, count) +} From 0a211362450d87f54f6384029245bade3464f825 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:55:16 +0800 Subject: [PATCH 05/37] feat(examples): enhance mouse demo with display-aware controls - prompt for DPI awareness init - add display selection, drag action, and per-display move - show display info plus ASCII layout at location - add helpers for input ranges and coordinate mapping --- examples/mouse/main.go | 406 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 400 insertions(+), 6 deletions(-) diff --git a/examples/mouse/main.go b/examples/mouse/main.go index ad7ed15..6398a44 100644 --- a/examples/mouse/main.go +++ b/examples/mouse/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "fmt" + "math" "os" "runtime" "strconv" @@ -11,7 +12,11 @@ import ( deskact "github.com/PekingSpades/DeskAct" ) -const defaultScrollAmount = 3 +const ( + defaultScrollAmount = 3 + maxDisplayASCIIWidth = 30 + asciiHeightScale = 0.5 +) var commandCleaner = strings.NewReplacer(" ", "", "-", "", "_", "") @@ -23,8 +28,12 @@ func main() { fmt.Println("========================================") fmt.Println("Note: all delays are in milliseconds (ms).") - settings := deskact.DefaultMouseSettings() reader := bufio.NewReader(os.Stdin) + dpiSelected, dpiResult := promptDPIInit(reader) + fmt.Printf("DPI init selected: %v\n", dpiSelected) + fmt.Printf("DPI init result: %s\n", dpiResult) + + settings := deskact.DefaultMouseSettings() for { printMenu() @@ -59,6 +68,8 @@ func main() { runClick(delay, settings, deskact.MouseButtonLeft, 2) case "left_triple": runClick(delay, settings, deskact.MouseButtonLeft, 3) + case "left_drag": + runDrag(reader, delay, settings, deskact.MouseButtonLeft) case "right_click": runClick(delay, settings, deskact.MouseButtonRight, 1) case "middle_click": @@ -87,6 +98,7 @@ func printMenu() { fmt.Println(" 8) middle click") fmt.Println(" 9) forward click") fmt.Println(" 10) back click") + fmt.Println(" 11) left click drag") fmt.Println(" q) exit") } @@ -120,6 +132,8 @@ func normalizeCommand(cmd string) (string, bool) { return "forward_click", true case "10", "back", "backclick", "backward", "bwd": return "back_click", true + case "11", "drag", "leftdrag", "leftclickdrag": + return "left_drag", true default: return "", false } @@ -130,6 +144,22 @@ func readLine(reader *bufio.Reader) string { return strings.TrimSpace(line) } +func promptDPIInit(reader *bufio.Reader) (bool, string) { + for { + fmt.Print("\nInitialize DPI awareness? (y/n): ") + input := strings.TrimSpace(strings.ToLower(readLine(reader))) + switch input { + case "y", "yes": + result := deskact.InitDPIAwareness() + return true, fmt.Sprintf("%v", result) + case "n", "no": + return false, "skipped" + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} + func readStringDefault(reader *bufio.Reader, prompt, defaultVal string) string { for { fmt.Print(prompt) @@ -178,6 +208,17 @@ func readInt(reader *bufio.Reader, prompt string) int { } } +func readIntInRange(reader *bufio.Reader, prompt string, minValue, maxValue int) int { + for { + value := readInt(reader, prompt) + if value < minValue || value > maxValue { + fmt.Printf("Please enter a value between %d and %d.\n", minValue, maxValue) + continue + } + return value + } +} + func waitDelay(delay int) { if delay <= 0 { return @@ -243,17 +284,141 @@ func runLocation(delay int) { waitDelay(delay) x, y := deskact.Location() fmt.Printf("Mouse location: (%d, %d)\n", x, y) + + displayOptions := deskact.DefaultDisplayOptions() + displays := deskact.AllDisplays(displayOptions) + if len(displays) == 0 { + fmt.Println("No displays detected.") + return + } + + layoutX, layoutY := x, y + var hitDisplay *deskact.Display + var hitInfo deskact.DisplayInfo + var relLogicalX, relLogicalY int + var percentX, percentY float64 + + for _, d := range displays { + if !d.Contains(x, y) { + continue + } + hitDisplay = d + hitInfo = d.Info() + relPhysX, relPhysY, ok := d.ToRelative(x, y) + if !ok { + break + } + relLogicalX, relLogicalY, percentX, percentY = mapRelativeToOrigin( + relPhysX, relPhysY, + hitInfo.Size.W, hitInfo.Size.H, + hitInfo.Origin.W, hitInfo.Origin.H, + ) + layoutX = hitInfo.Origin.X + relLogicalX + layoutY = hitInfo.Origin.Y + relLogicalY + break + } + + if hitDisplay != nil { + fmt.Printf("On display #%d (main=%v) at (%d, %d) of %dx%d (%.2f%%, %.2f%%)\n", + hitInfo.Index, hitInfo.IsMain, relLogicalX, relLogicalY, + hitInfo.Origin.W, hitInfo.Origin.H, + percentX*100, percentY*100) + } else { + fmt.Println("Mouse is outside all display bounds.") + } + + diagram := buildDisplayDiagram(displays, layoutX, layoutY, hitDisplay != nil) + if diagram != "" { + fmt.Println("Display layout (max display width scaled to 30 chars):") + fmt.Println(diagram) + fmt.Println("Legend: @ = mouse, * = main display") + } +} + +func formatDisplayRange(info deskact.DisplayInfo) string { + if info.Size.W <= 0 || info.Size.H <= 0 { + return fmt.Sprintf("size=%dx%d range: unavailable", info.Size.W, info.Size.H) + } + return fmt.Sprintf("size=%dx%d range: x=0..%d, y=0..%d", + info.Size.W, info.Size.H, info.Size.W-1, info.Size.H-1) +} + +func selectDisplay(reader *bufio.Reader) (*deskact.Display, deskact.DisplayInfo) { + displayOptions := deskact.DefaultDisplayOptions() + displays := deskact.AllDisplays(displayOptions) + if len(displays) == 0 { + fmt.Println("No displays detected.") + return nil, deskact.DisplayInfo{} + } + + displayMap := make(map[int]*deskact.Display, len(displays)) + fmt.Println("Available displays (physical pixel ranges):") + for _, d := range displays { + info := d.Info() + displayMap[info.Index] = d + fmt.Printf(" #%d (main=%v) %s\n", info.Index, info.IsMain, formatDisplayRange(info)) + } + + for { + index := readInt(reader, "Select display index: ") + d, ok := displayMap[index] + if !ok { + fmt.Println("Invalid display index.") + continue + } + return d, d.Info() + } +} + +func promptPhysicalPoint(reader *bufio.Reader, info deskact.DisplayInfo, label string) (int, int, bool) { + if info.Size.W <= 0 || info.Size.H <= 0 { + fmt.Println("Selected display has invalid physical size.") + return 0, 0, false + } + maxX := info.Size.W - 1 + maxY := info.Size.H - 1 + x := readIntInRange(reader, fmt.Sprintf("%s X (0..%d): ", label, maxX), 0, maxX) + y := readIntInRange(reader, fmt.Sprintf("%s Y (0..%d): ", label, maxY), 0, maxY) + return x, y, true } func runMove(reader *bufio.Reader, delay int, settings deskact.MouseSettings) { - x := readInt(reader, "X: ") - y := readInt(reader, "Y: ") + display, info := selectDisplay(reader) + if display == nil { + return + } + x, y, ok := promptPhysicalPoint(reader, info, "Target") + if !ok { + return + } waitDelay(delay) - if err := deskact.Move(x, y, settings); err != nil { + if err := display.Move(x, y, settings); err != nil { fmt.Printf("Move error: %v\n", err) return } - fmt.Printf("Moved to (%d, %d)\n", x, y) + fmt.Printf("Moved to display #%d at (%d, %d)\n", info.Index, x, y) +} + +func runDrag(reader *bufio.Reader, delay int, settings deskact.MouseSettings, button deskact.MouseButton) { + display, info := selectDisplay(reader) + if display == nil { + return + } + startX, startY, ok := promptPhysicalPoint(reader, info, "Start") + if !ok { + return + } + endX, endY, ok := promptPhysicalPoint(reader, info, "End") + if !ok { + return + } + waitDelay(delay) + if err := display.Drag(startX, startY, endX, endY, button, settings); err != nil { + fmt.Printf("Drag error: %v\n", err) + return + } + fmt.Printf("Dragged %s on display #%d from (%d, %d) to (%d, %d)\n", + button, info.Index, startX, startY, endX, endY) } func runClick(delay int, settings deskact.MouseSettings, button deskact.MouseButton, count int) { @@ -267,3 +432,232 @@ func runClick(delay int, settings deskact.MouseSettings, button deskact.MouseBut } fmt.Printf("Clicked %s x%d\n", button, count) } + +func mapRelativeToOrigin(relPhysX, relPhysY, physW, physH, originW, originH int) (relLogicalX, relLogicalY int, percentX, percentY float64) { + percentX = safeRatio(relPhysX, physW) + percentY = safeRatio(relPhysY, physH) + relLogicalX = int(math.Round(percentX * float64(originW))) + relLogicalY = int(math.Round(percentY * float64(originH))) + if originW > 0 { + relLogicalX = clampInt(relLogicalX, 0, originW-1) + } + if originH > 0 { + relLogicalY = clampInt(relLogicalY, 0, originH-1) + } + return relLogicalX, relLogicalY, percentX, percentY +} + +func buildDisplayDiagram(displays []*deskact.Display, mouseX, mouseY int, showMouse bool) string { + if len(displays) == 0 { + return "" + } + + infos := make([]deskact.DisplayInfo, 0, len(displays)) + maxDisplayWidth := 0 + minX, minY := 0, 0 + + for i, d := range displays { + info := d.Info() + infos = append(infos, info) + if i == 0 { + minX = info.Origin.X + minY = info.Origin.Y + } else { + if info.Origin.X < minX { + minX = info.Origin.X + } + if info.Origin.Y < minY { + minY = info.Origin.Y + } + } + if info.Origin.W > maxDisplayWidth { + maxDisplayWidth = info.Origin.W + } + } + + if maxDisplayWidth <= 0 { + return "" + } + + scaleX := 1.0 + if maxDisplayWidth > maxDisplayASCIIWidth { + scaleX = float64(maxDisplayASCIIWidth) / float64(maxDisplayWidth) + } + scaleY := scaleX * asciiHeightScale + + type scaledDisplay struct { + info deskact.DisplayInfo + x0, y0 int + width int + height int + } + + scaledDisplays := make([]scaledDisplay, 0, len(infos)) + maxGridX, maxGridY := 0, 0 + + for _, info := range infos { + if info.Origin.W <= 0 || info.Origin.H <= 0 { + continue + } + w := int(math.Round(float64(info.Origin.W) * scaleX)) + h := int(math.Round(float64(info.Origin.H) * scaleY)) + if w < 2 { + w = 2 + } + if h < 2 { + h = 2 + } + x0 := int(math.Round(float64(info.Origin.X-minX) * scaleX)) + y0 := int(math.Round(float64(info.Origin.Y-minY) * scaleY)) + x1 := x0 + w - 1 + y1 := y0 + h - 1 + if x1 > maxGridX { + maxGridX = x1 + } + if y1 > maxGridY { + maxGridY = y1 + } + scaledDisplays = append(scaledDisplays, scaledDisplay{ + info: info, + x0: x0, + y0: y0, + width: w, + height: h, + }) + } + + if len(scaledDisplays) == 0 { + return "" + } + + gridWidth := maxGridX + 1 + gridHeight := maxGridY + 1 + grid := make([][]byte, gridHeight) + for y := range grid { + row := make([]byte, gridWidth) + for x := range row { + row[x] = ' ' + } + grid[y] = row + } + + for _, d := range scaledDisplays { + label := displayLabel(d.info) + drawDisplayRect(grid, d.x0, d.y0, d.width, d.height, label) + } + + if showMouse { + mouseGX := int(math.Round(float64(mouseX-minX) * scaleX)) + mouseGY := int(math.Round(float64(mouseY-minY) * scaleY)) + mouseGX = clampInt(mouseGX, 0, gridWidth-1) + mouseGY = clampInt(mouseGY, 0, gridHeight-1) + setGridCell(grid, mouseGX, mouseGY, '@') + } + + var builder strings.Builder + for i, row := range grid { + line := strings.TrimRight(string(row), " ") + builder.WriteString(line) + if i < len(grid)-1 { + builder.WriteByte('\n') + } + } + return builder.String() +} + +func displayLabel(info deskact.DisplayInfo) string { + if info.IsMain { + return fmt.Sprintf("%d*", info.Index) + } + return fmt.Sprintf("%d", info.Index) +} + +func drawDisplayRect(grid [][]byte, x0, y0, w, h int, label string) { + if w < 2 || h < 2 { + return + } + x1 := x0 + w - 1 + y1 := y0 + h - 1 + + for y := y0 + 1; y < y1; y++ { + for x := x0 + 1; x < x1; x++ { + if getGridCell(grid, x, y) == ' ' { + setGridCell(grid, x, y, '.') + } + } + } + + for x := x0; x <= x1; x++ { + setGridCell(grid, x, y0, '-') + setGridCell(grid, x, y1, '-') + } + for y := y0; y <= y1; y++ { + setGridCell(grid, x0, y, '|') + setGridCell(grid, x1, y, '|') + } + setGridCell(grid, x0, y0, '+') + setGridCell(grid, x1, y0, '+') + setGridCell(grid, x0, y1, '+') + setGridCell(grid, x1, y1, '+') + + if label == "" { + return + } + labelX := x0 + 1 + labelY := y0 + 1 + if labelX > x1-1 || labelY > y1-1 { + return + } + if labelX+len(label)-1 > x1-1 { + return + } + for i := 0; i < len(label); i++ { + setGridCell(grid, labelX+i, labelY, label[i]) + } +} + +func setGridCell(grid [][]byte, x, y int, ch byte) { + if y < 0 || y >= len(grid) { + return + } + row := grid[y] + if x < 0 || x >= len(row) { + return + } + row[x] = ch +} + +func getGridCell(grid [][]byte, x, y int) byte { + if y < 0 || y >= len(grid) { + return ' ' + } + row := grid[y] + if x < 0 || x >= len(row) { + return ' ' + } + return row[x] +} + +func clampInt(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func safeRatio(value, size int) float64 { + if size <= 0 { + return 0 + } + ratio := float64(value) / float64(size) + if ratio < 0 { + return 0 + } + if ratio > 1 { + return 1 + } + return ratio +} From 0a2b4046490b561fc08393f378e8ad97b5bcc171 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:53:39 +0800 Subject: [PATCH 06/37] fix(macos): implement real multi-click event sequence - post per-click down/up events with incremented click state - add small delays between clicks for double/triple detection - handle null event source early --- mouse/mouse_c_macos.h | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index b06abc1..7fedc29 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -125,20 +125,39 @@ int multiClickErr(MMMouseButton button, int clickCount){ const CGEventType mouseTypeUP = MMMouseToCGEventType(false, button); CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); - CGEventRef event = CGEventCreateMouseEvent(source, mouseTypeDown, currentPos, (CGMouseButton)button); - - if (event == NULL) { - CFRelease(source); + if (source == NULL) { return (int)kCGErrorCannotComplete; } - CGEventSetIntegerValueField(event, kCGMouseEventClickState, clickCount); - CGEventPost(kCGHIDEventTap, event); - - CGEventSetType(event, mouseTypeUP); - CGEventPost(kCGHIDEventTap, event); + int i; + for (i = 0; i < clickCount; i++) { + CGEventRef down = CGEventCreateMouseEvent(source, mouseTypeDown, currentPos, (CGMouseButton)button); + if (down == NULL) { + CFRelease(source); + return (int)kCGErrorCannotComplete; + } + + CGEventSetIntegerValueField(down, kCGMouseEventClickState, i + 1); + CGEventPost(kCGHIDEventTap, down); + CFRelease(down); + + microsleep(5.0); + + CGEventRef up = CGEventCreateMouseEvent(source, mouseTypeUP, currentPos, (CGMouseButton)button); + if (up == NULL) { + CFRelease(source); + return (int)kCGErrorCannotComplete; + } + + CGEventSetIntegerValueField(up, kCGMouseEventClickState, i + 1); + CGEventPost(kCGHIDEventTap, up); + CFRelease(up); + + if (i < clickCount - 1) { + microsleep(200); + } + } - CFRelease(event); CFRelease(source); return 0; From a4e82d7c1a6b73e9a70072a0b9466dc3234a7a00 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:18:14 +0800 Subject: [PATCH 07/37] fix(mouse): use dragged events on macOS - add smooth drag path for darwin - route drag operations through platform hooks - remove unused dragMouse stubs on windows/x11 --- mouse/mouse.go | 4 ++-- mouse/mouse_c_macos.h | 28 ++++++++++++++++++++++++++++ mouse/mouse_c_windows.h | 4 ---- mouse/mouse_c_x11.h | 4 ---- mouse/platform_darwin.go | 33 +++++++++++++++++++++++++++++++++ mouse/platform_linux.go | 14 ++++++++++++++ mouse/platform_windows.go | 14 ++++++++++++++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/mouse/mouse.go b/mouse/mouse.go index d82e1ed..71df0a8 100644 --- a/mouse/mouse.go +++ b/mouse/mouse.go @@ -21,7 +21,7 @@ func Drag(x, y int, button MouseButton, settings MouseSettings) error { return err } MilliSleep(50) - if err := Move(x, y, settings); err != nil { + if err := dragTo(x, y, button, settings); err != nil { _ = Toggle(button, false, false, settings) return err } @@ -34,7 +34,7 @@ func DragSmooth(x, y int, button MouseButton, settings MouseSettings) error { return err } MilliSleep(50) - if err := MoveSmooth(x, y, settings); err != nil { + if err := dragSmoothTo(x, y, button, settings); err != nil { _ = Toggle(button, false, false, settings) return err } diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index 7fedc29..d36a356 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -226,3 +226,31 @@ bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed) return true; } + +bool smoothlyDragMouse(MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed){ + MMPointInt32 pos = location(); + double velo_x = 0.0, velo_y = 0.0; + double distance; + + while ((distance =crude_hypot((double)pos.x - endPoint.x, (double)pos.y - endPoint.y)) > 1.0) { + double gravity = DEADBEEF_UNIFORM(5.0, 500.0); + double veloDistance; + velo_x += (gravity * ((double)endPoint.x - pos.x)) / distance; + velo_y += (gravity * ((double)endPoint.y - pos.y)) / distance; + + /* Normalize velocity to get a unit vector of length 1. */ + veloDistance = crude_hypot(velo_x, velo_y); + velo_x /= veloDistance; + velo_y /= veloDistance; + + pos.x += floor(velo_x + 0.5); + pos.y += floor(velo_y + 0.5); + + dragMouse(pos, button); + + /* Wait 1 - 3 milliseconds. */ + microsleep(DEADBEEF_UNIFORM(lowSpeed, highSpeed)); + } + + return true; +} diff --git a/mouse/mouse_c_windows.h b/mouse/mouse_c_windows.h index f55221f..2e4e5b6 100644 --- a/mouse/mouse_c_windows.h +++ b/mouse/mouse_c_windows.h @@ -46,10 +46,6 @@ void moveMouse(MMPointInt32 point){ SetPhysicalCursorPos(point.x, point.y); } -void dragMouse(MMPointInt32 point, const MMMouseButton button){ - moveMouse(point); -} - MMPointInt32 location() { POINT point; GetPhysicalCursorPos(&point); diff --git a/mouse/mouse_c_x11.h b/mouse/mouse_c_x11.h index 7c8cd87..183dc88 100644 --- a/mouse/mouse_c_x11.h +++ b/mouse/mouse_c_x11.h @@ -26,10 +26,6 @@ void moveMouse(MMPointInt32 point){ XSync(display, false); } -void dragMouse(MMPointInt32 point, const MMMouseButton button){ - moveMouse(point); -} - MMPointInt32 location() { int x, y; /* This is all we care about. Seriously. */ Window garb1, garb2; /* Why you can't specify NULL as a parameter */ diff --git a/mouse/platform_darwin.go b/mouse/platform_darwin.go index 0ac57f5..e93bd10 100644 --- a/mouse/platform_darwin.go +++ b/mouse/platform_darwin.go @@ -5,6 +5,9 @@ package mouse /* #include "mouse.h" + +void dragMouse(MMPointInt32 point, const MMMouseButton button); +bool smoothlyDragMouse(MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed); */ import "C" @@ -30,6 +33,36 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } +func dragTo(x, y int, button MouseButton, settings MouseSettings) error { + cbtn, err := mouseButtonToC(button) + if err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + cx := C.int32_t(x) + cy := C.int32_t(y) + C.dragMouse(C.MMPointInt32Make(cx, cy), cbtn) + MilliSleep(settings.Sleep) + return nil +} + +func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { + cbtn, err := mouseButtonToC(button) + if err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + cx := C.int32_t(x) + cy := C.int32_t(y) + low := C.double(settings.MoveSmoothLow) + high := C.double(settings.MoveSmoothHigh) + + cbool := C.smoothlyDragMouse(C.MMPointInt32Make(cx, cy), cbtn, low, high) + MilliSleep(settings.Sleep + settings.MoveSmoothDelay) + if !bool(cbool) { + return wrapMouseError(MouseOpDrag, ErrMouseActionFailed, button, 0, 0, "smooth drag returned false", 0) + } + return nil +} + func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { switch delta.Unit { case ScrollUnitLine: diff --git a/mouse/platform_linux.go b/mouse/platform_linux.go index 4f8ad38..5293e95 100644 --- a/mouse/platform_linux.go +++ b/mouse/platform_linux.go @@ -40,6 +40,20 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } +func dragTo(x, y int, button MouseButton, settings MouseSettings) error { + if _, err := mouseButtonToC(button); err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + return Move(x, y, settings) +} + +func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { + if _, err := mouseButtonToC(button); err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + return MoveSmooth(x, y, settings) +} + func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { switch delta.Unit { case ScrollUnitLine: diff --git a/mouse/platform_windows.go b/mouse/platform_windows.go index 18b089d..dc73f00 100644 --- a/mouse/platform_windows.go +++ b/mouse/platform_windows.go @@ -34,6 +34,20 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } +func dragTo(x, y int, button MouseButton, settings MouseSettings) error { + if _, err := mouseButtonToC(button); err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + return Move(x, y, settings) +} + +func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { + if _, err := mouseButtonToC(button); err != nil { + return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) + } + return MoveSmooth(x, y, settings) +} + func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { switch delta.Unit { case ScrollUnitLine: From 5d2d9a7e5000463bc3ef7077d7807ccd9a141fe0 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 25 Jan 2026 18:02:41 +0800 Subject: [PATCH 08/37] refactor(darwin): route display drag through mouse drag - reuse DragTo for display-level drags - convert to absolute coords for DragSmooth --- display/display_darwin.go | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/display/display_darwin.go b/display/display_darwin.go index 4dca7d0..c1bde7c 100644 --- a/display/display_darwin.go +++ b/display/display_darwin.go @@ -175,28 +175,13 @@ func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings if err := d.Move(fromX, fromY, settings); err != nil { return err } - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(toX, toY, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) + return d.DragTo(toX, toY, button, settings) } // DragTo drags the mouse from the current position to the specified position on this display. func (d *Display) DragTo(physX, physY int, button MouseButton, settings MouseSettings) error { - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(physX, physY, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + return mouse.DragSmooth(virtAbsX, virtAbsY, button, settings) } // CaptureRect captures a rectangular region of this display. From a127f39bf6cb873455481ae93a32025a331223bc Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:43:44 +0800 Subject: [PATCH 09/37] feat(keyboardstate): add modifier and lock state API - add cross-platform key state reader with press/toggle enums - expose keyboardstate helpers from the root package --- go.mod | 5 +- go.sum | 2 + keyboardstate/state.go | 152 +++++++++++++++++++++++++++++++++ keyboardstate/state_darwin.go | 99 +++++++++++++++++++++ keyboardstate/state_windows.go | 43 ++++++++++ keyboardstate/state_x11.go | 130 ++++++++++++++++++++++++++++ keyboardstate_exports.go | 49 +++++++++++ 7 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 keyboardstate/state.go create mode 100644 keyboardstate/state_darwin.go create mode 100644 keyboardstate/state_windows.go create mode 100644 keyboardstate/state_x11.go create mode 100644 keyboardstate_exports.go diff --git a/go.mod b/go.mod index 8caa619..bdc33b2 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,7 @@ require ( golang.org/x/image v0.22.0 ) -require golang.org/x/sys v0.24.0 // indirect +require ( + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum index 41d89ef..0d6cc94 100644 --- a/go.sum +++ b/go.sum @@ -11,3 +11,5 @@ golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4 golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/keyboardstate/state.go b/keyboardstate/state.go new file mode 100644 index 0000000..ea0595b --- /dev/null +++ b/keyboardstate/state.go @@ -0,0 +1,152 @@ +package keyboardstate + +import "errors" + +var ( + // ErrDisplayUnavailable is returned when the X11 display cannot be opened. + ErrDisplayUnavailable = errors.New("display not available") +) + +type stateSnapshot struct { + mask uint32 + supported uint32 +} + +const ( + bitShift uint32 = 1 << iota + bitCtrl + bitAlt + bitCmd + bitCapsLock + bitNumLock + bitScrollLock +) + +// PressState describes the current pressed state of a modifier key. +type PressState int + +const ( + PressUnsupported PressState = iota + PressUp + PressDown +) + +// ToggleState describes the current toggle state of a lock key. +type ToggleState int + +const ( + ToggleUnsupported ToggleState = iota + ToggleOff + ToggleOn +) + +// Snapshot reports the current modifier and lock key states. +type Snapshot struct { + Shift PressState + Ctrl PressState + Alt PressState + Cmd PressState + CapsLock ToggleState + NumLock ToggleState + ScrollLock ToggleState +} + +// Current returns a snapshot of modifier and lock key states. +func Current() (Snapshot, error) { + state, err := currentState() + if err != nil { + return Snapshot{}, err + } + return Snapshot{ + Shift: pressStateFrom(state, bitShift), + Ctrl: pressStateFrom(state, bitCtrl), + Alt: pressStateFrom(state, bitAlt), + Cmd: pressStateFrom(state, bitCmd), + CapsLock: toggleStateFrom(state, bitCapsLock), + NumLock: toggleStateFrom(state, bitNumLock), + ScrollLock: toggleStateFrom(state, bitScrollLock), + }, nil +} + +// Shift returns the current state of the Shift modifier. +func Shift() (PressState, error) { + return pressStateFor(bitShift) +} + +// Ctrl returns the current state of the Ctrl modifier. +func Ctrl() (PressState, error) { + return pressStateFor(bitCtrl) +} + +// Alt returns the current state of the Alt modifier. +func Alt() (PressState, error) { + return pressStateFor(bitAlt) +} + +// Cmd returns the current state of the Command/Windows/Super modifier. +func Cmd() (PressState, error) { + return pressStateFor(bitCmd) +} + +// CapsLock returns the current state of Caps Lock. +func CapsLock() (ToggleState, error) { + return toggleStateFor(bitCapsLock) +} + +// NumLock returns the current state of Num Lock. +func NumLock() (ToggleState, error) { + return toggleStateFor(bitNumLock) +} + +// ScrollLock returns the current state of Scroll Lock. +func ScrollLock() (ToggleState, error) { + return toggleStateFor(bitScrollLock) +} + +func pressStateFor(bit uint32) (PressState, error) { + state, err := currentState() + if err != nil { + return PressUnsupported, err + } + if state.supported&bit == 0 { + return PressUnsupported, nil + } + if state.mask&bit != 0 { + return PressDown, nil + } + return PressUp, nil +} + +func toggleStateFor(bit uint32) (ToggleState, error) { + state, err := currentState() + if err != nil { + return ToggleUnsupported, err + } + if state.supported&bit == 0 { + return ToggleUnsupported, nil + } + if state.mask&bit != 0 { + return ToggleOn, nil + } + return ToggleOff, nil +} + +func pressStateFrom(state stateSnapshot, bit uint32) PressState { + if state.supported&bit == 0 { + return PressUnsupported + } + if state.mask&bit != 0 { + return PressDown + } + return PressUp +} + +func toggleStateFrom(state stateSnapshot, bit uint32) ToggleState { + if state.supported&bit == 0 { + return ToggleUnsupported + } + if state.mask&bit != 0 { + return ToggleOn + } + return ToggleOff +} diff --git a/keyboardstate/state_darwin.go b/keyboardstate/state_darwin.go new file mode 100644 index 0000000..88de1b9 --- /dev/null +++ b/keyboardstate/state_darwin.go @@ -0,0 +1,99 @@ +//go:build darwin +// +build darwin + +package keyboardstate + +/* +#cgo darwin CFLAGS: -Wno-deprecated-declarations +#cgo darwin LDFLAGS: -framework ApplicationServices -framework IOKit -framework CoreFoundation + +#include +#include +#include +#include +#include +#include + +static uint64_t deskactKeyFlagsState(void) { + return (uint64_t)CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); +} + +static int deskactGetLockStates(bool *caps, bool *num, bool *capsOk, bool *numOk) { + *caps = false; + *num = false; + *capsOk = false; + *numOk = false; + + io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, + IOServiceMatching(kIOHIDSystemClass)); + if (service == 0) { + return -1; + } + + io_connect_t connect = IO_OBJECT_NULL; + kern_return_t kr = IOServiceOpen(service, mach_task_self(), kIOHIDParamConnectType, &connect); + IOObjectRelease(service); + if (kr != KERN_SUCCESS) { + return -2; + } + + kr = IOHIDGetModifierLockState(connect, kIOHIDCapsLockState, caps); + if (kr == KERN_SUCCESS) { + *capsOk = true; + } + + kr = IOHIDGetModifierLockState(connect, kIOHIDNumLockState, num); + if (kr == KERN_SUCCESS) { + *numOk = true; + } + + IOServiceClose(connect); + return 0; +} +*/ +import "C" + +func currentState() (stateSnapshot, error) { + var state stateSnapshot + flags := uint64(C.deskactKeyFlagsState()) + + state.supported = bitShift | bitCtrl | bitAlt | bitCmd | bitCapsLock + + if flags&uint64(C.kCGEventFlagMaskShift) != 0 { + state.mask |= bitShift + } + if flags&uint64(C.kCGEventFlagMaskControl) != 0 { + state.mask |= bitCtrl + } + if flags&uint64(C.kCGEventFlagMaskAlternate) != 0 { + state.mask |= bitAlt + } + if flags&uint64(C.kCGEventFlagMaskCommand) != 0 { + state.mask |= bitCmd + } + if flags&uint64(C.kCGEventFlagMaskAlphaShift) != 0 { + state.mask |= bitCapsLock + } + + var caps C.bool + var num C.bool + var capsOk C.bool + var numOk C.bool + if C.deskactGetLockStates(&caps, &num, &capsOk, &numOk) == 0 { + if capsOk != 0 { + state.mask &^= bitCapsLock + if caps != 0 { + state.mask |= bitCapsLock + } + } + if numOk != 0 { + state.supported |= bitNumLock + if num != 0 { + state.mask |= bitNumLock + } + } + } + + return state, nil +} + diff --git a/keyboardstate/state_windows.go b/keyboardstate/state_windows.go new file mode 100644 index 0000000..7859344 --- /dev/null +++ b/keyboardstate/state_windows.go @@ -0,0 +1,43 @@ +//go:build windows +// +build windows + +package keyboardstate + +import "github.com/lxn/win" + +func currentState() (stateSnapshot, error) { + var state stateSnapshot + state.supported = bitShift | bitCtrl | bitAlt | bitCmd | bitCapsLock | bitNumLock | bitScrollLock + + if down, _ := keyState(win.VK_SHIFT); down { + state.mask |= bitShift + } + if down, _ := keyState(win.VK_CONTROL); down { + state.mask |= bitCtrl + } + if down, _ := keyState(win.VK_MENU); down { + state.mask |= bitAlt + } + if down, _ := keyState(win.VK_LWIN); down { + state.mask |= bitCmd + } + if down, _ := keyState(win.VK_RWIN); down { + state.mask |= bitCmd + } + if _, toggled := keyState(win.VK_CAPITAL); toggled { + state.mask |= bitCapsLock + } + if _, toggled := keyState(win.VK_NUMLOCK); toggled { + state.mask |= bitNumLock + } + if _, toggled := keyState(win.VK_SCROLL); toggled { + state.mask |= bitScrollLock + } + + return state, nil +} + +func keyState(vk int32) (down bool, toggled bool) { + state := int32(win.GetKeyState(vk)) + return state&0x8000 != 0, state&0x0001 != 0 +} diff --git a/keyboardstate/state_x11.go b/keyboardstate/state_x11.go new file mode 100644 index 0000000..914c413 --- /dev/null +++ b/keyboardstate/state_x11.go @@ -0,0 +1,130 @@ +//go:build !windows && !darwin +// +build !windows,!darwin + +package keyboardstate + +/* +#cgo linux CFLAGS: -I/usr/src +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst + +#include "../base/xdisplay_c.h" +#include +#include + +static int deskactKeycodeExists(Display *display, KeySym sym) { + return XKeysymToKeycode(display, sym) != 0; +} + +static int deskactKeyDown(Display *display, const char *keys, KeySym sym) { + KeyCode code = XKeysymToKeycode(display, sym); + if (code == 0) { + return 0; + } + return (keys[code / 8] & (1 << (code % 8))) != 0; +} + +static int deskactIndicatorState(Display *display, const char *name, Bool *state) { + Atom atom = XInternAtom(display, name, False); + if (atom == None) { + return 0; + } + if (XkbGetNamedIndicator(display, atom, NULL, state, NULL, NULL) == True) { + return 1; + } + return 0; +} + +static int deskactIndicatorStateAny(Display *display, const char *primary, const char *fallback, Bool *state) { + if (deskactIndicatorState(display, primary, state)) { + return 1; + } + if (fallback != NULL && deskactIndicatorState(display, fallback, state)) { + return 1; + } + return 0; +} + +static int deskactGetModifierState(uint32_t *stateOut, uint32_t *supportedOut) { + Display *display = XGetMainDisplay(); + if (display == NULL) { + return -1; + } + + char keys[32]; + XQueryKeymap(display, keys); + + uint32_t state = 0; + uint32_t supported = 0; + + if (deskactKeycodeExists(display, XK_Shift_L) || deskactKeycodeExists(display, XK_Shift_R)) { + supported |= (1u << 0); + if (deskactKeyDown(display, keys, XK_Shift_L) || deskactKeyDown(display, keys, XK_Shift_R)) { + state |= (1u << 0); + } + } + + if (deskactKeycodeExists(display, XK_Control_L) || deskactKeycodeExists(display, XK_Control_R)) { + supported |= (1u << 1); + if (deskactKeyDown(display, keys, XK_Control_L) || deskactKeyDown(display, keys, XK_Control_R)) { + state |= (1u << 1); + } + } + + if (deskactKeycodeExists(display, XK_Alt_L) || deskactKeycodeExists(display, XK_Alt_R)) { + supported |= (1u << 2); + if (deskactKeyDown(display, keys, XK_Alt_L) || deskactKeyDown(display, keys, XK_Alt_R)) { + state |= (1u << 2); + } + } + + if (deskactKeycodeExists(display, XK_Super_L) || deskactKeycodeExists(display, XK_Super_R)) { + supported |= (1u << 3); + if (deskactKeyDown(display, keys, XK_Super_L) || deskactKeyDown(display, keys, XK_Super_R)) { + state |= (1u << 3); + } + } + + int xkbOpcode, xkbEvent, xkbError; + int xkbMajor = XkbMajorVersion; + int xkbMinor = XkbMinorVersion; + if (XkbQueryExtension(display, &xkbOpcode, &xkbEvent, &xkbError, &xkbMajor, &xkbMinor)) { + Bool indicator = False; + if (deskactIndicatorStateAny(display, "Caps Lock", "CapsLock", &indicator)) { + supported |= (1u << 4); + if (indicator) { + state |= (1u << 4); + } + } + if (deskactIndicatorStateAny(display, "Num Lock", "NumLock", &indicator)) { + supported |= (1u << 5); + if (indicator) { + state |= (1u << 5); + } + } + if (deskactIndicatorStateAny(display, "Scroll Lock", "ScrollLock", &indicator)) { + supported |= (1u << 6); + if (indicator) { + state |= (1u << 6); + } + } + } + + *stateOut = state; + *supportedOut = supported; + return 0; +} +*/ +import "C" + +func currentState() (stateSnapshot, error) { + var state stateSnapshot + var mask C.uint32_t + var supported C.uint32_t + if C.deskactGetModifierState(&mask, &supported) != 0 { + return stateSnapshot{}, ErrDisplayUnavailable + } + state.mask = uint32(mask) + state.supported = uint32(supported) + return state, nil +} + diff --git a/keyboardstate_exports.go b/keyboardstate_exports.go new file mode 100644 index 0000000..b73ac31 --- /dev/null +++ b/keyboardstate_exports.go @@ -0,0 +1,49 @@ +package deskact + +import ks "github.com/PekingSpades/DeskAct/keyboardstate" + +type KeyboardPressState = ks.PressState +type KeyboardToggleState = ks.ToggleState +type KeyboardStateSnapshot = ks.Snapshot + +const ( + KeyboardPressUnsupported = ks.PressUnsupported + KeyboardPressUp = ks.PressUp + KeyboardPressDown = ks.PressDown + + KeyboardToggleUnsupported = ks.ToggleUnsupported + KeyboardToggleOff = ks.ToggleOff + KeyboardToggleOn = ks.ToggleOn +) + +func KeyboardStateCurrent() (KeyboardStateSnapshot, error) { + return ks.Current() +} + +func KeyboardStateShift() (KeyboardPressState, error) { + return ks.Shift() +} + +func KeyboardStateCtrl() (KeyboardPressState, error) { + return ks.Ctrl() +} + +func KeyboardStateAlt() (KeyboardPressState, error) { + return ks.Alt() +} + +func KeyboardStateCmd() (KeyboardPressState, error) { + return ks.Cmd() +} + +func KeyboardStateCapsLock() (KeyboardToggleState, error) { + return ks.CapsLock() +} + +func KeyboardStateNumLock() (KeyboardToggleState, error) { + return ks.NumLock() +} + +func KeyboardStateScrollLock() (KeyboardToggleState, error) { + return ks.ScrollLock() +} From 74be2d2ea9eea791d246a00b046729131ba74a4c Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:49:38 +0800 Subject: [PATCH 10/37] feat(keyboard)!: drop key aliases and add supported key list - remove alias constants and alias lookup in key handling - expose SupportedKeyNames to filter keys by platform support --- keyboard/key_aliases.go | 27 --------------------------- keyboard/key_catalog.go | 27 +++++++++++++++------------ keyboard/keyboard.go | 3 +-- keyboard/keys.go | 5 ----- keyboard_exports.go | 11 ++++------- 5 files changed, 20 insertions(+), 53 deletions(-) delete mode 100644 keyboard/key_aliases.go diff --git a/keyboard/key_aliases.go b/keyboard/key_aliases.go deleted file mode 100644 index a81dc59..0000000 --- a/keyboard/key_aliases.go +++ /dev/null @@ -1,27 +0,0 @@ -package keyboard - -var keyAliases = map[string]string{ - Escape: Esc, - Control: Ctrl, - Printscreen: Print, - "command": Cmd, - "right_shift": Rshift, - "numpad_0": Num0, - "numpad_1": Num1, - "numpad_2": Num2, - "numpad_3": Num3, - "numpad_4": Num4, - "numpad_5": Num5, - "numpad_6": Num6, - "numpad_7": Num7, - "numpad_8": Num8, - "numpad_9": Num9, - "numpad_lock": NumLock, -} - -func canonicalizeKeyName(key string) string { - if replacement, ok := keyAliases[key]; ok { - return replacement - } - return key -} diff --git a/keyboard/key_catalog.go b/keyboard/key_catalog.go index 73437d3..9a85312 100644 --- a/keyboard/key_catalog.go +++ b/keyboard/key_catalog.go @@ -6,12 +6,12 @@ var keyNames = []string{ CapA, CapB, CapC, CapD, CapE, CapF, CapG, CapH, CapI, CapJ, CapK, CapL, CapM, CapN, CapO, CapP, CapQ, CapR, CapS, CapT, CapU, CapV, CapW, CapX, CapY, CapZ, Key0, Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, - Backspace, Delete, Enter, Tab, Esc, Escape, Up, Down, Right, Left, Home, End, + Backspace, Delete, Enter, Tab, Esc, Up, Down, Right, Left, Home, End, Pageup, Pagedown, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, F24, - Cmd, Lcmd, Rcmd, Alt, Lalt, Ralt, Ctrl, Lctrl, Rctrl, Control, Shift, Lshift, - Rshift, Capslock, Space, Print, Printscreen, Insert, Menu, + Cmd, Lcmd, Rcmd, Alt, Lalt, Ralt, Ctrl, Lctrl, Rctrl, Shift, Lshift, Rshift, + Capslock, Space, Print, Insert, Menu, AudioMute, AudioVolDown, AudioVolUp, AudioPlay, AudioStop, AudioPause, AudioPrev, AudioNext, AudioRewind, AudioForward, AudioRepeat, AudioRandom, Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9, NumLock, @@ -28,18 +28,21 @@ func KeyNames() []string { return names } +// SupportedKeyNames returns the key names supported on the current platform. +func SupportedKeyNames() []string { + names := make([]string, 0, len(keyNames)) + for _, name := range keyNames { + normalized, _ := normalizeKeyAndModifiers(name, nil) + if _, err := checkKeyCodes(normalized); err == nil { + names = append(names, name) + } + } + return names +} + // ModifierNames returns a copy of the supported modifier names. func ModifierNames() []Modifier { names := make([]Modifier, len(modifierNames)) copy(names, modifierNames) return names } - -// KeyAliases returns a copy of the supported key aliases. -func KeyAliases() map[string]string { - aliases := make(map[string]string, len(keyAliases)) - for k, v := range keyAliases { - aliases[k] = v - } - return aliases -} diff --git a/keyboard/keyboard.go b/keyboard/keyboard.go index 2e83c72..3c3e92d 100644 --- a/keyboard/keyboard.go +++ b/keyboard/keyboard.go @@ -244,8 +244,7 @@ func checkKeyCodes(k string) (key C.MMKeyCode, err error) { return } - keyName := canonicalizeKeyName(k) - if v, ok := keyNameMap[keyName]; ok { + if v, ok := keyNameMap[k]; ok { key = v if key == C.K_NOT_A_KEY { return 0, keyLookupError(ErrUnsupportedKey, k) diff --git a/keyboard/keys.go b/keyboard/keys.go index b8371fc..51a7544 100644 --- a/keyboard/keys.go +++ b/keyboard/keys.go @@ -74,7 +74,6 @@ const ( Enter = "enter" Tab = "tab" Esc = "esc" - Escape = "escape" Up = "up" // Up arrow key Down = "down" // Down arrow key Right = "right" // Right arrow key @@ -112,22 +111,18 @@ const ( Cmd = "cmd" // is the "win" key for windows Lcmd = "lcmd" // left command Rcmd = "rcmd" // right command - // "command" Alt = "alt" Lalt = "lalt" // left alt Ralt = "ralt" // right alt Ctrl = "ctrl" Lctrl = "lctrl" // left ctrl Rctrl = "rctrl" // right ctrl - Control = "control" Shift = "shift" Lshift = "lshift" // left shift Rshift = "rshift" // right shift - // "right_shift" Capslock = "capslock" Space = "space" Print = "print" - Printscreen = "printscreen" // No Mac support Insert = "insert" Menu = "menu" // Windows only diff --git a/keyboard_exports.go b/keyboard_exports.go index 6a0c913..d35c6ea 100644 --- a/keyboard_exports.go +++ b/keyboard_exports.go @@ -88,7 +88,6 @@ const ( Enter = kbd.Enter Tab = kbd.Tab Esc = kbd.Esc - Escape = kbd.Escape Up = kbd.Up Down = kbd.Down Right = kbd.Right @@ -130,14 +129,12 @@ const ( Ctrl = kbd.Ctrl Lctrl = kbd.Lctrl Rctrl = kbd.Rctrl - Control = kbd.Control Shift = kbd.Shift Lshift = kbd.Lshift Rshift = kbd.Rshift Capslock = kbd.Capslock Space = kbd.Space Print = kbd.Print - Printscreen = kbd.Printscreen Insert = kbd.Insert Menu = kbd.Menu AudioMute = kbd.AudioMute @@ -191,12 +188,12 @@ func KeyNames() []string { return kbd.KeyNames() } -func ModifierNames() []Modifier { - return kbd.ModifierNames() +func SupportedKeyNames() []string { + return kbd.SupportedKeyNames() } -func KeyAliases() map[string]string { - return kbd.KeyAliases() +func ModifierNames() []Modifier { + return kbd.ModifierNames() } func DefaultSpecialKeys() map[string]string { From e8a1c8f921269e93e28f58084592b4b99b9c6807 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:03:52 +0800 Subject: [PATCH 11/37] feat(window): add window listing and display regions - implement window enumeration for Windows and macOS with platform metadata - compute per-display window regions with DPI-aware physical mapping - add window overlay example for capture/annotation - use Windows logical display size from monitor info when available --- display/display_c_windows.h | 10 +- display/display_windows.go | 33 ++- examples/window/main.go | 507 +++++++++++++++++++++++++++++++++ window/coords_darwin.go | 8 + window/coords_linux.go | 8 + window/coords_windows.go | 8 + window/list_windows_darwin.go | 227 +++++++++++++++ window/list_windows_linux.go | 8 + window/list_windows_windows.go | 182 ++++++++++++ window/regions.go | 114 ++++++++ window/regions_other.go | 13 + window/regions_windows.go | 91 ++++++ window/window.go | 90 ++++++ window_exports.go | 18 ++ 14 files changed, 1302 insertions(+), 15 deletions(-) create mode 100644 examples/window/main.go create mode 100644 window/coords_darwin.go create mode 100644 window/coords_linux.go create mode 100644 window/coords_windows.go create mode 100644 window/list_windows_darwin.go create mode 100644 window/list_windows_linux.go create mode 100644 window/list_windows_windows.go create mode 100644 window/regions.go create mode 100644 window/regions_other.go create mode 100644 window/regions_windows.go create mode 100644 window/window.go create mode 100644 window_exports.go diff --git a/display/display_c_windows.h b/display/display_c_windows.h index c1c235a..d0e41b3 100644 --- a/display/display_c_windows.h +++ b/display/display_c_windows.h @@ -52,6 +52,7 @@ typedef struct { int8_t isMain; // Is main display int32_t x, y, w, h; // Physical coordinates and size int32_t vx, vy; // Virtual (logical) coordinates origin + int32_t vw, vh; // Virtual (logical) size double scale; // Scale factor (physical/logical) } DisplayInfoC; @@ -124,6 +125,10 @@ static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRE // Always store virtual (logical) coordinates info->vx = lprcMonitor->left; info->vy = lprcMonitor->top; + int32_t logicalW = lprcMonitor->right - lprcMonitor->left; + int32_t logicalH = lprcMonitor->bottom - lprcMonitor->top; + info->vw = logicalW; + info->vh = logicalH; if (hasPhysical) { // Use physical coordinates @@ -133,7 +138,6 @@ static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRE info->h = physRect.bottom - physRect.top; // Calculate scale based on DPI awareness mode - int32_t logicalW = lprcMonitor->right - lprcMonitor->left; if (logicalW > 0 && info->w != logicalW) { // Physical and logical sizes differ (DPI unaware mode) // Windows virtualizes coordinates, use ratio to calculate scale @@ -147,8 +151,8 @@ static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRE // Fallback: use logical coordinates info->x = lprcMonitor->left; info->y = lprcMonitor->top; - info->w = lprcMonitor->right - lprcMonitor->left; - info->h = lprcMonitor->bottom - lprcMonitor->top; + info->w = logicalW; + info->h = logicalH; info->scale = 1.0; } diff --git a/display/display_windows.go b/display/display_windows.go index 924b66d..bc46a42 100644 --- a/display/display_windows.go +++ b/display/display_windows.go @@ -37,10 +37,13 @@ func MainDisplay(options DisplayOptions) *Display { physW, physH := int(info.w), int(info.h) // Calculate logical size. - logicalW, logicalH := physW, physH - if scale > 0 { - logicalW = int(float64(physW) / scale) - logicalH = int(float64(physH) / scale) + logicalW, logicalH := int(info.vw), int(info.vh) + if logicalW <= 0 || logicalH <= 0 { + logicalW, logicalH = physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } } // Always store physical origin for capture operations. @@ -92,10 +95,13 @@ func AllDisplays(options DisplayOptions) []*Display { scale := float64(info.scale) physW, physH := int(info.w), int(info.h) - logicalW, logicalH := physW, physH - if scale > 0 { - logicalW = int(float64(physW) / scale) - logicalH = int(float64(physH) / scale) + logicalW, logicalH := int(info.vw), int(info.vh) + if logicalW <= 0 || logicalH <= 0 { + logicalW, logicalH = physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } } // Always store physical origin for capture operations. @@ -147,10 +153,13 @@ func DisplayAt(index int, options DisplayOptions) *Display { scale := float64(info.scale) physW, physH := int(info.w), int(info.h) - logicalW, logicalH := physW, physH - if scale > 0 { - logicalW = int(float64(physW) / scale) - logicalH = int(float64(physH) / scale) + logicalW, logicalH := int(info.vw), int(info.vh) + if logicalW <= 0 || logicalH <= 0 { + logicalW, logicalH = physW, physH + if scale > 0 { + logicalW = int(float64(physW) / scale) + logicalH = int(float64(physH) / scale) + } } // Always store physical origin for capture operations. diff --git a/examples/window/main.go b/examples/window/main.go new file mode 100644 index 0000000..00b7a87 --- /dev/null +++ b/examples/window/main.go @@ -0,0 +1,507 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "runtime" + "strings" + + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" + + deskact "github.com/PekingSpades/DeskAct" +) + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Window Overlay Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + + dpiSelected, dpiResult := promptDPIInit() + fmt.Printf("DPI init selected: %v\n", dpiSelected) + fmt.Printf("DPI init result: %s\n", dpiResult) + + displayOptions := deskact.DefaultDisplayOptions() + windowOptions := deskact.DefaultWindowOptions() + windowOptions.DPIAware = displayOptions.DPIAware + + displays := deskact.AllDisplays(displayOptions) + if len(displays) == 0 { + fmt.Println("No displays detected.") + return + } + + windows, err := deskact.ListWindows(windowOptions) + if err != nil { + if errors.Is(err, deskact.ErrWindowUnsupported) { + fmt.Println("Window listing is not supported on this platform.") + return + } + fmt.Printf("ListWindows error: %v\n", err) + return + } + fmt.Printf("Detected %d windows\n", len(windows)) + + face, closeFace, faceInfo := loadFontFace() + defer closeFace() + if faceInfo != "" { + fmt.Printf("Using font: %s\n", faceInfo) + } else { + fmt.Println("Using built-in ASCII font (CJK may not render).") + } + + minX, minY, maxX, maxY := displayBounds(displays) + overviewWidth := maxX - minX + overviewHeight := maxY - minY + if overviewWidth <= 0 || overviewHeight <= 0 { + fmt.Println("Invalid overview bounds.") + return + } + + axisMargin := 50 + canvasWidth := overviewWidth + axisMargin + canvasHeight := overviewHeight + axisMargin + overview := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) + + bgColor := color.RGBA{R: 40, G: 40, B: 40, A: 255} + draw.Draw(overview, overview.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src) + drawAxes(overview, axisMargin, canvasWidth, canvasHeight, minX, minY, maxX, maxY) + + fmt.Println("\nCapturing displays...") + for _, d := range displays { + info := d.Info() + if info.Size.W <= 0 || info.Size.H <= 0 { + continue + } + + img, err := d.CaptureRect(0, 0, info.Size.W, info.Size.H, deskact.DefaultCaptureOptions()) + if err != nil { + fmt.Printf(" Display #%d: capture failed: %v\n", info.Index, err) + continue + } + + annotateDisplay(img, windows, info.Index, face) + displayFile := fmt.Sprintf("window_display_%d.png", info.Index) + if err := savePNG(displayFile, img); err != nil { + fmt.Printf(" Display #%d: save failed: %v\n", info.Index, err) + } else { + fmt.Printf(" Display #%d: saved to %s (%dx%d)\n", + info.Index, displayFile, img.Bounds().Dx(), img.Bounds().Dy()) + } + + posX := info.Origin.X - minX + axisMargin + posY := info.Origin.Y - minY + axisMargin + destW := info.Origin.W + destH := info.Origin.H + if destW <= 0 || destH <= 0 { + continue + } + + destRect := image.Rect(posX, posY, posX+destW, posY+destH) + draw.CatmullRom.Scale(overview, destRect, img, img.Bounds(), draw.Over, nil) + } + + fmt.Println("\nOverlaying windows...") + overlayWindows(overview, windows, displays, minX, minY, axisMargin, face) + + overviewFile := "window_overview.png" + if err := savePNG(overviewFile, overview); err != nil { + fmt.Printf("\nFailed to save overview: %v\n", err) + } else { + fmt.Printf("\nOverview saved to %s (%dx%d)\n", overviewFile, canvasWidth, canvasHeight) + } +} + +func displayBounds(displays []*deskact.Display) (minX, minY, maxX, maxY int) { + for i, d := range displays { + info := d.Info() + x := info.Origin.X + y := info.Origin.Y + w := info.Origin.W + h := info.Origin.H + + if i == 0 { + minX, minY = x, y + maxX, maxY = x+w, y+h + continue + } + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x+w > maxX { + maxX = x + w + } + if y+h > maxY { + maxY = y + h + } + } + return minX, minY, maxX, maxY +} + +func overlayWindows(img *image.RGBA, windows []deskact.WindowInfo, displays []*deskact.Display, minX, minY, margin int, face font.Face) { + if img == nil { + return + } + red := color.RGBA{R: 220, G: 40, B: 40, A: 255} + displayByIndex := make(map[int]deskact.DisplayInfo, len(displays)) + for _, d := range displays { + displayByIndex[d.Index()] = d.Info() + } + + for _, w := range windows { + title := strings.TrimSpace(w.Title) + if title == "" { + title = fmt.Sprintf("PID %d", w.PID) + } + title = truncateLabel(title, 60) + + labeled := false + for _, region := range w.DisplayRegions { + info, ok := displayByIndex[region.DisplayIndex] + if !ok { + continue + } + virt, ok := physicalToVirtualRect(region.PhysicalRect, info) + if !ok { + continue + } + x := virt.X - minX + margin + y := virt.Y - minY + margin + rect := image.Rect(x, y, x+virt.W, y+virt.H) + drawRect(img, rect, red, 2) + if !labeled { + drawLabel(img, rect.Min.X+2, rect.Min.Y+2, title, red, face) + labeled = true + } + } + if labeled { + continue + } + if w.Bounds.W <= 0 || w.Bounds.H <= 0 { + continue + } + x := w.Bounds.X - minX + margin + y := w.Bounds.Y - minY + margin + rect := image.Rect(x, y, x+w.Bounds.W, y+w.Bounds.H) + drawRect(img, rect, red, 2) + drawLabel(img, rect.Min.X+2, rect.Min.Y+2, title, red, face) + } +} + +func physicalToVirtualRect(phys deskact.Rect, info deskact.DisplayInfo) (deskact.Rect, bool) { + if phys.W <= 0 || phys.H <= 0 { + return deskact.Rect{}, false + } + if info.Size.W <= 0 || info.Size.H <= 0 || info.Origin.W <= 0 || info.Origin.H <= 0 { + return deskact.Rect{}, false + } + + scaleX := float64(info.Origin.W) / float64(info.Size.W) + scaleY := float64(info.Origin.H) / float64(info.Size.H) + x0 := int(math.Round(float64(phys.X) * scaleX)) + y0 := int(math.Round(float64(phys.Y) * scaleY)) + x1 := int(math.Round(float64(phys.X+phys.W) * scaleX)) + y1 := int(math.Round(float64(phys.Y+phys.H) * scaleY)) + w := x1 - x0 + h := y1 - y0 + if w <= 0 || h <= 0 { + return deskact.Rect{}, false + } + return deskact.Rect{ + Point: deskact.Point{X: info.Origin.X + x0, Y: info.Origin.Y + y0}, + Size: deskact.Size{W: w, H: h}, + }, true +} + +func annotateDisplay(img *image.RGBA, windows []deskact.WindowInfo, displayIndex int, face font.Face) { + if img == nil { + return + } + red := color.RGBA{R: 220, G: 40, B: 40, A: 255} + + for _, w := range windows { + title := strings.TrimSpace(w.Title) + if title == "" { + title = fmt.Sprintf("PID %d", w.PID) + } + title = truncateLabel(title, 60) + + for _, region := range w.DisplayRegions { + if region.DisplayIndex != displayIndex { + continue + } + if region.PhysicalRect.W <= 0 || region.PhysicalRect.H <= 0 { + continue + } + x := region.PhysicalRect.X + y := region.PhysicalRect.Y + rect := image.Rect(x, y, x+region.PhysicalRect.W, y+region.PhysicalRect.H) + drawRect(img, rect, red, 2) + drawLabel(img, rect.Min.X+2, rect.Min.Y+2, title, red, face) + } + } +} + +func loadFontFace() (font.Face, func(), string) { + size := 14.0 + paths := fontPaths() + for _, path := range paths { + face, err := loadFontFromPath(path, size) + if err == nil { + return face, func() { closeFace(face) }, path + } + } + return basicfont.Face7x13, func() {}, "" +} + +func fontPaths() []string { + switch runtime.GOOS { + case "windows": + return []string{ + `C:\Windows\Fonts\msyh.ttc`, + `C:\Windows\Fonts\simsun.ttc`, + `C:\Windows\Fonts\simhei.ttf`, + `C:\Windows\Fonts\msyh.ttf`, + `C:\Windows\Fonts\segoeui.ttf`, + } + case "darwin": + return []string{ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Medium.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/System/Library/Fonts/AppleGothic.ttf", + } + default: + return []string{ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJKSC-Regular.otf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + } + } +} + +func loadFontFromPath(path string, size float64) (font.Face, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + collection, err := opentype.ParseCollection(data) + if err != nil { + return nil, err + } + for i := 0; i < collection.NumFonts(); i++ { + f, err := collection.Font(i) + if err != nil { + continue + } + face, err := opentype.NewFace(f, &opentype.FaceOptions{ + Size: size, + DPI: 72, + Hinting: font.HintingFull, + }) + if err == nil { + return face, nil + } + } + return nil, errors.New("no usable font in collection") +} + +func closeFace(face font.Face) { + if face == nil { + return + } + if closer, ok := face.(interface{ Close() error }); ok { + _ = closer.Close() + } +} + +func truncateLabel(text string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= maxRunes { + return text + } + if maxRunes <= 3 { + return string(runes[:maxRunes]) + } + return string(runes[:maxRunes-3]) + "..." +} + +func drawRect(img *image.RGBA, rect image.Rectangle, c color.RGBA, thickness int) { + if thickness < 1 { + thickness = 1 + } + b := img.Bounds() + rect = rect.Intersect(b) + if rect.Empty() { + return + } + + for t := 0; t < thickness; t++ { + x0 := rect.Min.X + t + y0 := rect.Min.Y + t + x1 := rect.Max.X - 1 - t + y1 := rect.Max.Y - 1 - t + if x1 < x0 || y1 < y0 { + break + } + for x := x0; x <= x1; x++ { + img.SetRGBA(x, y0, c) + img.SetRGBA(x, y1, c) + } + for y := y0; y <= y1; y++ { + img.SetRGBA(x0, y, c) + img.SetRGBA(x1, y, c) + } + } +} + +func drawLabel(img *image.RGBA, x, y int, text string, c color.RGBA, face font.Face) { + if text == "" || img == nil || face == nil { + return + } + bounds := img.Bounds() + if x < bounds.Min.X { + x = bounds.Min.X + } + if y < bounds.Min.Y { + y = bounds.Min.Y + } + ascent := face.Metrics().Ascent.Round() + descent := face.Metrics().Descent.Round() + baseline := y + ascent + if baseline+descent > bounds.Max.Y { + baseline = bounds.Max.Y - descent + } + + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(c), + Face: face, + Dot: fixed.P(x, baseline), + } + d.DrawString(text) +} + +func savePNG(filename string, img image.Image) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + return png.Encode(file, img) +} + +func promptDPIInit() (bool, string) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("\nInitialize DPI awareness? (y/n): ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "y", "yes": + result := deskact.InitDPIAwareness() + return true, fmt.Sprintf("%v", result) + case "n", "no": + return false, "skipped" + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} + +func drawAxes(img *image.RGBA, margin, canvasW, canvasH, minX, minY, maxX, maxY int) { + face := basicfont.Face7x13 + axisColor := color.RGBA{R: 200, G: 200, B: 200, A: 255} + tickColor := color.RGBA{R: 150, G: 150, B: 150, A: 255} + + for y := margin; y < canvasH; y++ { + img.Set(margin-1, y, axisColor) + img.Set(margin, y, axisColor) + } + + for x := margin; x < canvasW; x++ { + img.Set(x, margin-1, axisColor) + img.Set(x, margin, axisColor) + } + + contentW := maxX - minX + tickInterval := calculateTickInterval(contentW) + startTick := (minX / tickInterval) * tickInterval + if startTick < minX { + startTick += tickInterval + } + + for tick := startTick; tick <= maxX; tick += tickInterval { + x := tick - minX + margin + for ty := margin - 5; ty < margin; ty++ { + img.Set(x, ty, tickColor) + } + label := fmt.Sprintf("%d", tick) + labelW := font.MeasureString(face, label).Ceil() + drawText(img, x-labelW/2, margin-10, label, face, axisColor) + } + + contentH := maxY - minY + tickIntervalY := calculateTickInterval(contentH) + startTickY := (minY / tickIntervalY) * tickIntervalY + if startTickY < minY { + startTickY += tickIntervalY + } + + for tick := startTickY; tick <= maxY; tick += tickIntervalY { + y := tick - minY + margin + for tx := margin - 5; tx < margin; tx++ { + img.Set(tx, y, tickColor) + } + label := fmt.Sprintf("%d", tick) + labelW := font.MeasureString(face, label).Ceil() + drawText(img, margin-labelW-8, y+4, label, face, axisColor) + } + + originLabel := fmt.Sprintf("(%d,%d)", minX, minY) + drawText(img, 2, margin-10, originLabel, face, axisColor) +} + +func calculateTickInterval(size int) int { + if size <= 500 { + return 100 + } + if size <= 1000 { + return 200 + } + if size <= 2000 { + return 500 + } + if size <= 5000 { + return 1000 + } + return 2000 +} + +func drawText(img *image.RGBA, x, y int, text string, face font.Face, col color.Color) { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(col), + Face: face, + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, + } + d.DrawString(text) +} diff --git a/window/coords_darwin.go b/window/coords_darwin.go new file mode 100644 index 0000000..bc60cac --- /dev/null +++ b/window/coords_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin +// +build darwin + +package window + +func coordsArePhysical(options WindowOptions) bool { + return false +} diff --git a/window/coords_linux.go b/window/coords_linux.go new file mode 100644 index 0000000..0696250 --- /dev/null +++ b/window/coords_linux.go @@ -0,0 +1,8 @@ +//go:build linux +// +build linux + +package window + +func coordsArePhysical(options WindowOptions) bool { + return false +} diff --git a/window/coords_windows.go b/window/coords_windows.go new file mode 100644 index 0000000..cf951df --- /dev/null +++ b/window/coords_windows.go @@ -0,0 +1,8 @@ +//go:build windows +// +build windows + +package window + +func coordsArePhysical(options WindowOptions) bool { + return options.DPIAware +} diff --git a/window/list_windows_darwin.go b/window/list_windows_darwin.go new file mode 100644 index 0000000..797efae --- /dev/null +++ b/window/list_windows_darwin.go @@ -0,0 +1,227 @@ +//go:build darwin +// +build darwin + +package window + +/* +#cgo darwin LDFLAGS: -framework CoreGraphics -framework CoreFoundation +#include +#include +#include +#include + +typedef struct { + uint32_t windowID; + int32_t ownerPID; + int32_t layer; + int32_t isOnscreen; + double alpha; + int32_t x; + int32_t y; + int32_t w; + int32_t h; + char* title; + char* ownerName; +} WindowInfoC; + +typedef struct { + WindowInfoC* items; + int32_t count; +} WindowListC; + +static char* CopyCFString(CFStringRef str) { + if (!str) { + return NULL; + } + CFIndex length = CFStringGetLength(str); + CFIndex maxSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + char* buffer = (char*)calloc((size_t)maxSize, sizeof(char)); + if (!buffer) { + return NULL; + } + if (CFStringGetCString(str, buffer, maxSize, kCFStringEncodingUTF8)) { + return buffer; + } + free(buffer); + return NULL; +} + +static int32_t GetInt32(CFDictionaryRef dict, CFStringRef key, int32_t defValue) { + CFNumberRef num = (CFNumberRef)CFDictionaryGetValue(dict, key); + if (!num) { + return defValue; + } + int32_t value = defValue; + if (CFNumberGetValue(num, kCFNumberSInt32Type, &value)) { + return value; + } + return defValue; +} + +static double GetDouble(CFDictionaryRef dict, CFStringRef key, double defValue) { + CFNumberRef num = (CFNumberRef)CFDictionaryGetValue(dict, key); + if (!num) { + return defValue; + } + double value = defValue; + if (CFNumberGetValue(num, kCFNumberDoubleType, &value)) { + return value; + } + return defValue; +} + +static int32_t GetOnscreen(CFDictionaryRef dict) { + CFBooleanRef onScreen = (CFBooleanRef)CFDictionaryGetValue(dict, kCGWindowIsOnscreen); + if (!onScreen) { + return 0; + } + return CFBooleanGetValue(onScreen) ? 1 : 0; +} + +static WindowListC getWindowList(bool includeOffscreen, bool includeAllLayers) { + WindowListC list = {0}; + CGWindowListOption opts = kCGWindowListExcludeDesktopElements; + if (includeOffscreen) { + opts |= kCGWindowListOptionAll; + } else { + opts |= kCGWindowListOptionOnScreenOnly; + } + CFArrayRef infoList = CGWindowListCopyWindowInfo(opts, kCGNullWindowID); + if (!infoList) { + return list; + } + + CFIndex count = CFArrayGetCount(infoList); + if (count <= 0) { + CFRelease(infoList); + return list; + } + + WindowInfoC* items = (WindowInfoC*)calloc((size_t)count, sizeof(WindowInfoC)); + if (!items) { + CFRelease(infoList); + return list; + } + + int32_t outCount = 0; + for (CFIndex i = 0; i < count; i++) { + CFDictionaryRef dict = (CFDictionaryRef)CFArrayGetValueAtIndex(infoList, i); + if (!dict) { + continue; + } + + int32_t layer = GetInt32(dict, kCGWindowLayer, 0); + if (!includeAllLayers && layer != 0) { + continue; + } + + int32_t onScreen = GetOnscreen(dict); + if (!includeOffscreen && !onScreen) { + continue; + } + + CFDictionaryRef boundsDict = (CFDictionaryRef)CFDictionaryGetValue(dict, kCGWindowBounds); + if (!boundsDict) { + continue; + } + CGRect bounds; + if (!CGRectMakeWithDictionaryRepresentation(boundsDict, &bounds)) { + continue; + } + if (bounds.size.width <= 0 || bounds.size.height <= 0) { + continue; + } + + WindowInfoC* item = &items[outCount++]; + item->windowID = (uint32_t)GetInt32(dict, kCGWindowNumber, 0); + item->ownerPID = GetInt32(dict, kCGWindowOwnerPID, 0); + item->layer = layer; + item->isOnscreen = onScreen; + item->alpha = GetDouble(dict, kCGWindowAlpha, 1.0); + item->x = (int32_t)bounds.origin.x; + item->y = (int32_t)bounds.origin.y; + item->w = (int32_t)bounds.size.width; + item->h = (int32_t)bounds.size.height; + item->title = CopyCFString((CFStringRef)CFDictionaryGetValue(dict, kCGWindowName)); + item->ownerName = CopyCFString((CFStringRef)CFDictionaryGetValue(dict, kCGWindowOwnerName)); + } + + CFRelease(infoList); + + list.items = items; + list.count = outCount; + return list; +} + +static void freeWindowList(WindowListC list) { + if (!list.items) { + return; + } + for (int32_t i = 0; i < list.count; i++) { + free(list.items[i].title); + free(list.items[i].ownerName); + } + free(list.items); +} +*/ +import "C" + +import "unsafe" + +// DarwinPlatformInfo contains macOS-specific metadata. +type DarwinPlatformInfo struct { + WindowID uint32 + OwnerName string + Layer int32 + Alpha float64 + Onscreen bool +} + +// Platform returns the platform name. +func (d *DarwinPlatformInfo) Platform() string { + return "darwin" +} + +func listWindows(options WindowOptions) ([]WindowInfo, error) { + includeOffscreen := options.IncludeOffscreen || options.IncludeMinimized + list := C.getWindowList(C.bool(includeOffscreen), C.bool(options.IncludeAllLayers)) + defer C.freeWindowList(list) + + if list.count == 0 || list.items == nil { + return nil, nil + } + + count := int(list.count) + items := unsafe.Slice(list.items, count) + windows := make([]WindowInfo, 0, count) + + for i := 0; i < count; i++ { + item := items[i] + bounds := makeRect(int(item.x), int(item.y), int(item.w), int(item.h)) + if bounds.W <= 0 || bounds.H <= 0 { + continue + } + + title := C.GoString(item.title) + ownerName := C.GoString(item.ownerName) + onScreen := item.isOnscreen != 0 + + windows = append(windows, WindowInfo{ + ID: uint64(item.windowID), + PID: int(item.ownerPID), + Title: title, + Bounds: bounds, + IsVisible: onScreen, + IsMinimized: false, + platform: &DarwinPlatformInfo{ + WindowID: uint32(item.windowID), + OwnerName: ownerName, + Layer: item.layer, + Alpha: float64(item.alpha), + Onscreen: onScreen, + }, + }) + } + + return windows, nil +} diff --git a/window/list_windows_linux.go b/window/list_windows_linux.go new file mode 100644 index 0000000..99e16e4 --- /dev/null +++ b/window/list_windows_linux.go @@ -0,0 +1,8 @@ +//go:build linux +// +build linux + +package window + +func listWindows(options WindowOptions) ([]WindowInfo, error) { + return nil, errUnsupported() +} diff --git a/window/list_windows_windows.go b/window/list_windows_windows.go new file mode 100644 index 0000000..1b294a5 --- /dev/null +++ b/window/list_windows_windows.go @@ -0,0 +1,182 @@ +//go:build windows +// +build windows + +package window + +import ( + "unsafe" + + "github.com/PekingSpades/DeskAct/display" + "github.com/lxn/win" + "golang.org/x/sys/windows" +) + +const ( + dwmwaExtendedFrameBounds = 9 + dwmwaCloaked = 14 +) + +// WindowsPlatformInfo contains Windows-specific metadata. +type WindowsPlatformInfo struct { + HWND uint64 + ClassName string + Style uint32 + ExStyle uint32 + Cloaked bool +} + +// Platform returns the platform name. +func (w *WindowsPlatformInfo) Platform() string { + return "windows" +} + +type enumContext struct { + options WindowOptions + windows []WindowInfo +} + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + procGetWindowTextLenW = user32.NewProc("GetWindowTextLengthW") +) + +func listWindows(options WindowOptions) ([]WindowInfo, error) { + ctx := &enumContext{options: options} + callback := windows.NewCallback(enumWindowsProc) + if err := windows.EnumWindows(callback, unsafe.Pointer(ctx)); err != nil { + return nil, err + } + return ctx.windows, nil +} + +func enumWindowsProc(hwnd windows.HWND, lparam uintptr) uintptr { + ctx := (*enumContext)(unsafe.Pointer(lparam)) + info, ok := buildWindowInfo(hwnd, ctx.options) + if ok { + ctx.windows = append(ctx.windows, info) + } + return 1 +} + +func buildWindowInfo(hwnd windows.HWND, options WindowOptions) (WindowInfo, bool) { + if hwnd == 0 { + return WindowInfo{}, false + } + + isVisible := windows.IsWindowVisible(hwnd) + if !options.IncludeInvisible && !isVisible { + return WindowInfo{}, false + } + + isMinimized := win.IsIconic(win.HWND(hwnd)) + if !options.IncludeMinimized && isMinimized { + return WindowInfo{}, false + } + + exStyle := uint32(win.GetWindowLongPtr(win.HWND(hwnd), win.GWL_EXSTYLE)) + if !options.IncludeToolWindows && (exStyle&win.WS_EX_TOOLWINDOW) != 0 { + return WindowInfo{}, false + } + + cloaked := windowCloaked(hwnd) + if !options.IncludeCloaked && cloaked { + return WindowInfo{}, false + } + + bounds, ok := windowBounds(hwnd, options) + if !ok || bounds.W <= 0 || bounds.H <= 0 { + return WindowInfo{}, false + } + + title := windowTitle(hwnd) + pid := windowPID(hwnd) + className := windowClassName(hwnd) + style := uint32(win.GetWindowLongPtr(win.HWND(hwnd), win.GWL_STYLE)) + + return WindowInfo{ + ID: uint64(uintptr(hwnd)), + PID: int(pid), + Title: title, + Bounds: bounds, + IsVisible: isVisible && !cloaked, + IsMinimized: isMinimized, + platform: &WindowsPlatformInfo{ + HWND: uint64(uintptr(hwnd)), + ClassName: className, + Style: style, + ExStyle: exStyle, + Cloaked: cloaked, + }, + }, true +} + +func windowBounds(hwnd windows.HWND, options WindowOptions) (display.Rect, bool) { + if options.DPIAware { + if rect, ok := dwmExtendedFrameBounds(hwnd); ok { + return rect, true + } + } + return windowRect(hwnd) +} + +func windowRect(hwnd windows.HWND) (display.Rect, bool) { + var rect win.RECT + if !win.GetWindowRect(win.HWND(hwnd), &rect) { + return display.Rect{}, false + } + width := int(rect.Right - rect.Left) + height := int(rect.Bottom - rect.Top) + if width <= 0 || height <= 0 { + return display.Rect{}, false + } + return makeRect(int(rect.Left), int(rect.Top), width, height), true +} + +func dwmExtendedFrameBounds(hwnd windows.HWND) (display.Rect, bool) { + var rect win.RECT + err := windows.DwmGetWindowAttribute(hwnd, dwmwaExtendedFrameBounds, unsafe.Pointer(&rect), uint32(unsafe.Sizeof(rect))) + if err != nil { + return display.Rect{}, false + } + width := int(rect.Right - rect.Left) + height := int(rect.Bottom - rect.Top) + if width <= 0 || height <= 0 { + return display.Rect{}, false + } + return makeRect(int(rect.Left), int(rect.Top), width, height), true +} + +func windowCloaked(hwnd windows.HWND) bool { + var cloaked uint32 + err := windows.DwmGetWindowAttribute(hwnd, dwmwaCloaked, unsafe.Pointer(&cloaked), uint32(unsafe.Sizeof(cloaked))) + if err != nil { + return false + } + return cloaked != 0 +} + +func windowPID(hwnd windows.HWND) uint32 { + var pid uint32 + _, _ = windows.GetWindowThreadProcessId(hwnd, &pid) + return pid +} + +func windowClassName(hwnd windows.HWND) string { + buf := make([]uint16, 256) + n, _ := windows.GetClassName(hwnd, &buf[0], int32(len(buf))) + if n == 0 { + return "" + } + return windows.UTF16ToString(buf[:n]) +} + +func windowTitle(hwnd windows.HWND) string { + length, _, _ := procGetWindowTextLenW.Call(uintptr(hwnd)) + if length == 0 { + return "" + } + buf := make([]uint16, int(length)+1) + _, _, _ = procGetWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + return windows.UTF16ToString(buf) +} diff --git a/window/regions.go b/window/regions.go new file mode 100644 index 0000000..b303406 --- /dev/null +++ b/window/regions.go @@ -0,0 +1,114 @@ +package window + +import ( + "math" + + "github.com/PekingSpades/DeskAct/display" +) + +func windowRegions(bounds display.Rect, displays []*display.Display, coordsPhysical bool) []DisplayRegion { + regions := make([]DisplayRegion, 0, len(displays)) + for _, d := range displays { + intersect, ok := intersectRect(bounds, d.Origin()) + if !ok { + continue + } + phys := physicalRect(intersect, d, coordsPhysical) + if phys.W <= 0 || phys.H <= 0 { + continue + } + regions = append(regions, DisplayRegion{ + DisplayIndex: d.Index(), + DisplayID: d.ID(), + PhysicalRect: phys, + }) + } + return regions +} + +func intersectRect(a, b display.Rect) (display.Rect, bool) { + x0 := maxInt(a.X, b.X) + y0 := maxInt(a.Y, b.Y) + x1 := minInt(a.X+a.W, b.X+b.W) + y1 := minInt(a.Y+a.H, b.Y+b.H) + if x1 <= x0 || y1 <= y0 { + return display.Rect{}, false + } + return makeRect(x0, y0, x1-x0, y1-y0), true +} + +func physicalRect(virt display.Rect, d *display.Display, coordsPhysical bool) display.Rect { + origin := d.Origin() + relX := virt.X - origin.X + relY := virt.Y - origin.Y + relW := virt.W + relH := virt.H + + scale := d.Scale() + if scale <= 0 { + scale = 1 + } + + physX, physY, physW, physH := relX, relY, relW, relH + if !coordsPhysical { + physX0 := scaleRound(relX, scale) + physY0 := scaleRound(relY, scale) + physX1 := scaleRound(relX+relW, scale) + physY1 := scaleRound(relY+relH, scale) + physX = physX0 + physY = physY0 + physW = physX1 - physX0 + physH = physY1 - physY0 + } + + size := d.Size() + if physX < 0 { + physW += physX + physX = 0 + } + if physY < 0 { + physH += physY + physY = 0 + } + if physW <= 0 || physH <= 0 { + return display.Rect{} + } + if physX+physW > size.W { + physW = size.W - physX + } + if physY+physH > size.H { + physH = size.H - physY + } + if physW <= 0 || physH <= 0 { + return display.Rect{} + } + return makeRect(physX, physY, physW, physH) +} + +func scaleRound(value int, scale float64) int { + if scale == 1 { + return value + } + return int(math.Round(float64(value) * scale)) +} + +func makeRect(x, y, w, h int) display.Rect { + return display.Rect{ + Point: display.Point{X: x, Y: y}, + Size: display.Size{W: w, H: h}, + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/window/regions_other.go b/window/regions_other.go new file mode 100644 index 0000000..4a6bb9e --- /dev/null +++ b/window/regions_other.go @@ -0,0 +1,13 @@ +//go:build !windows +// +build !windows + +package window + +import "github.com/PekingSpades/DeskAct/display" + +func attachDisplayRegions(windows []WindowInfo, displays []*display.Display, options WindowOptions) { + coordsPhysical := coordsArePhysical(options) + for i := range windows { + windows[i].DisplayRegions = windowRegions(windows[i].Bounds, displays, coordsPhysical) + } +} diff --git a/window/regions_windows.go b/window/regions_windows.go new file mode 100644 index 0000000..33d4819 --- /dev/null +++ b/window/regions_windows.go @@ -0,0 +1,91 @@ +//go:build windows +// +build windows + +package window + +import ( + "github.com/PekingSpades/DeskAct/display" + "golang.org/x/sys/windows" + "runtime" +) + +func attachDisplayRegions(windows []WindowInfo, displays []*display.Display, options WindowOptions) { + coordsPhysical := coordsArePhysical(options) + for i := range windows { + if !coordsPhysical { + if physBounds, ok := windowPhysicalBounds(windows[i]); ok { + windows[i].DisplayRegions = windowRegionsFromPhysical(physBounds, displays) + continue + } + } + windows[i].DisplayRegions = windowRegions(windows[i].Bounds, displays, coordsPhysical) + } +} + +func windowRegionsFromPhysical(bounds display.Rect, displays []*display.Display) []DisplayRegion { + regions := make([]DisplayRegion, 0, len(displays)) + for _, d := range displays { + pi, ok := d.GetPlatformInfo().(*display.WindowsPlatformInfo) + if !ok { + continue + } + physDisplay := makeRect(pi.PhysicalOrigin.X, pi.PhysicalOrigin.Y, d.Size().W, d.Size().H) + intersect, ok := intersectRect(bounds, physDisplay) + if !ok { + continue + } + relPhys := makeRect( + intersect.X-pi.PhysicalOrigin.X, + intersect.Y-pi.PhysicalOrigin.Y, + intersect.W, + intersect.H, + ) + if relPhys.W <= 0 || relPhys.H <= 0 { + continue + } + regions = append(regions, DisplayRegion{ + DisplayIndex: d.Index(), + DisplayID: d.ID(), + PhysicalRect: relPhys, + }) + } + return regions +} + +func windowPhysicalBounds(info WindowInfo) (display.Rect, bool) { + pi, ok := info.platform.(*WindowsPlatformInfo) + if !ok { + return display.Rect{}, false + } + hwnd := windows.HWND(uintptr(pi.HWND)) + return physicalWindowRect(hwnd) +} + +func physicalWindowRect(hwnd windows.HWND) (display.Rect, bool) { + // Thread DPI awareness is thread-scoped; lock to keep set/read/restore on the same OS thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + prev := setThreadDPIAwarenessContext(dpiAwarenessContextPerMonitorAwareV2) + if prev != 0 { + defer setThreadDPIAwarenessContext(prev) + } + if rect, ok := dwmExtendedFrameBounds(hwnd); ok { + return rect, true + } + return windowRect(hwnd) +} + +const dpiAwarenessContextPerMonitorAwareV2 = ^uintptr(3) // -4 + +var ( + user32DPI = windows.NewLazySystemDLL("user32.dll") + procSetThreadDpiAwarenessContext = user32DPI.NewProc("SetThreadDpiAwarenessContext") +) + +func setThreadDPIAwarenessContext(ctx uintptr) uintptr { + if procSetThreadDpiAwarenessContext.Find() != nil { + return 0 + } + prev, _, _ := procSetThreadDpiAwarenessContext.Call(ctx) + return prev +} diff --git a/window/window.go b/window/window.go new file mode 100644 index 0000000..726da2b --- /dev/null +++ b/window/window.go @@ -0,0 +1,90 @@ +package window + +import ( + "errors" + + "github.com/PekingSpades/DeskAct/display" +) + +var ErrUnsupported = errors.New("window listing is not supported on this platform") + +// WindowOptions defines options for listing windows. +type WindowOptions struct { + // DPIAware controls whether coordinates are returned in physical pixels on Windows. + // On macOS this flag is ignored because display coordinates are already virtual. + // The caller should set this to match the current process DPI awareness. + DPIAware bool + + // IncludeMinimized includes minimized windows when supported. + IncludeMinimized bool + // IncludeOffscreen includes windows not currently visible on screen (macOS). + IncludeOffscreen bool + // IncludeInvisible includes windows that are not visible (Windows). + IncludeInvisible bool + // IncludeToolWindows includes tool windows (Windows). + IncludeToolWindows bool + // IncludeCloaked includes cloaked windows such as those on other desktops (Windows). + IncludeCloaked bool + // IncludeAllLayers includes non-layer-0 windows (macOS). + IncludeAllLayers bool +} + +// DefaultWindowOptions returns the default options for window listing. +func DefaultWindowOptions() WindowOptions { + return WindowOptions{ + DPIAware: display.DefaultDisplayOptions().DPIAware, + } +} + +// DisplayRegion describes the window's intersection with a display. +type DisplayRegion struct { + DisplayIndex int + DisplayID int + // PhysicalRect is in physical pixels relative to the display origin. + PhysicalRect display.Rect +} + +// PlatformInfo provides platform-specific window metadata. +type PlatformInfo interface { + Platform() string +} + +// WindowInfo describes a desktop window and its location. +type WindowInfo struct { + ID uint64 + PID int + Title string + Bounds display.Rect + IsVisible bool + IsMinimized bool + DisplayRegions []DisplayRegion + platform PlatformInfo +} + +// GetPlatformInfo returns platform-specific metadata for the window. +func (w WindowInfo) GetPlatformInfo() PlatformInfo { + return w.platform +} + +// List returns the current desktop windows and their locations. +func List(options WindowOptions) ([]WindowInfo, error) { + windows, err := listWindows(options) + if err != nil { + return nil, err + } + if len(windows) == 0 { + return windows, nil + } + + displays := display.AllDisplays(display.DisplayOptions{DPIAware: options.DPIAware}) + if len(displays) == 0 { + return windows, nil + } + + attachDisplayRegions(windows, displays, options) + return windows, nil +} + +func errUnsupported() error { + return ErrUnsupported +} diff --git a/window_exports.go b/window_exports.go new file mode 100644 index 0000000..65702e1 --- /dev/null +++ b/window_exports.go @@ -0,0 +1,18 @@ +package deskact + +import "github.com/PekingSpades/DeskAct/window" + +type WindowOptions = window.WindowOptions +type WindowInfo = window.WindowInfo +type WindowDisplayRegion = window.DisplayRegion +type WindowPlatformInfo = window.PlatformInfo + +var ErrWindowUnsupported = window.ErrUnsupported + +func DefaultWindowOptions() WindowOptions { + return window.DefaultWindowOptions() +} + +func ListWindows(options WindowOptions) ([]WindowInfo, error) { + return window.List(options) +} From 93daa8307a72d05aaa4c286f11d8786eba07816e Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:13:54 +0800 Subject: [PATCH 12/37] feat(apps): add app discovery and icon export example - add apps package with AppInfo, desktop/installed listing, and platform-specific icon extraction - expose AppInfo and app listing helpers from core package - add apps example to list apps, save icons, and generate Markdown/PDF summaries - build new apps/window examples in CI and ignore output artifacts --- .github/workflows/build-examples.yml | 4 + .gitignore | 8 +- apps/apps.go | 46 + apps/apps_darwin.go | 314 +++++++ apps/apps_darwin_other.go | 11 + apps/apps_other.go | 11 + apps/apps_windows.go | 1282 ++++++++++++++++++++++++++ apps_exports.go | 15 + examples/apps/main.go | 1045 +++++++++++++++++++++ 9 files changed, 2735 insertions(+), 1 deletion(-) create mode 100644 apps/apps.go create mode 100644 apps/apps_darwin.go create mode 100644 apps/apps_darwin_other.go create mode 100644 apps/apps_other.go create mode 100644 apps/apps_windows.go create mode 100644 apps_exports.go create mode 100644 examples/apps/main.go diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index bc533ed..7ac8cdf 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -65,6 +65,8 @@ jobs: go build -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture go build -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display go build -v -o "mouse-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/mouse + go build -v -o "window-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/window + go build -v -o "apps-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/apps - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -74,4 +76,6 @@ jobs: capture-${{ matrix.goos }}-${{ matrix.goarch }}* display-${{ matrix.goos }}-${{ matrix.goarch }}* mouse-${{ matrix.goos }}-${{ matrix.goarch }}* + window-${{ matrix.goos }}-${{ matrix.goarch }}* + apps-${{ matrix.goos }}-${{ matrix.goarch }}* retention-days: 30 diff --git a/.gitignore b/.gitignore index 2c0d931..3d8fbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,10 @@ go.work.sum .deskact-tester/* .refer -.refer/* \ No newline at end of file +.refer/* + +.apps +.apps/* + +display_*.png +window_*.png \ No newline at end of file diff --git a/apps/apps.go b/apps/apps.go new file mode 100644 index 0000000..440b91b --- /dev/null +++ b/apps/apps.go @@ -0,0 +1,46 @@ +package apps + +import ( + "errors" + "image" + "image/draw" + "sort" + "strings" +) + +// AppInfo describes an application and its icon. +type AppInfo struct { + Name string + Path string + Icon *image.RGBA +} + +var errUnsupported = errors.New("apps does not support your platform") + +// ErrIconNotFound is returned when an app icon cannot be resolved. +var ErrIconNotFound = errors.New("icon source not found") + +func joinErrors(errs []error) error { + if len(errs) == 0 { + return nil + } + return errors.Join(errs...) +} + +func toRGBA(src image.Image) *image.RGBA { + b := src.Bounds() + dst := image.NewRGBA(b) + draw.Draw(dst, b, src, b.Min, draw.Src) + return dst +} + +func sortApps(apps []AppInfo) { + sort.SliceStable(apps, func(i, j int) bool { + ai := strings.ToLower(apps[i].Name) + aj := strings.ToLower(apps[j].Name) + if ai == aj { + return strings.ToLower(apps[i].Path) < strings.ToLower(apps[j].Path) + } + return ai < aj + }) +} diff --git a/apps/apps_darwin.go b/apps/apps_darwin.go new file mode 100644 index 0000000..8a9375c --- /dev/null +++ b/apps/apps_darwin.go @@ -0,0 +1,314 @@ +//go:build darwin && cgo + +package apps + +/* +#cgo CFLAGS: -x objective-c -Wno-deprecated-declarations +#cgo LDFLAGS: -framework Cocoa -framework CoreFoundation -framework CoreGraphics +#include +#include +#include +#include +#include + +static char *resolve_alias_path(const char *path) { + if (!path) { + return NULL; + } + CFURLRef url = CFURLCreateFromFileSystemRepresentation(kCFAllocatorDefault, (const UInt8 *)path, (CFIndex)strlen(path), false); + if (!url) { + return NULL; + } + Boolean wasAliased = false; + CFErrorRef error = NULL; + CFURLRef resolved = CFURLCreateByResolvingAliasFile(kCFAllocatorDefault, url, 0, &wasAliased, &error); + CFRelease(url); + if (!resolved) { + if (error) { + CFRelease(error); + } + return NULL; + } + if (!wasAliased) { + CFRelease(resolved); + return NULL; + } + CFStringRef cfPath = CFURLCopyFileSystemPath(resolved, kCFURLPOSIXPathStyle); + CFRelease(resolved); + if (!cfPath) { + return NULL; + } + CFIndex maxSize = CFStringGetMaximumSizeForEncoding(CFStringGetLength(cfPath), kCFStringEncodingUTF8) + 1; + char *buffer = (char *)malloc(maxSize); + if (!buffer) { + CFRelease(cfPath); + return NULL; + } + if (!CFStringGetCString(cfPath, buffer, maxSize, kCFStringEncodingUTF8)) { + free(buffer); + buffer = NULL; + } + CFRelease(cfPath); + return buffer; +} + +static unsigned char *icon_rgba_for_path(const char *path, int *width, int *height) { + @autoreleasepool { + if (!path) { + return NULL; + } + NSString *nsPath = [NSString stringWithUTF8String:path]; + if (!nsPath) { + return NULL; + } + NSImage *image = [[NSWorkspace sharedWorkspace] iconForFile:nsPath]; + if (!image) { + return NULL; + } + + NSInteger bestWidth = 0; + NSInteger bestHeight = 0; + for (NSImageRep *rep in [image representations]) { + NSInteger w = [rep pixelsWide]; + NSInteger h = [rep pixelsHigh]; + if (w > bestWidth && h > 0) { + bestWidth = w; + bestHeight = h; + } + } + if (bestWidth <= 0 || bestHeight <= 0) { + NSSize size = [image size]; + bestWidth = (NSInteger)size.width; + bestHeight = (NSInteger)size.height; + } + if (bestWidth <= 0 || bestHeight <= 0) { + return NULL; + } + + NSRect rect = NSMakeRect(0, 0, bestWidth, bestHeight); + CGImageRef cgImage = [image CGImageForProposedRect:&rect context:nil hints:nil]; + if (!cgImage) { + return NULL; + } + CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + if (!cs) { + return NULL; + } + + size_t w = (size_t)bestWidth; + size_t h = (size_t)bestHeight; + size_t bytesPerRow = w * 4; + unsigned char *data = (unsigned char *)malloc(bytesPerRow * h); + if (!data) { + CGColorSpaceRelease(cs); + return NULL; + } + + CGContextRef ctx = CGBitmapContextCreate(data, w, h, 8, bytesPerRow, cs, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); + if (!ctx) { + free(data); + CGColorSpaceRelease(cs); + return NULL; + } + CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), cgImage); + CGContextRelease(ctx); + CGColorSpaceRelease(cs); + + *width = (int)w; + *height = (int)h; + return data; + } +} + +static void free_icon_data(void *data) { + free(data); +} +*/ +import "C" + +import ( + "errors" + "image" + "io/fs" + "os" + "path/filepath" + "strings" + "unsafe" +) + +func DesktopApps() ([]AppInfo, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + desktop := filepath.Join(home, "Desktop") + entries, err := os.ReadDir(desktop) + if err != nil { + return nil, err + } + + var apps []AppInfo + var errs []error + for _, entry := range entries { + name := entry.Name() + path := filepath.Join(desktop, name) + if entry.IsDir() { + if strings.HasSuffix(strings.ToLower(name), ".app") { + info, infoErr := appInfoForPath(path) + if infoErr != nil { + errs = append(errs, infoErr) + } + apps = append(apps, info) + } + continue + } + + resolved, resolveErr := resolveAlias(path) + if resolveErr != nil { + errs = append(errs, resolveErr) + } + if resolved == "" { + continue + } + if !strings.HasSuffix(strings.ToLower(resolved), ".app") { + continue + } + info, infoErr := appInfoForPath(resolved) + if infoErr != nil { + errs = append(errs, infoErr) + } + apps = append(apps, info) + } + + sortApps(apps) + return apps, joinErrors(errs) +} + +func InstalledApps() ([]AppInfo, error) { + roots := []string{ + "/Applications", + "/Applications/Utilities", + "/System/Applications", + "/System/Applications/Utilities", + } + home, err := os.UserHomeDir() + if err == nil { + roots = append(roots, filepath.Join(home, "Applications")) + } + + seen := make(map[string]struct{}) + var apps []AppInfo + var errs []error + for _, root := range roots { + bundles, walkErr := findAppBundles(root, 4) + if walkErr != nil { + errs = append(errs, walkErr) + continue + } + for _, bundle := range bundles { + key := strings.ToLower(bundle) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + info, infoErr := appInfoForPath(bundle) + if infoErr != nil { + errs = append(errs, infoErr) + } + apps = append(apps, info) + } + } + + sortApps(apps) + return apps, joinErrors(errs) +} + +func appInfoForPath(path string) (AppInfo, error) { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + icon, err := iconForPath(path) + return AppInfo{ + Name: name, + Path: path, + Icon: icon, + }, err +} + +func resolveAlias(path string) (string, error) { + cpath := C.CString(path) + defer C.free(unsafe.Pointer(cpath)) + resolved := C.resolve_alias_path(cpath) + if resolved == nil { + return "", nil + } + defer C.free(unsafe.Pointer(resolved)) + return C.GoString(resolved), nil +} + +func iconForPath(path string) (*image.RGBA, error) { + cpath := C.CString(path) + defer C.free(unsafe.Pointer(cpath)) + var width C.int + var height C.int + data := C.icon_rgba_for_path(cpath, &width, &height) + if data == nil || width <= 0 || height <= 0 { + return nil, ErrIconNotFound + } + defer C.free_icon_data(unsafe.Pointer(data)) + + w := int(width) + h := int(height) + size := w * h * 4 + src := unsafe.Slice((*byte)(unsafe.Pointer(data)), size) + pix := make([]byte, size) + copy(pix, src) + + return &image.RGBA{ + Pix: pix, + Stride: w * 4, + Rect: image.Rect(0, 0, w, h), + }, nil +} + +func findAppBundles(root string, maxDepth int) ([]string, error) { + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if !info.IsDir() { + return nil, nil + } + + root = filepath.Clean(root) + var bundles []string + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".app") { + bundles = append(bundles, path) + return filepath.SkipDir + } + if d.IsDir() && relativeDepth(root, path) >= maxDepth { + return filepath.SkipDir + } + return nil + }) + if err != nil { + return bundles, err + } + return bundles, nil +} + +func relativeDepth(root, path string) int { + rel, err := filepath.Rel(root, path) + if err != nil { + return 0 + } + if rel == "." { + return 0 + } + return strings.Count(rel, string(os.PathSeparator)) + 1 +} diff --git a/apps/apps_darwin_other.go b/apps/apps_darwin_other.go new file mode 100644 index 0000000..27ca677 --- /dev/null +++ b/apps/apps_darwin_other.go @@ -0,0 +1,11 @@ +//go:build darwin && !cgo + +package apps + +func DesktopApps() ([]AppInfo, error) { + return nil, errUnsupported +} + +func InstalledApps() ([]AppInfo, error) { + return nil, errUnsupported +} diff --git a/apps/apps_other.go b/apps/apps_other.go new file mode 100644 index 0000000..287b4bc --- /dev/null +++ b/apps/apps_other.go @@ -0,0 +1,11 @@ +//go:build !windows && !darwin + +package apps + +func DesktopApps() ([]AppInfo, error) { + return nil, errUnsupported +} + +func InstalledApps() ([]AppInfo, error) { + return nil, errUnsupported +} diff --git a/apps/apps_windows.go b/apps/apps_windows.go new file mode 100644 index 0000000..0b04293 --- /dev/null +++ b/apps/apps_windows.go @@ -0,0 +1,1282 @@ +//go:build windows + +package apps + +import ( + "errors" + "image" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "unsafe" + + "github.com/lxn/win" + xwindows "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + slgpRawPath = 0x0004 + stgmRead = 0x00000000 + iconRequestSize = 256 + rpcEChangedMode = 0x80010106 + uninstallKeyPath = `Software\Microsoft\Windows\CurrentVersion\Uninstall` + uninstallKeyPath32 = `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall` + shellAppsFolder = "shell:AppsFolder\\" + + shcontfFolders = 0x20 + shcontfNonFolders = 0x40 + + sigdnNormalDisplay = 0x00000000 + sigdnDesktopAbsoluteParsing = 0x80028000 +) + +var ( + clsidShellLink = win.CLSID{0x00021401, 0x0000, 0x0000, [8]byte{0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} + iidIShellLinkW = win.IID{0x000214F9, 0x0000, 0x0000, [8]byte{0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} + iidIPersistFile = win.IID{0x0000010B, 0x0000, 0x0000, [8]byte{0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} + iidIShellFolder = win.IID{0x000214E6, 0x0000, 0x0000, [8]byte{0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} + shell32DLL = xwindows.NewLazySystemDLL("shell32.dll") + procSHBindToObject = shell32DLL.NewProc("SHBindToObject") + procSHGetNameFromIDList = shell32DLL.NewProc("SHGetNameFromIDList") + procILCombine = shell32DLL.NewProc("ILCombine") +) + +var ( + installerNameKeywords = []string{ + "setup", + "installer", + "install", + "uninstall", + "unins", + "update", + "updater", + "patch", + "hotfix", + "upgrade", + "repair", + "driver", + "drv", + "bootstrap", + "bootstrapper", + } + installerArgKeywords = []string{ + ".msi", + ".msix", + ".appx", + ".appxbundle", + " /i", + " /x", + "/uninstall", + "/repair", + "/update", + " install", + " uninstall", + " setup", + } +) + +type IShellLinkW struct { + LpVtbl *IShellLinkWVtbl +} + +type IShellLinkWVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + GetPath uintptr + GetIDList uintptr + SetIDList uintptr + GetDescription uintptr + SetDescription uintptr + GetWorkingDirectory uintptr + SetWorkingDirectory uintptr + GetArguments uintptr + SetArguments uintptr + GetHotkey uintptr + SetHotkey uintptr + GetShowCmd uintptr + SetShowCmd uintptr + GetIconLocation uintptr + SetIconLocation uintptr + SetRelativePath uintptr + Resolve uintptr + SetPath uintptr +} + +type IPersistFile struct { + LpVtbl *IPersistFileVtbl +} + +type IPersistFileVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + GetClassID uintptr + IsDirty uintptr + Load uintptr + Save uintptr + SaveCompleted uintptr + GetCurFile uintptr +} + +type IShellFolder struct { + LpVtbl *IShellFolderVtbl +} + +type IShellFolderVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + ParseDisplayName uintptr + EnumObjects uintptr + BindToObject uintptr + BindToStorage uintptr + CompareIDs uintptr + CreateViewObject uintptr + GetAttributesOf uintptr + GetUIObjectOf uintptr + GetDisplayNameOf uintptr + SetNameOf uintptr +} + +type IEnumIDList struct { + LpVtbl *IEnumIDListVtbl +} + +type IEnumIDListVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + Next uintptr + Skip uintptr + Reset uintptr + Clone uintptr +} + +func (sl *IShellLinkW) QueryInterface(riid *win.IID, ppvObject *unsafe.Pointer) win.HRESULT { + ret, _, _ := syscall.SyscallN(sl.LpVtbl.QueryInterface, + uintptr(unsafe.Pointer(sl)), + uintptr(unsafe.Pointer(riid)), + uintptr(unsafe.Pointer(ppvObject)), + ) + return win.HRESULT(ret) +} + +func (sl *IShellLinkW) Release() uint32 { + ret, _, _ := syscall.SyscallN(sl.LpVtbl.Release, + uintptr(unsafe.Pointer(sl)), + ) + return uint32(ret) +} + +func (sl *IShellLinkW) GetPath(pszFile *uint16, cchMaxPath int, pfd *xwindows.Win32finddata, fFlags uint32) win.HRESULT { + ret, _, _ := syscall.SyscallN(sl.LpVtbl.GetPath, + uintptr(unsafe.Pointer(sl)), + uintptr(unsafe.Pointer(pszFile)), + uintptr(cchMaxPath), + uintptr(unsafe.Pointer(pfd)), + uintptr(fFlags), + ) + return win.HRESULT(ret) +} + +func (sl *IShellLinkW) GetIconLocation(pszIconPath *uint16, cchIconPath int, piIcon *int32) win.HRESULT { + ret, _, _ := syscall.SyscallN(sl.LpVtbl.GetIconLocation, + uintptr(unsafe.Pointer(sl)), + uintptr(unsafe.Pointer(pszIconPath)), + uintptr(cchIconPath), + uintptr(unsafe.Pointer(piIcon)), + ) + return win.HRESULT(ret) +} + +func (sl *IShellLinkW) GetArguments(pszArgs *uint16, cchMaxPath int) win.HRESULT { + ret, _, _ := syscall.SyscallN(sl.LpVtbl.GetArguments, + uintptr(unsafe.Pointer(sl)), + uintptr(unsafe.Pointer(pszArgs)), + uintptr(cchMaxPath), + ) + return win.HRESULT(ret) +} + +func (pf *IPersistFile) Release() uint32 { + ret, _, _ := syscall.SyscallN(pf.LpVtbl.Release, + uintptr(unsafe.Pointer(pf)), + ) + return uint32(ret) +} + +func (pf *IPersistFile) Load(pszFileName *uint16, mode uint32) win.HRESULT { + ret, _, _ := syscall.SyscallN(pf.LpVtbl.Load, + uintptr(unsafe.Pointer(pf)), + uintptr(unsafe.Pointer(pszFileName)), + uintptr(mode), + ) + return win.HRESULT(ret) +} + +func (sf *IShellFolder) Release() uint32 { + ret, _, _ := syscall.SyscallN(sf.LpVtbl.Release, + uintptr(unsafe.Pointer(sf)), + ) + return uint32(ret) +} + +func (sf *IShellFolder) EnumObjects(hwnd win.HWND, flags uint32, ppenum **IEnumIDList) win.HRESULT { + ret, _, _ := syscall.SyscallN(sf.LpVtbl.EnumObjects, + uintptr(unsafe.Pointer(sf)), + uintptr(hwnd), + uintptr(flags), + uintptr(unsafe.Pointer(ppenum)), + ) + return win.HRESULT(ret) +} + +func (enum *IEnumIDList) Release() uint32 { + ret, _, _ := syscall.SyscallN(enum.LpVtbl.Release, + uintptr(unsafe.Pointer(enum)), + ) + return uint32(ret) +} + +func (enum *IEnumIDList) Next(celt uint32, rgelt *uintptr, fetched *uint32) win.HRESULT { + ret, _, _ := syscall.SyscallN(enum.LpVtbl.Next, + uintptr(unsafe.Pointer(enum)), + uintptr(celt), + uintptr(unsafe.Pointer(rgelt)), + uintptr(unsafe.Pointer(fetched)), + ) + return win.HRESULT(ret) +} + +func DesktopApps() ([]AppInfo, error) { + desktops, dirErr := desktopDirectories() + if len(desktops) == 0 { + return nil, dirErr + } + + var apps []AppInfo + var errs []error + if dirErr != nil { + errs = append(errs, dirErr) + } + + seenNames := make(map[string]struct{}) + for _, desktop := range desktops { + entries, err := os.ReadDir(desktop) + if err != nil { + errs = append(errs, err) + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + lowerName := strings.ToLower(name) + if _, exists := seenNames[lowerName]; exists { + continue + } + + ext := strings.ToLower(filepath.Ext(name)) + fullPath := filepath.Join(desktop, name) + switch ext { + case ".lnk": + info, infoErr := appFromShortcut(fullPath) + if errors.Is(infoErr, errSkipEntry) { + continue + } + if infoErr != nil { + errs = append(errs, infoErr) + } + if info.Name != "" { + apps = append(apps, info) + seenNames[lowerName] = struct{}{} + } + case ".exe": + if shouldSkipDesktopExe(fullPath) { + continue + } + info, infoErr := appFromExe(fullPath) + if infoErr != nil { + errs = append(errs, infoErr) + } + if info.Name != "" { + apps = append(apps, info) + seenNames[lowerName] = struct{}{} + } + } + } + } + + sortApps(apps) + return apps, joinErrors(errs) +} + +func InstalledApps() ([]AppInfo, error) { + roots := []registry.Key{ + registry.CURRENT_USER, + registry.LOCAL_MACHINE, + } + subPaths := []string{ + uninstallKeyPath, + uninstallKeyPath32, + } + + seen := make(map[string]struct{}) + var apps []AppInfo + var errs []error + for _, root := range roots { + for _, subPath := range subPaths { + items, err := appsFromRegistry(root, subPath) + if err != nil { + errs = append(errs, err) + } + for _, item := range items { + key := appKey(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + apps = append(apps, item) + } + } + } + + appsFolderItems, folderErr := appsFromAppsFolder(true) + if folderErr != nil { + errs = append(errs, folderErr) + } + for _, item := range appsFolderItems { + key := appKey(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + apps = append(apps, item) + } + + sortApps(apps) + return apps, joinErrors(errs) +} + +func appFromExe(path string) (AppInfo, error) { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + icon, err := iconFromFile(path, 0, false) + return AppInfo{ + Name: name, + Path: path, + Icon: icon, + }, err +} + +func appFromShortcut(path string) (AppInfo, error) { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + target, iconPath, iconIndex, args, resolveErr := resolveShortcut(path) + if target == "" { + target = path + } + + if appsFolderPath := extractAppsFolderPath(target, args); appsFolderPath != "" { + info, infoErr := appInfoFromShellItemPath(appsFolderPath) + if info.Name == "" { + info.Name = name + } + if info.Path == "" { + info.Path = appsFolderPath + } + if info.Icon == nil { + source := iconPath + if source == "" { + source = target + } + source = resolveRelativePath(normalizePath(source), path) + if source != "" { + fallback, fallbackErr := iconFromFile(source, iconIndex, iconPath != "") + if fallbackErr == nil { + info.Icon = fallback + infoErr = nil + } else if infoErr != nil { + infoErr = joinErrors([]error{infoErr, fallbackErr}) + } else { + infoErr = fallbackErr + } + } + } + + if resolveErr != nil && infoErr != nil { + return info, joinErrors([]error{resolveErr, infoErr}) + } + if resolveErr != nil { + return info, resolveErr + } + return info, infoErr + } + + if isInstallerTarget(target, args) { + return AppInfo{}, errSkipEntry + } + + source := iconPath + if source == "" { + source = target + } + source = resolveRelativePath(normalizePath(source), path) + + icon, iconErr := iconFromFile(source, iconIndex, iconPath != "") + if iconErr != nil && source != path { + fallback, fallbackErr := iconFromFile(path, 0, false) + if fallbackErr == nil { + icon = fallback + iconErr = nil + } else { + iconErr = joinErrors([]error{iconErr, fallbackErr}) + } + } + + info := AppInfo{ + Name: name, + Path: target, + Icon: icon, + } + + if resolveErr != nil && iconErr != nil { + return info, joinErrors([]error{resolveErr, iconErr}) + } + if resolveErr != nil { + return info, resolveErr + } + return info, iconErr +} + +func appsFromRegistry(root registry.Key, path string) ([]AppInfo, error) { + key, err := registry.OpenKey(root, path, registry.READ) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer key.Close() + + names, err := key.ReadSubKeyNames(-1) + if err != nil { + return nil, err + } + + var apps []AppInfo + var errs []error + for _, name := range names { + sub, err := registry.OpenKey(key, name, registry.READ) + if err != nil { + errs = append(errs, err) + continue + } + info, infoErr := appFromUninstallKey(sub) + sub.Close() + if errors.Is(infoErr, errSkipEntry) { + continue + } + if infoErr != nil { + errs = append(errs, infoErr) + } + if info.Name == "" { + continue + } + apps = append(apps, info) + } + + return apps, joinErrors(errs) +} + +var errSkipEntry = errors.New("skip entry") + +func appFromUninstallKey(key registry.Key) (AppInfo, error) { + displayName, _, err := key.GetStringValue("DisplayName") + if err != nil || strings.TrimSpace(displayName) == "" { + return AppInfo{}, errSkipEntry + } + if isSystemComponent(key) { + return AppInfo{}, errSkipEntry + } + + displayName = strings.TrimSpace(displayName) + displayIcon, _, _ := key.GetStringValue("DisplayIcon") + installLocation, _, _ := key.GetStringValue("InstallLocation") + uninstallString, _, _ := key.GetStringValue("UninstallString") + quietUninstallString, _, _ := key.GetStringValue("QuietUninstallString") + modifyPath, _, _ := key.GetStringValue("ModifyPath") + + iconPath, iconIndex, hasIndex := parseIconLocation(displayIcon) + iconPath = normalizePath(iconPath) + installLocation = normalizePath(installLocation) + + appPath := iconPath + if appPath == "" { + appPath = normalizePath(extractExeFromCommandLine(uninstallString)) + if appPath == "" { + appPath = normalizePath(extractExeFromCommandLine(quietUninstallString)) + } + if appPath == "" { + appPath = normalizePath(extractExeFromCommandLine(modifyPath)) + } + } + if appPath == "" { + appPath = pickExeFromInstallLocation(installLocation, displayName) + if appPath == "" { + appPath = installLocation + } + } + + var icon *image.RGBA + var iconErr error + if iconPath != "" { + icon, iconErr = iconFromFile(iconPath, iconIndex, hasIndex) + } else if appPath != "" { + icon, iconErr = iconFromFile(appPath, 0, false) + } else { + iconErr = ErrIconNotFound + } + + return AppInfo{ + Name: displayName, + Path: appPath, + Icon: icon, + }, iconErr +} + +func appsFromAppsFolder(onlyPackaged bool) ([]AppInfo, error) { + hr := win.CoInitializeEx(nil, win.COINIT_APARTMENTTHREADED) + uninit := hr == win.S_OK || hr == win.S_FALSE + if win.FAILED(hr) && uint32(hr) != rpcEChangedMode { + return nil, errors.New("CoInitializeEx failed") + } + if uninit { + defer win.CoUninitialize() + } + + pidlApps, err := parseShellItemPIDL(shellAppsFolder) + if err != nil { + return nil, err + } + defer win.CoTaskMemFree(pidlApps) + + var folder *IShellFolder + hr = shBindToObject(pidlApps, &iidIShellFolder, unsafe.Pointer(&folder)) + if win.FAILED(hr) || folder == nil { + return nil, errors.New("SHBindToObject failed") + } + defer folder.Release() + + var enum *IEnumIDList + hr = folder.EnumObjects(0, shcontfFolders|shcontfNonFolders, &enum) + if win.FAILED(hr) || enum == nil { + return nil, errors.New("EnumObjects failed") + } + defer enum.Release() + + seen := make(map[string]struct{}) + var apps []AppInfo + var errs []error + for { + var itemPIDL uintptr + var fetched uint32 + hr = enum.Next(1, &itemPIDL, &fetched) + if hr == win.S_FALSE || fetched == 0 { + break + } + if win.FAILED(hr) { + errs = append(errs, errors.New("EnumObjects next failed")) + break + } + if itemPIDL == 0 { + continue + } + + fullPIDL := ilCombine(pidlApps, itemPIDL) + win.CoTaskMemFree(itemPIDL) + if fullPIDL == 0 { + continue + } + + info, infoErr := appInfoFromPIDL(fullPIDL) + win.CoTaskMemFree(fullPIDL) + if infoErr != nil { + errs = append(errs, infoErr) + } + if info.Name == "" { + continue + } + if onlyPackaged && !isPackagedAppPath(info.Path) { + continue + } + key := appKey(info) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + apps = append(apps, info) + } + + return apps, joinErrors(errs) +} + +func appInfoFromShellItemPath(path string) (AppInfo, error) { + pidl, err := parseShellItemPIDL(path) + if err != nil { + return AppInfo{}, err + } + defer win.CoTaskMemFree(pidl) + return appInfoFromPIDL(pidl) +} + +func appInfoFromPIDL(pidl uintptr) (AppInfo, error) { + displayName, nameErr := nameFromPIDL(pidl, sigdnNormalDisplay) + parsingName, parseErr := nameFromPIDL(pidl, sigdnDesktopAbsoluteParsing) + icon, iconErr := iconFromPIDL(pidl) + + if displayName == "" { + displayName = parsingName + } + if parsingName == "" { + parsingName = displayName + } + + info := AppInfo{ + Name: displayName, + Path: parsingName, + Icon: icon, + } + + var errs []error + if nameErr != nil { + errs = append(errs, nameErr) + } + if parseErr != nil { + errs = append(errs, parseErr) + } + if iconErr != nil { + errs = append(errs, iconErr) + } + return info, joinErrors(errs) +} + +func iconFromPIDL(pidl uintptr) (*image.RGBA, error) { + var sfi win.SHFILEINFO + flags := uint32(win.SHGFI_PIDL | win.SHGFI_ICON | win.SHGFI_LARGEICON) + if win.SHGetFileInfo((*uint16)(unsafe.Pointer(pidl)), 0, &sfi, uint32(unsafe.Sizeof(sfi)), flags) == 0 || sfi.HIcon == 0 { + return nil, ErrIconNotFound + } + defer win.DestroyIcon(sfi.HIcon) + return hiconToRGBA(sfi.HIcon) +} + +func nameFromPIDL(pidl uintptr, sigdn uint32) (string, error) { + var psz *uint16 + ret, _, _ := procSHGetNameFromIDList.Call(pidl, uintptr(sigdn), uintptr(unsafe.Pointer(&psz))) + hr := win.HRESULT(ret) + if win.FAILED(hr) || psz == nil { + return "", errors.New("SHGetNameFromIDList failed") + } + defer win.CoTaskMemFree(uintptr(unsafe.Pointer(psz))) + return xwindows.UTF16PtrToString(psz), nil +} + +func parseShellItemPIDL(path string) (uintptr, error) { + if strings.TrimSpace(path) == "" { + return 0, errors.New("shell item path empty") + } + ptr, err := xwindows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var pidl uintptr + hr := win.SHParseDisplayName(ptr, 0, &pidl, 0, nil) + if win.FAILED(hr) || pidl == 0 { + return 0, errors.New("SHParseDisplayName failed") + } + return pidl, nil +} + +func isPackagedAppPath(path string) bool { + return hasAppsFolderItem(path) && strings.Contains(path, "!") +} + +func extractAppsFolderPath(target string, args string) string { + if path := findAppsFolderPath(target); path != "" { + if hasAppsFolderItem(path) { + return path + } + } + if path := findAppsFolderPath(args); path != "" { + if hasAppsFolderItem(path) { + return path + } + } + return "" +} + +func hasAppsFolderItem(path string) bool { + lower := strings.ToLower(path) + prefixLower := strings.ToLower(shellAppsFolder) + return strings.HasPrefix(lower, prefixLower) && len(path) > len(shellAppsFolder) +} + +func findAppsFolderPath(source string) string { + source = strings.TrimSpace(source) + if source == "" { + return "" + } + lower := strings.ToLower(source) + idx := strings.Index(lower, "shell:appsfolder") + if idx == -1 { + return "" + } + tail := strings.TrimLeft(source[idx:], " \"'") + if tail == "" { + return "" + } + end := len(tail) + for i, r := range tail { + if r == '"' || r == '\'' || r == ' ' || r == '\t' { + end = i + break + } + } + tail = tail[:end] + return normalizeAppsFolderPath(tail) +} + +func normalizeAppsFolderPath(path string) string { + path = strings.TrimSpace(strings.Trim(path, "\"")) + lower := strings.ToLower(path) + prefixLower := strings.ToLower(shellAppsFolder) + if strings.HasPrefix(lower, prefixLower) { + return shellAppsFolder + path[len(prefixLower):] + } + if strings.EqualFold(path, strings.TrimSuffix(shellAppsFolder, "\\")) { + return shellAppsFolder + } + return path +} + +func shouldSkipDesktopExe(path string) bool { + base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) + return isInstallerName(base) +} + +func isInstallerTarget(target string, args string) bool { + target = strings.TrimSpace(target) + if target == "" { + return false + } + if hasAppsFolderItem(target) || hasAppsFolderItem(args) { + return false + } + base := strings.ToLower(filepath.Base(target)) + if base == "msiexec.exe" || base == "rundll32.exe" { + return true + } + baseName := strings.TrimSuffix(base, filepath.Ext(base)) + if isInstallerName(baseName) { + return true + } + return hasInstallerArgs(args) +} + +func isInstallerName(name string) bool { + if name == "" { + return false + } + lower := strings.ToLower(name) + for _, keyword := range installerNameKeywords { + if strings.Contains(lower, keyword) { + return true + } + } + return false +} + +func hasInstallerArgs(args string) bool { + if args == "" { + return false + } + lower := strings.ToLower(args) + for _, keyword := range installerArgKeywords { + if strings.Contains(lower, keyword) { + return true + } + } + return false +} + +func shBindToObject(pidl uintptr, riid *win.IID, ppv unsafe.Pointer) win.HRESULT { + ret, _, _ := procSHBindToObject.Call( + 0, + pidl, + 0, + uintptr(unsafe.Pointer(riid)), + uintptr(ppv), + ) + return win.HRESULT(ret) +} + +func ilCombine(pidl1 uintptr, pidl2 uintptr) uintptr { + ret, _, _ := procILCombine.Call(pidl1, pidl2) + return ret +} + +func isSystemComponent(key registry.Key) bool { + value, _, err := key.GetIntegerValue("SystemComponent") + return err == nil && value == 1 +} + +func pickExeFromInstallLocation(dir string, displayName string) string { + if dir == "" { + return "" + } + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + + nameLower := strings.ToLower(displayName) + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(entry.Name())) != ".exe" { + continue + } + base := strings.ToLower(strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))) + if base == nameLower { + return filepath.Join(dir, entry.Name()) + } + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(entry.Name())) == ".exe" { + return filepath.Join(dir, entry.Name()) + } + } + return "" +} + +func parseIconLocation(raw string) (string, int, bool) { + path := cleanPath(raw) + if path == "" { + return "", 0, false + } + if idx := strings.LastIndex(path, ","); idx != -1 { + tail := strings.TrimSpace(path[idx+1:]) + if tail != "" { + if val, err := strconv.Atoi(tail); err == nil { + return strings.TrimSpace(path[:idx]), val, true + } + } + } + return path, 0, false +} + +func cleanPath(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + s = strings.TrimPrefix(s, "@") + s = strings.TrimSpace(s) + s = strings.Trim(s, "\"") + return s +} + +func normalizePath(raw string) string { + s := cleanPath(raw) + if s == "" { + return "" + } + expanded, err := expandEnv(s) + if err == nil { + s = expanded + } + s = strings.Trim(s, "\"") + return s +} + +func extractExeFromCommandLine(cmd string) string { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return "" + } + cmd = strings.TrimSpace(strings.TrimPrefix(cmd, "@")) + var path string + if strings.HasPrefix(cmd, "\"") { + end := strings.Index(cmd[1:], "\"") + if end == -1 { + return "" + } + path = cmd[1 : 1+end] + } else { + fields := strings.Fields(cmd) + if len(fields) == 0 { + return "" + } + path = fields[0] + } + path = normalizePath(path) + if path == "" { + return "" + } + base := strings.ToLower(filepath.Base(path)) + if base == "msiexec.exe" || base == "rundll32.exe" { + return "" + } + if !strings.HasSuffix(strings.ToLower(path), ".exe") { + return "" + } + return path +} + +func resolveRelativePath(path string, base string) string { + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return path + } + return filepath.Join(filepath.Dir(base), path) +} + +func appKey(app AppInfo) string { + if app.Path != "" { + return strings.ToLower(app.Path) + } + return strings.ToLower(app.Name) +} + +func desktopDirectory() (string, error) { + path, err := desktopDirectoryByCSIDL(win.CSIDL_DESKTOPDIRECTORY) + if err != nil { + return "", err + } + return path, nil +} + +func desktopDirectories() ([]string, error) { + var dirs []string + var errs []error + + userDir, err := desktopDirectoryByCSIDL(win.CSIDL_DESKTOPDIRECTORY) + if err == nil && userDir != "" { + dirs = append(dirs, userDir) + } else if err != nil { + errs = append(errs, err) + } + + publicDir, err := desktopDirectoryByCSIDL(win.CSIDL_COMMON_DESKTOPDIRECTORY) + if err == nil && publicDir != "" { + dirs = append(dirs, publicDir) + } else if err != nil { + errs = append(errs, err) + } + + if len(dirs) == 0 { + return nil, joinErrors(errs) + } + return dirs, joinErrors(errs) +} + +func desktopDirectoryByCSIDL(csidl win.CSIDL) (string, error) { + var buf [win.MAX_PATH]uint16 + if !win.SHGetSpecialFolderPath(0, &buf[0], csidl, false) { + return "", errors.New("desktop directory not found") + } + return xwindows.UTF16ToString(buf[:]), nil +} + +func resolveShortcut(path string) (string, string, int, string, error) { + hr := win.CoInitializeEx(nil, win.COINIT_APARTMENTTHREADED) + uninit := hr == win.S_OK || hr == win.S_FALSE + if win.FAILED(hr) && uint32(hr) != rpcEChangedMode { + return "", "", 0, "", errors.New("CoInitializeEx failed") + } + if uninit { + defer win.CoUninitialize() + } + + var sl *IShellLinkW + var unk unsafe.Pointer + hr = win.CoCreateInstance(&clsidShellLink, nil, win.CLSCTX_INPROC_SERVER, &iidIShellLinkW, &unk) + if win.FAILED(hr) || unk == nil { + return "", "", 0, "", errors.New("CoCreateInstance failed") + } + sl = (*IShellLinkW)(unk) + defer sl.Release() + + var pf *IPersistFile + hr = sl.QueryInterface(&iidIPersistFile, &unk) + if win.FAILED(hr) || unk == nil { + return "", "", 0, "", errors.New("QueryInterface failed") + } + pf = (*IPersistFile)(unk) + defer pf.Release() + + lnkPath, err := xwindows.UTF16PtrFromString(path) + if err != nil { + return "", "", 0, "", err + } + hr = pf.Load(lnkPath, stgmRead) + if win.FAILED(hr) { + return "", "", 0, "", errors.New("shortcut load failed") + } + + targetBuf := make([]uint16, win.MAX_PATH) + hr = sl.GetPath(&targetBuf[0], win.MAX_PATH, nil, slgpRawPath) + if win.FAILED(hr) { + return "", "", 0, "", errors.New("shortcut target read failed") + } + target := xwindows.UTF16ToString(targetBuf) + + iconBuf := make([]uint16, win.MAX_PATH) + var iconIndex int32 + hr = sl.GetIconLocation(&iconBuf[0], win.MAX_PATH, &iconIndex) + if win.FAILED(hr) { + return target, "", 0, "", errors.New("shortcut icon read failed") + } + iconPath := xwindows.UTF16ToString(iconBuf) + iconPath = resolveRelativePath(iconPath, path) + + argsBuf := make([]uint16, win.MAX_PATH) + args := "" + if hr = sl.GetArguments(&argsBuf[0], win.MAX_PATH); !win.FAILED(hr) { + args = xwindows.UTF16ToString(argsBuf) + } + + return target, iconPath, int(iconIndex), args, nil +} + +func iconFromFile(path string, index int, hasIndex bool) (*image.RGBA, error) { + path = normalizePath(path) + if path == "" { + return nil, ErrIconNotFound + } + + var hIcon win.HICON + var err error + if hasIndex { + hIcon, err = extractIconByIndex(path, index) + } + if hIcon == 0 { + hIcon, err = extractIconByFile(path) + } + if hIcon == 0 { + if err == nil { + err = ErrIconNotFound + } + return nil, err + } + defer win.DestroyIcon(hIcon) + + img, err := hiconToRGBA(hIcon) + if err != nil { + return nil, err + } + return img, nil +} + +func extractIconByIndex(path string, index int) (win.HICON, error) { + ptr, err := xwindows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var hIcon win.HICON + hr := win.SHDefExtractIcon(ptr, int32(index), 0, &hIcon, nil, win.MAKELONG(uint16(iconRequestSize), uint16(iconRequestSize))) + if win.FAILED(hr) || hIcon == 0 { + return 0, errors.New("SHDefExtractIcon failed") + } + return hIcon, nil +} + +func extractIconByFile(path string) (win.HICON, error) { + ptr, err := xwindows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var sfi win.SHFILEINFO + flags := uint32(win.SHGFI_ICON | win.SHGFI_LARGEICON) + if win.SHGetFileInfo(ptr, 0, &sfi, uint32(unsafe.Sizeof(sfi)), flags) == 0 || sfi.HIcon == 0 { + return 0, errors.New("SHGetFileInfo failed") + } + return sfi.HIcon, nil +} + +func hiconToRGBA(hIcon win.HICON) (*image.RGBA, error) { + var info win.ICONINFO + hasInfo := win.GetIconInfo(hIcon, &info) + if hasInfo { + defer win.DeleteObject(win.HGDIOBJ(info.HbmMask)) + if info.HbmColor != 0 { + defer win.DeleteObject(win.HGDIOBJ(info.HbmColor)) + } + } + + width := 0 + height := 0 + if hasInfo { + hBmp := info.HbmColor + if hBmp == 0 { + hBmp = info.HbmMask + } + var bmp win.BITMAP + if win.GetObject(win.HGDIOBJ(hBmp), unsafe.Sizeof(bmp), unsafe.Pointer(&bmp)) != 0 { + width = int(bmp.BmWidth) + height = int(bmp.BmHeight) + if info.HbmColor == 0 && height > 1 { + height = height / 2 + } + } + } + if width <= 0 || height <= 0 { + width = int(win.GetSystemMetrics(win.SM_CXICON)) + height = int(win.GetSystemMetrics(win.SM_CYICON)) + if width <= 0 || height <= 0 { + return nil, errors.New("icon size invalid") + } + } + + hdc := win.CreateCompatibleDC(0) + if hdc == 0 { + return nil, errors.New("CreateCompatibleDC failed") + } + defer win.DeleteDC(hdc) + + var header win.BITMAPINFOHEADER + header.BiSize = uint32(unsafe.Sizeof(header)) + header.BiPlanes = 1 + header.BiBitCount = 32 + header.BiWidth = int32(width) + header.BiHeight = int32(-height) + header.BiCompression = win.BI_RGB + + var bits unsafe.Pointer + hBmp := win.CreateDIBSection(hdc, &header, win.DIB_RGB_COLORS, &bits, 0, 0) + if hBmp == 0 { + return nil, errors.New("CreateDIBSection failed") + } + defer win.DeleteObject(win.HGDIOBJ(hBmp)) + + old := win.SelectObject(hdc, win.HGDIOBJ(hBmp)) + if old == 0 { + return nil, errors.New("SelectObject failed") + } + defer win.SelectObject(hdc, old) + + if !win.DrawIconEx(hdc, 0, 0, hIcon, int32(width), int32(height), 0, 0, win.DI_NORMAL) { + return nil, errors.New("DrawIconEx failed") + } + + pixelCount := width * height * 4 + if pixelCount <= 0 { + return nil, errors.New("icon buffer invalid") + } + + src := unsafe.Slice((*byte)(bits), pixelCount) + pix := make([]byte, pixelCount) + copy(pix, src) + + hasAlpha := false + for i := 0; i < len(pix); i += 4 { + b, g, r, a := pix[i], pix[i+1], pix[i+2], pix[i+3] + pix[i], pix[i+1], pix[i+2], pix[i+3] = r, g, b, a + if a != 0 { + hasAlpha = true + } + } + + if !hasAlpha && hasInfo && info.HbmMask != 0 { + if maskErr := applyMaskAlpha(pix, width, height, info.HbmMask); maskErr == nil { + hasAlpha = true + } + } + if !hasAlpha { + for i := 3; i < len(pix); i += 4 { + pix[i] = 0xFF + } + } + + return &image.RGBA{ + Pix: pix, + Stride: width * 4, + Rect: image.Rect(0, 0, width, height), + }, nil +} + +func applyMaskAlpha(pix []byte, width, height int, mask win.HBITMAP) error { + if width <= 0 || height <= 0 { + return errors.New("mask size invalid") + } + rowSize := ((width + 31) / 32) * 4 + if rowSize <= 0 { + return errors.New("mask row size invalid") + } + + hdc := win.CreateCompatibleDC(0) + if hdc == 0 { + return errors.New("CreateCompatibleDC failed") + } + defer win.DeleteDC(hdc) + + var bmi win.BITMAPINFO + bmi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bmi.BmiHeader)) + bmi.BmiHeader.BiWidth = int32(width) + bmi.BmiHeader.BiHeight = int32(-height) + bmi.BmiHeader.BiPlanes = 1 + bmi.BmiHeader.BiBitCount = 1 + bmi.BmiHeader.BiCompression = win.BI_RGB + + maskBits := make([]byte, rowSize*height) + if win.GetDIBits(hdc, mask, 0, uint32(height), &maskBits[0], &bmi, win.DIB_RGB_COLORS) == 0 { + return errors.New("GetDIBits failed") + } + + for y := 0; y < height; y++ { + row := maskBits[y*rowSize:] + for x := 0; x < width; x++ { + byteIndex := x / 8 + bit := 7 - (x % 8) + if ((row[byteIndex] >> bit) & 1) == 1 { + pix[(y*width+x)*4+3] = 0 + } else { + pix[(y*width+x)*4+3] = 0xFF + } + } + } + + return nil +} + +func expandEnv(path string) (string, error) { + if !strings.Contains(path, "%") { + return path, nil + } + src, err := xwindows.UTF16PtrFromString(path) + if err != nil { + return path, err + } + buf := make([]uint16, 256) + for { + n, err := xwindows.ExpandEnvironmentStrings(src, &buf[0], uint32(len(buf))) + if err == nil { + return xwindows.UTF16ToString(buf[:n]), nil + } + if err != xwindows.ERROR_INSUFFICIENT_BUFFER { + return path, err + } + buf = make([]uint16, n) + } +} diff --git a/apps_exports.go b/apps_exports.go new file mode 100644 index 0000000..2471ccf --- /dev/null +++ b/apps_exports.go @@ -0,0 +1,15 @@ +package deskact + +import "github.com/PekingSpades/DeskAct/apps" + +type AppInfo = apps.AppInfo + +var ErrIconNotFound = apps.ErrIconNotFound + +func DesktopApps() ([]AppInfo, error) { + return apps.DesktopApps() +} + +func InstalledApps() ([]AppInfo, error) { + return apps.InstalledApps() +} diff --git a/examples/apps/main.go b/examples/apps/main.go new file mode 100644 index 0000000..d566c6e --- /dev/null +++ b/examples/apps/main.go @@ -0,0 +1,1045 @@ +package main + +import ( + "bufio" + "bytes" + "compress/zlib" + "errors" + "fmt" + "image" + "image/color" + imagedraw "image/draw" + "image/png" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + "unicode/utf16" + + deskact "github.com/PekingSpades/DeskAct" + "golang.org/x/image/draw" +) + +const ( + iconSize = 32 + saveIconSize = 64 + appsPerRow = 5 + + appsRootDirName = ".apps" + installedDirName = ".installed" + desktopDirName = ".desktop" + iconsDirName = "icons" + markdownFileName = "README.md" + pdfFileName = "README.pdf" + logFileName = "output.log" + + pdfPageWidth = 612.0 + pdfPageHeight = 792.0 + pdfMargin = 54.0 + pdfFontSize = 12.0 + pdfLeading = 16.0 + pdfIconSize = 48.0 + pdfTextGap = 12.0 + pdfSectionGap = 18.0 + pdfEntryGap = 12.0 +) + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Apps Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + defer waitForExit() + + listMode := promptListMode() + appsDir := resolveAppsDir() + outputMode := promptOutputMode(appsDir) + + groups := loadAppGroups(listMode) + if len(groups) == 0 { + fmt.Println("No app groups selected.") + return + } + + for _, group := range groups { + missing, other := splitAppErrors(group.Err) + if other != nil { + fmt.Printf("Warning (%s): %v\n", group.Label, other) + } + if missing > 0 && outputMode == "console" { + fmt.Printf("Missing icons (%s): %d\n", group.Label, missing) + } + + switch outputMode { + case "console": + printGroup(group) + case "save": + saveGroup(group, appsDir) + } + } +} + +func waitForExit() { + fmt.Print("\n按任意键退出程序...") + reader := bufio.NewReader(os.Stdin) + _, _ = reader.ReadByte() +} + +type appGroup struct { + Mode string + Label string + Apps []deskact.AppInfo + Err error +} + +func loadAppGroups(listMode string) []appGroup { + var groups []appGroup + if listMode == "installed" || listMode == "both" { + apps, err := deskact.InstalledApps() + groups = append(groups, appGroup{ + Mode: "installed", + Label: "Installed apps", + Apps: apps, + Err: err, + }) + } + if listMode == "desktop" || listMode == "both" { + apps, err := deskact.DesktopApps() + groups = append(groups, appGroup{ + Mode: "desktop", + Label: "Desktop apps", + Apps: apps, + Err: err, + }) + } + return groups +} + +func printGroup(group appGroup) { + fmt.Printf("\n%s\n", group.Label) + fmt.Printf("Total apps: %d\n", len(group.Apps)) + fmt.Println("----------------------------------------") + printApps(group.Apps) +} + +func saveGroup(group appGroup, appsDir string) { + groupDir := filepath.Join(appsDir, dirNameForMode(group.Mode)) + iconsDir := filepath.Join(groupDir, iconsDirName) + + fmt.Printf("\n%s\n", group.Label) + fmt.Printf("Total apps: %d\n", len(group.Apps)) + + apps, missing, saveErr := saveIcons(group.Apps, iconsDir, iconsDirName) + savedCount := countSavedIcons(apps) + if saveErr != nil { + fmt.Printf("Save errors (%s): %v\n", group.Label, saveErr) + } + if missing > 0 { + fmt.Printf("Missing icons (%s): %d\n", group.Label, missing) + } + mdErr := writeMarkdown(groupDir, apps, group.Mode, len(group.Apps), missing, saveErr) + if mdErr != nil { + fmt.Printf("Markdown errors (%s): %v\n", group.Label, mdErr) + } + pdfErr := writePDF(groupDir, apps, group.Mode, len(group.Apps), missing, saveErr) + if pdfErr != nil { + fmt.Printf("PDF errors (%s): %v\n", group.Label, pdfErr) + } + if logErr := writeLog(groupDir, group, apps, missing, saveErr, mdErr, pdfErr); logErr != nil { + fmt.Printf("Log errors (%s): %v\n", group.Label, logErr) + } + fmt.Printf("Saved %d icons to %s\n", savedCount, iconsDir) +} + +func dirNameForMode(mode string) string { + switch mode { + case "installed": + return installedDirName + case "desktop": + return desktopDirName + default: + return mode + } +} + +func promptListMode() string { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Println("Select app list:") + fmt.Println("1) Installed apps") + fmt.Println("2) Desktop apps") + fmt.Println("3) Both installed + desktop apps") + fmt.Print("Choice [1/2/3]: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "1", "installed", "i": + return "installed" + case "2", "desktop", "d": + return "desktop" + case "3", "both", "b", "all": + return "both" + default: + fmt.Println("Please enter 1, 2, or 3.") + } + } +} + +func promptOutputMode(appsDir string) string { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Println("\nSelect output mode:") + fmt.Println("1) Print to console") + fmt.Printf("2) Save to .apps directory (%s)\n", appsDir) + fmt.Print("Choice [1/2]: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "1", "console", "c": + return "console" + case "2", "save", "s": + return "save" + default: + fmt.Println("Please enter 1 or 2.") + } + } +} + +func resolveAppsDir() string { + wd, err := os.Getwd() + if err != nil { + return filepath.Join(".", appsRootDirName) + } + return filepath.Join(wd, appsRootDirName) +} + +func printApps(apps []deskact.AppInfo) { + for i, app := range apps { + fmt.Printf("\n[%d] %s\n", i+1, app.Name) + fmt.Printf("Path: %s\n", app.Path) + if app.Icon == nil { + fmt.Println("Icon: (none)") + continue + } + fmt.Printf("Icon: %dx%d\n", app.Icon.Bounds().Dx(), app.Icon.Bounds().Dy()) + printIconDots(app.Icon) + } +} + +func printIconDots(icon *image.RGBA) { + scaled := resizeIcon(icon, iconSize, draw.NearestNeighbor) + if scaled == nil { + fmt.Println("(icon size invalid)") + return + } + + for y := 0; y < iconSize; y++ { + row := scaled.Pix[y*scaled.Stride:] + var sb strings.Builder + sb.Grow(iconSize * 2) + for x := 0; x < iconSize; x++ { + if row[x*4+3] != 0 { + sb.WriteByte('.') + sb.WriteByte(' ') + } else { + sb.WriteByte(' ') + sb.WriteByte(' ') + } + } + fmt.Println(sb.String()) + } +} + +func resizeIcon(src *image.RGBA, size int, scaler draw.Scaler) *image.RGBA { + if src == nil || size <= 0 { + return nil + } + if src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 { + return nil + } + if src.Bounds().Dx() == size && src.Bounds().Dy() == size { + return src + } + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + scaler.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + return dst +} + +type appEntry struct { + Name string + Path string + IconPath string + IconFilePath string + HasIcon bool +} + +func saveIcons(apps []deskact.AppInfo, iconsDir string, iconRelDir string) ([]appEntry, int, error) { + if err := os.MkdirAll(iconsDir, 0755); err != nil { + return nil, 0, err + } + + entries := make([]appEntry, 0, len(apps)) + missing := 0 + var errs []error + for i, app := range apps { + entry := appEntry{ + Name: app.Name, + Path: app.Path, + } + if app.Icon == nil { + missing++ + entries = append(entries, entry) + continue + } + scaled := resizeIcon(app.Icon, saveIconSize, draw.CatmullRom) + if scaled == nil { + missing++ + entries = append(entries, entry) + continue + } + name := sanitizeFileName(app.Name) + if name == "" { + name = "app" + } + fileBase := name + "_" + strconv.Itoa(i+1) + path := filepath.Join(iconsDir, fileBase+".png") + + path = ensureUniquePath(path) + if err := savePNG(scaled, path); err != nil { + if errors.Is(err, deskact.ErrIconNotFound) { + missing++ + } else { + errs = append(errs, err) + } + entries = append(entries, entry) + continue + } + entry.HasIcon = true + entry.IconFilePath = path + entry.IconPath = buildIconRelPath(iconRelDir, filepath.Base(path)) + entries = append(entries, entry) + } + + if len(errs) > 0 { + return entries, missing, errors.Join(errs...) + } + return entries, missing, nil +} + +func buildIconRelPath(iconRelDir string, fileName string) string { + if iconRelDir == "" { + return filepath.ToSlash(fileName) + } + return filepath.ToSlash(filepath.Join(iconRelDir, fileName)) +} + +func filterAppsWithIcons(apps []appEntry) []appEntry { + if len(apps) == 0 { + return nil + } + filtered := make([]appEntry, 0, len(apps)) + for _, app := range apps { + if app.HasIcon { + filtered = append(filtered, app) + } + } + return filtered +} + +func countSavedIcons(apps []appEntry) int { + count := 0 + for _, app := range apps { + if app.HasIcon { + count++ + } + } + return count +} + +func savePNG(img image.Image, path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + return png.Encode(file, img) +} + +func sanitizeFileName(name string) string { + var sb strings.Builder + sb.Grow(len(name)) + for _, r := range name { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + sb.WriteRune(r) + } else { + sb.WriteByte('_') + } + } + return strings.Trim(sb.String(), "_") +} + +func ensureUniquePath(path string) string { + if _, err := os.Stat(path); os.IsNotExist(err) { + return path + } + dir := filepath.Dir(path) + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ext := filepath.Ext(path) + for i := 2; ; i++ { + candidate := filepath.Join(dir, base+"_"+strconv.Itoa(i)+ext) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } +} + +func writeMarkdown(dir string, apps []appEntry, listMode string, totalApps int, missing int, saveErr error) error { + iconsOnly := filterAppsWithIcons(apps) + savedCount := len(iconsOnly) + var sb strings.Builder + sb.WriteString("# DeskAct Apps\n\n") + writeTable(&sb, iconsOnly) + sb.WriteString("\n---\n\n") + sb.WriteString("Summary\n\n") + sb.WriteString(fmt.Sprintf("- List mode: %s\n", listMode)) + sb.WriteString(fmt.Sprintf("- Total apps: %d\n", totalApps)) + sb.WriteString(fmt.Sprintf("- Saved icons: %d\n", savedCount)) + sb.WriteString(fmt.Sprintf("- Missing icons: %d\n", missing)) + sb.WriteString(fmt.Sprintf("- Generated at: %s\n", time.Now().Format(time.RFC3339))) + if saveErr != nil { + sb.WriteString(fmt.Sprintf("- Save errors: %v\n", saveErr)) + } + + path := filepath.Join(dir, markdownFileName) + return os.WriteFile(path, []byte(sb.String()), 0644) +} + +func writePDF(dir string, apps []appEntry, listMode string, totalApps int, missing int, saveErr error) error { + savedCount := countSavedIcons(apps) + summary := buildPDFSummary(listMode, totalApps, savedCount, missing, saveErr) + data, buildErr := buildPDF(summary, apps) + if len(data) == 0 { + if buildErr != nil { + return buildErr + } + return errors.New("pdf generation returned empty data") + } + path := filepath.Join(dir, pdfFileName) + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + return buildErr +} + +func buildPDFSummary(listMode string, totalApps int, savedCount int, missing int, saveErr error) []string { + lines := []string{ + "DeskAct Apps", + fmt.Sprintf("List mode: %s", listMode), + fmt.Sprintf("Total apps: %d", totalApps), + fmt.Sprintf("Saved icons: %d", savedCount), + fmt.Sprintf("Missing icons: %d", missing), + fmt.Sprintf("Generated at: %s", time.Now().Format(time.RFC3339)), + } + if saveErr != nil { + lines = append(lines, fmt.Sprintf("Save errors: %v", saveErr)) + } + return lines +} + +type pdfImage struct { + Name string + Width int + Height int + Data []byte + ObjectID int +} + +type pdfPage struct { + Content string + Images map[string]*pdfImage +} + +type pdfDocument struct { + Pages []pdfPage + Images []*pdfImage +} + +func buildPDF(summary []string, apps []appEntry) ([]byte, error) { + doc, err := buildPDFDocument(summary, apps) + if doc == nil { + return nil, err + } + data := renderPDF(doc) + return data, err +} + +func buildPDFDocument(summary []string, apps []appEntry) (*pdfDocument, error) { + doc := &pdfDocument{} + imageRegistry := make(map[string]*pdfImage) + var imageErrors []error + + page := pdfPage{Images: make(map[string]*pdfImage)} + var content strings.Builder + y := pdfPageHeight - pdfMargin + + summaryWidth := pdfPageWidth - 2*pdfMargin + summaryMaxRunes := maxRunesForWidth(summaryWidth, pdfFontSize) + summaryLines := wrapLines(summary, summaryMaxRunes) + summaryHeight := textBlockHeight(summaryLines, pdfLeading, pdfFontSize) + addTextBlock(&content, summaryLines, pdfMargin, y-pdfFontSize, pdfFontSize, pdfLeading) + y -= summaryHeight + pdfSectionGap + + textX := pdfMargin + pdfIconSize + pdfTextGap + textWidth := pdfPageWidth - pdfMargin - textX + textMaxRunes := maxRunesForWidth(textWidth, pdfFontSize) + + for _, app := range apps { + iconStatus := "图标: (无)" + var iconName string + var iconImage *pdfImage + if app.HasIcon && app.IconFilePath != "" { + iconStatus = fmt.Sprintf("图标: %s", app.IconPath) + img, err := getPDFImage(app.IconFilePath, imageRegistry, &doc.Images) + if err != nil { + imageErrors = append(imageErrors, err) + iconStatus = "图标: (加载失败)" + } else { + iconName = img.Name + iconImage = img + } + } + + lines := buildAppLines(app, iconStatus, textMaxRunes) + entryHeight := maxFloat(pdfIconSize, textBlockHeight(lines, pdfLeading, pdfFontSize)) + pdfEntryGap + if y-entryHeight < pdfMargin { + page.Content = content.String() + doc.Pages = append(doc.Pages, page) + page = pdfPage{Images: make(map[string]*pdfImage)} + content.Reset() + y = pdfPageHeight - pdfMargin + } + + if iconImage != nil { + page.Images[iconImage.Name] = iconImage + } + if iconName != "" { + addImageCommand(&content, iconName, pdfMargin, y-pdfIconSize, pdfIconSize, pdfIconSize) + } + addTextBlock(&content, lines, textX, y-pdfFontSize, pdfFontSize, pdfLeading) + y -= entryHeight + } + + page.Content = content.String() + doc.Pages = append(doc.Pages, page) + return doc, errors.Join(imageErrors...) +} + +func renderPDF(doc *pdfDocument) []byte { + pageCount := len(doc.Pages) + imageCount := len(doc.Images) + if pageCount == 0 { + return nil + } + + catalogID := 1 + pagesID := 2 + firstPageID := 3 + firstContentID := firstPageID + pageCount + firstImageID := firstContentID + pageCount + fontType0ID := firstImageID + imageCount + cidFontID := fontType0ID + 1 + fontDescriptorID := fontType0ID + 2 + helveticaID := fontType0ID + 3 + lastID := helveticaID + + for i, img := range doc.Images { + img.ObjectID = firstImageID + i + } + + objects := make(map[int][]byte, lastID+1) + objects[catalogID] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID)) + + kids := make([]string, pageCount) + for i := 0; i < pageCount; i++ { + kids[i] = fmt.Sprintf("%d 0 R", firstPageID+i) + } + objects[pagesID] = []byte(fmt.Sprintf("<< /Type /Pages /Kids [ %s ] /Count %d >>", strings.Join(kids, " "), pageCount)) + + for i, page := range doc.Pages { + pageID := firstPageID + i + contentID := firstContentID + i + objects[contentID] = buildStreamObject([]byte(page.Content)) + + resources := buildPDFResources(page.Images, fontType0ID, helveticaID) + pageObj := fmt.Sprintf("<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Contents %d 0 R /Resources %s >>", + pagesID, pdfPageWidth, pdfPageHeight, contentID, resources) + objects[pageID] = []byte(pageObj) + } + + for _, img := range doc.Images { + objects[img.ObjectID] = buildImageObject(img) + } + + // Built-in CJK font so Unicode app names render without external font files. + objects[fontType0ID] = []byte(fmt.Sprintf("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [ %d 0 R ] >>", cidFontID)) + objects[cidFontID] = []byte(fmt.Sprintf("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor %d 0 R /DW 1000 >>", fontDescriptorID)) + objects[fontDescriptorID] = []byte("<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 /FontBBox [0 -260 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -260 /CapHeight 750 /StemV 80 >>") + objects[helveticaID] = []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + + var buf bytes.Buffer + buf.WriteString("%PDF-1.4\n") + + offsets := make([]int, lastID+1) + for id := 1; id <= lastID; id++ { + content := objects[id] + offsets[id] = buf.Len() + buf.WriteString(fmt.Sprintf("%d 0 obj\n", id)) + buf.Write(content) + if len(content) == 0 || content[len(content)-1] != '\n' { + buf.WriteByte('\n') + } + buf.WriteString("endobj\n") + } + + xrefOffset := buf.Len() + buf.WriteString("xref\n") + buf.WriteString(fmt.Sprintf("0 %d\n", lastID+1)) + buf.WriteString("0000000000 65535 f \n") + for i := 1; i <= lastID; i++ { + buf.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i])) + } + buf.WriteString("trailer\n") + buf.WriteString(fmt.Sprintf("<< /Size %d /Root %d 0 R >>\n", lastID+1, catalogID)) + buf.WriteString("startxref\n") + buf.WriteString(fmt.Sprintf("%d\n", xrefOffset)) + buf.WriteString("%%EOF\n") + return buf.Bytes() +} + +func buildPDFResources(images map[string]*pdfImage, cjkFontID int, latinFontID int) string { + var sb strings.Builder + sb.WriteString("<< /Font << /F1 ") + sb.WriteString(strconv.Itoa(cjkFontID)) + sb.WriteString(" 0 R /F2 ") + sb.WriteString(strconv.Itoa(latinFontID)) + sb.WriteString(" 0 R >>") + if len(images) > 0 { + sb.WriteString(" /XObject << ") + names := make([]string, 0, len(images)) + for name := range images { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + img := images[name] + sb.WriteString("/") + sb.WriteString(name) + sb.WriteString(" ") + sb.WriteString(strconv.Itoa(img.ObjectID)) + sb.WriteString(" 0 R ") + } + sb.WriteString(">>") + } + sb.WriteString(" >>") + return sb.String() +} + +func buildStreamObject(data []byte) []byte { + var sb bytes.Buffer + sb.WriteString(fmt.Sprintf("<< /Length %d >>\nstream\n", len(data))) + sb.Write(data) + sb.WriteString("\nendstream") + return sb.Bytes() +} + +func buildImageObject(img *pdfImage) []byte { + var sb bytes.Buffer + sb.WriteString(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n", + img.Width, img.Height, len(img.Data))) + sb.Write(img.Data) + sb.WriteString("\nendstream") + return sb.Bytes() +} + +func addTextBlock(sb *strings.Builder, lines []string, x float64, y float64, fontSize float64, leading float64) { + if len(lines) == 0 { + return + } + sb.WriteString("BT\n") + sb.WriteString(fmt.Sprintf("%.2f %.2f Td\n", x, y)) + sb.WriteString(fmt.Sprintf("%.2f TL\n", leading)) + lastFont := "" + for i, line := range lines { + segments := splitTextSegments(line) + if len(segments) == 0 { + if lastFont == "" { + sb.WriteString(fmt.Sprintf("/F2 %.2f Tf\n", fontSize)) + lastFont = "F2" + } + sb.WriteString("() Tj\n") + } else { + for _, segment := range segments { + if segment.Font != lastFont { + sb.WriteString(fmt.Sprintf("/%s %.2f Tf\n", segment.Font, fontSize)) + lastFont = segment.Font + } + sb.WriteString(segment.PDFText) + sb.WriteString(" Tj\n") + } + } + if i != len(lines)-1 { + sb.WriteString("T*\n") + } + } + sb.WriteString("ET\n") +} + +func addImageCommand(sb *strings.Builder, name string, x float64, y float64, w float64, h float64) { + sb.WriteString("q\n") + sb.WriteString(fmt.Sprintf("%.2f 0 0 %.2f %.2f %.2f cm\n", w, h, x, y)) + sb.WriteString("/") + sb.WriteString(name) + sb.WriteString(" Do\n") + sb.WriteString("Q\n") +} + +func textBlockHeight(lines []string, leading float64, fontSize float64) float64 { + if len(lines) == 0 { + return 0 + } + return leading*float64(len(lines)-1) + fontSize +} + +func maxRunesForWidth(width float64, fontSize float64) int { + if fontSize <= 0 || width <= 0 { + return 10 + } + max := int(width / fontSize) + if max < 10 { + return 10 + } + return max +} + +func wrapLines(lines []string, maxRunes int) []string { + var out []string + for _, line := range lines { + out = append(out, wrapText(line, maxRunes)...) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func wrapText(text string, maxRunes int) []string { + if maxRunes <= 0 { + return []string{text} + } + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\n", " ") + + var lines []string + var sb strings.Builder + count := 0 + for _, r := range text { + sb.WriteRune(r) + count++ + if count >= maxRunes { + lines = append(lines, sb.String()) + sb.Reset() + count = 0 + } + } + if sb.Len() > 0 { + lines = append(lines, sb.String()) + } + if len(lines) == 0 { + lines = []string{""} + } + return lines +} + +func buildAppLines(app appEntry, iconStatus string, maxRunes int) []string { + name := strings.TrimSpace(app.Name) + if name == "" { + name = "(unknown)" + } + path := strings.TrimSpace(app.Path) + if path == "" { + path = "(unknown)" + } + if iconStatus == "" { + iconStatus = "图标: (无)" + } + + var lines []string + lines = append(lines, wrapText(fmt.Sprintf("应用名称: %s", name), maxRunes)...) + lines = append(lines, wrapText(fmt.Sprintf("路径: %s", path), maxRunes)...) + lines = append(lines, wrapText(iconStatus, maxRunes)...) + return lines +} + +type textSegment struct { + Font string + PDFText string +} + +func splitTextSegments(text string) []textSegment { + if text == "" { + return nil + } + var segments []textSegment + var sb strings.Builder + currentASCII := true + currentSet := false + + flush := func() { + if sb.Len() == 0 { + return + } + if currentASCII { + segments = append(segments, textSegment{ + Font: "F2", + PDFText: pdfLiteralText(sb.String()), + }) + } else { + segments = append(segments, textSegment{ + Font: "F1", + PDFText: pdfHexText(sb.String()), + }) + } + sb.Reset() + } + + for _, r := range text { + isASCII := r >= 32 && r <= 126 + if r < 32 || r == 127 { + r = '?' + isASCII = true + } + if !currentSet { + currentASCII = isASCII + currentSet = true + } + if isASCII != currentASCII { + flush() + currentASCII = isASCII + } + sb.WriteRune(r) + } + flush() + return segments +} + +func pdfHexText(text string) string { + encoded := utf16.Encode([]rune(text)) + var sb strings.Builder + sb.WriteString("<") + for _, v := range encoded { + sb.WriteString(fmt.Sprintf("%04X", v)) + } + sb.WriteString(">") + return sb.String() +} + +func pdfLiteralText(text string) string { + return "(" + pdfEscapeLiteral(text) + ")" +} + +func pdfEscapeLiteral(text string) string { + text = strings.ReplaceAll(text, "\\", "\\\\") + text = strings.ReplaceAll(text, "(", "\\(") + text = strings.ReplaceAll(text, ")", "\\)") + text = strings.ReplaceAll(text, "\r", "\\r") + text = strings.ReplaceAll(text, "\n", "\\n") + return text +} + +func getPDFImage(path string, registry map[string]*pdfImage, images *[]*pdfImage) (*pdfImage, error) { + if img, ok := registry[path]; ok { + return img, nil + } + width, height, data, err := loadPDFImageData(path) + if err != nil { + return nil, err + } + img := &pdfImage{ + Name: fmt.Sprintf("Im%d", len(*images)+1), + Width: width, + Height: height, + Data: data, + } + registry[path] = img + *images = append(*images, img) + return img, nil +} + +func loadPDFImageData(path string) (int, int, []byte, error) { + file, err := os.Open(path) + if err != nil { + return 0, 0, nil, err + } + defer file.Close() + + img, err := png.Decode(file) + if err != nil { + return 0, 0, nil, err + } + return encodePDFImage(img) +} + +func encodePDFImage(img image.Image) (int, int, []byte, error) { + rgba := flattenToRGBA(img) + width := rgba.Bounds().Dx() + height := rgba.Bounds().Dy() + if width <= 0 || height <= 0 { + return 0, 0, nil, errors.New("invalid image size") + } + + raw := make([]byte, width*height*3) + index := 0 + for y := 0; y < height; y++ { + row := rgba.Pix[y*rgba.Stride:] + for x := 0; x < width; x++ { + p := x * 4 + raw[index] = row[p] + raw[index+1] = row[p+1] + raw[index+2] = row[p+2] + index += 3 + } + } + + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + if _, err := zw.Write(raw); err != nil { + _ = zw.Close() + return 0, 0, nil, err + } + if err := zw.Close(); err != nil { + return 0, 0, nil, err + } + return width, height, buf.Bytes(), nil +} + +func flattenToRGBA(img image.Image) *image.RGBA { + bounds := img.Bounds() + rgba := image.NewRGBA(bounds) + imagedraw.Draw(rgba, bounds, &image.Uniform{C: color.White}, image.Point{}, imagedraw.Src) + imagedraw.Draw(rgba, bounds, img, bounds.Min, imagedraw.Over) + return rgba +} + +func maxFloat(a float64, b float64) float64 { + if a > b { + return a + } + return b +} + +func writeLog(dir string, group appGroup, apps []appEntry, missing int, saveErr error, mdErr error, pdfErr error) error { + var sb strings.Builder + sb.WriteString("DeskAct Apps Log\n") + sb.WriteString(fmt.Sprintf("Generated at: %s\n", time.Now().Format(time.RFC3339))) + sb.WriteString(fmt.Sprintf("Group: %s\n", group.Label)) + sb.WriteString(fmt.Sprintf("List mode: %s\n", group.Mode)) + sb.WriteString(fmt.Sprintf("Total apps: %d\n", len(apps))) + sb.WriteString(fmt.Sprintf("Saved icons: %d\n", countSavedIcons(apps))) + sb.WriteString(fmt.Sprintf("Missing icons: %d\n", missing)) + if group.Err != nil { + sb.WriteString(fmt.Sprintf("List errors: %v\n", group.Err)) + } + if saveErr != nil { + sb.WriteString(fmt.Sprintf("Save errors: %v\n", saveErr)) + } + if mdErr != nil { + sb.WriteString(fmt.Sprintf("Markdown errors: %v\n", mdErr)) + } + if pdfErr != nil { + sb.WriteString(fmt.Sprintf("PDF errors: %v\n", pdfErr)) + } + path := filepath.Join(dir, logFileName) + return os.WriteFile(path, []byte(sb.String()), 0644) +} + +func writeTable(sb *strings.Builder, apps []appEntry) { + headers := make([]string, appsPerRow) + separators := make([]string, appsPerRow) + for i := 0; i < appsPerRow; i++ { + headers[i] = "App" + separators[i] = "---" + } + sb.WriteString("| " + strings.Join(headers, " | ") + " |\n") + sb.WriteString("| " + strings.Join(separators, " | ") + " |\n") + + for i := 0; i < len(apps); i += appsPerRow { + row := make([]string, appsPerRow) + for j := 0; j < appsPerRow; j++ { + index := i + j + if index >= len(apps) { + row[j] = "" + continue + } + row[j] = formatTableCell(apps[index]) + } + sb.WriteString("| " + strings.Join(row, " | ") + " |\n") + } + if len(apps) == 0 { + sb.WriteString("| " + strings.Repeat(" |", appsPerRow-1) + " |\n") + } +} + +func formatTableCell(app appEntry) string { + name := escapeMarkdown(app.Name) + icon := "(missing icon)" + if app.HasIcon { + icon = fmt.Sprintf("", app.IconPath, saveIconSize, saveIconSize) + } + if name == "" { + return icon + } + return icon + "
      " + name +} + +func escapeMarkdown(text string) string { + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\n", " ") + text = strings.ReplaceAll(text, "|", "\\|") + return text +} + +func splitAppErrors(err error) (int, error) { + var errs []error + missing := 0 + + var collect func(error) + collect = func(e error) { + if e == nil { + return + } + if errors.Is(e, deskact.ErrIconNotFound) { + missing++ + return + } + type joiner interface { + Unwrap() []error + } + if j, ok := e.(joiner); ok { + for _, inner := range j.Unwrap() { + collect(inner) + } + return + } + errs = append(errs, e) + } + + collect(err) + if len(errs) == 0 { + return missing, nil + } + return missing, errors.Join(errs...) +} From 1342cbf2127276fc75c3c8e888c718cf5f5d9dfa Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:31:37 +0800 Subject: [PATCH 13/37] ci: upload artifacts to server and shorten retention - allow GitHub artifact uploads to fail without breaking CI - upload build artifacts to the server for examples and tester builds - set artifact retention to 7 days --- .github/workflows/build-examples.yml | 54 +++++++++++++++++++++++++++- .github/workflows/build-tester.yml | 48 ++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 7ac8cdf..642ad74 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -69,7 +69,9 @@ jobs: go build -v -o "apps-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/apps - name: Upload artifacts + id: upload_github_artifacts uses: actions/upload-artifact@v4 + continue-on-error: true with: name: examples-${{ matrix.goos }}-${{ matrix.goarch }} path: | @@ -78,4 +80,54 @@ jobs: mouse-${{ matrix.goos }}-${{ matrix.goarch }}* window-${{ matrix.goos }}-${{ matrix.goarch }}* apps-${{ matrix.goos }}-${{ matrix.goarch }}* - retention-days: 30 + retention-days: 7 + + - name: Upload artifacts to server + shell: bash + continue-on-error: true + env: + ARTIFACT_SERVER_URL: ${{ secrets.ARTIFACT_SERVER_URL }} + ARTIFACT_USER: ${{ secrets.ARTIFACT_SERVER_USER }} + ARTIFACT_PASSWORD: ${{ secrets.ARTIFACT_SERVER_PASSWORD }} + run: | + set -euo pipefail + repo_name="${GITHUB_REPOSITORY##*/}" + date_str="$(date -u +%Y-%m-%d)" + commit_hash="${GITHUB_SHA}" + base_path="/${repo_name}/${date_str}/${commit_hash}/" + server_url="${ARTIFACT_SERVER_URL%/}" + shopt -s nullglob + files=( + capture-${{ matrix.goos }}-${{ matrix.goarch }}* + display-${{ matrix.goos }}-${{ matrix.goarch }}* + mouse-${{ matrix.goos }}-${{ matrix.goarch }}* + window-${{ matrix.goos }}-${{ matrix.goarch }}* + apps-${{ matrix.goos }}-${{ matrix.goarch }}* + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No artifacts found to upload" + exit 1 + fi + for file in "${files[@]}"; do + if [ -f "$file" ]; then + filename=$(basename "$file") + relative_path="${repo_name}/${date_str}/${commit_hash}/${filename}" + echo "Uploading ${filename}..." + for attempt in 1 2 3; do + if curl --http1.1 -f --max-time 300 --silent --output /dev/null \ + -u "${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" \ + -F "path=@${file}" \ + "${server_url}/upload?path=${base_path}"; then + echo "${relative_path}" + break + else + echo "Attempt ${attempt} failed for ${filename}" + if [ "$attempt" -eq 3 ]; then + echo "Failed to upload ${filename} after 3 attempts" + exit 1 + fi + sleep 5 + fi + done + fi + done diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml index 7e9a281..2dafcd0 100644 --- a/.github/workflows/build-tester.yml +++ b/.github/workflows/build-tester.yml @@ -65,9 +65,55 @@ jobs: go build -v -o "deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./cmd/deskact-tester - name: Upload artifact + id: upload_github_artifact uses: actions/upload-artifact@v4 + continue-on-error: true with: name: deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }} path: | deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}* - retention-days: 30 + retention-days: 7 + + - name: Upload artifact to server + shell: bash + continue-on-error: true + env: + ARTIFACT_SERVER_URL: ${{ secrets.ARTIFACT_SERVER_URL }} + ARTIFACT_USER: ${{ secrets.ARTIFACT_SERVER_USER }} + ARTIFACT_PASSWORD: ${{ secrets.ARTIFACT_SERVER_PASSWORD }} + run: | + set -euo pipefail + repo_name="${GITHUB_REPOSITORY##*/}" + date_str="$(date -u +%Y-%m-%d)" + commit_hash="${GITHUB_SHA}" + base_path="/${repo_name}/${date_str}/${commit_hash}/" + server_url="${ARTIFACT_SERVER_URL%/}" + shopt -s nullglob + files=(deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}*) + if [ ${#files[@]} -eq 0 ]; then + echo "No artifacts found to upload" + exit 1 + fi + for file in "${files[@]}"; do + if [ -f "$file" ]; then + filename=$(basename "$file") + relative_path="${repo_name}/${date_str}/${commit_hash}/${filename}" + echo "Uploading ${filename}..." + for attempt in 1 2 3; do + if curl --http1.1 -f --max-time 300 --silent --output /dev/null \ + -u "${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" \ + -F "path=@${file}" \ + "${server_url}/upload?path=${base_path}"; then + echo "${relative_path}" + break + else + echo "Attempt ${attempt} failed for ${filename}" + if [ "$attempt" -eq 3 ]; then + echo "Failed to upload ${filename} after 3 attempts" + exit 1 + fi + sleep 5 + fi + done + fi + done From 6487990de214952efee31a43e8bd8a3e97fb0a35 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:15:29 +0800 Subject: [PATCH 14/37] Fix MSI icon lookup and enhance app logs - fall back to MSI ProductIcon/InstallLocation when uninstall metadata is empty - include per-app name/path/icon status in output logs --- apps/apps_windows.go | 126 +++++++++++++++++++++++++++++++++++++++++- examples/apps/main.go | 14 +++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/apps_windows.go b/apps/apps_windows.go index 0b04293..17ad225 100644 --- a/apps/apps_windows.go +++ b/apps/apps_windows.go @@ -7,6 +7,7 @@ import ( "image" "os" "path/filepath" + "regexp" "strconv" "strings" "syscall" @@ -76,6 +77,7 @@ var ( " uninstall", " setup", } + msiProductCodePattern = regexp.MustCompile(`(?i)\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}`) ) type IShellLinkW struct { @@ -531,10 +533,23 @@ func appFromUninstallKey(key registry.Key) (AppInfo, error) { } } + msiCode := msiProductCodeFromStrings(uninstallString, quietUninstallString, modifyPath) + if (iconPath == "" || appPath == "") && msiCode != "" { + msiIconPath, msiInstallLocation, _ := msiProductInfo(msiCode) + if iconPath == "" && msiIconPath != "" { + iconPath, iconIndex, hasIndex = parseIconLocation(msiIconPath) + iconPath = normalizePath(iconPath) + } + if appPath == "" && msiInstallLocation != "" { + appPath = normalizePath(msiInstallLocation) + } + } + var icon *image.RGBA var iconErr error if iconPath != "" { - icon, iconErr = iconFromFile(iconPath, iconIndex, hasIndex) + forceIndex := hasIndex || filepath.Ext(iconPath) == "" + icon, iconErr = iconFromFile(iconPath, iconIndex, forceIndex) } else if appPath != "" { icon, iconErr = iconFromFile(appPath, 0, false) } else { @@ -763,6 +778,115 @@ func normalizeAppsFolderPath(path string) string { return path } +func msiProductCodeFromStrings(values ...string) string { + for _, value := range values { + code := extractMsiProductCode(value) + if code != "" { + return code + } + } + return "" +} + +func extractMsiProductCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + match := msiProductCodePattern.FindString(value) + if match == "" { + return "" + } + return strings.ToUpper(match) +} + +func msiProductInfo(productCode string) (string, string, error) { + productCode = strings.TrimSpace(productCode) + if productCode == "" { + return "", "", nil + } + packed := packMsiProductCode(productCode) + if packed == "" { + return "", "", errors.New("invalid MSI product code") + } + + var errs []error + iconPath, err := readRegistryString(registry.LOCAL_MACHINE, `Software\Classes\Installer\Products\`+packed, "ProductIcon") + if err != nil { + errs = append(errs, err) + } + if iconPath == "" { + iconPath, err = readRegistryString(registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\`+packed+`\InstallProperties`, "DisplayIcon") + if err != nil { + errs = append(errs, err) + } + } + + installLocation, err := readRegistryString(registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\`+packed+`\InstallProperties`, "InstallLocation") + if err != nil { + errs = append(errs, err) + } + if installLocation == "" { + installLocation, err = readRegistryString(registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\`+packed+`\InstallProperties`, "InstallSource") + if err != nil { + errs = append(errs, err) + } + } + + return iconPath, installLocation, joinErrors(errs) +} + +func packMsiProductCode(code string) string { + code = strings.TrimSpace(code) + code = strings.Trim(code, "{}") + code = strings.ReplaceAll(code, "-", "") + if len(code) != 32 { + return "" + } + part1 := reverseString(code[0:8]) + part2 := reverseString(code[8:12]) + part3 := reverseString(code[12:16]) + part4 := swapNibbles(code[16:20]) + part5 := swapNibbles(code[20:32]) + return strings.ToUpper(part1 + part2 + part3 + part4 + part5) +} + +func reverseString(value string) string { + b := []byte(value) + for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } + return string(b) +} + +func swapNibbles(value string) string { + b := []byte(value) + for i := 0; i+1 < len(b); i += 2 { + b[i], b[i+1] = b[i+1], b[i] + } + return string(b) +} + +func readRegistryString(root registry.Key, path string, name string) (string, error) { + key, err := registry.OpenKey(root, path, registry.READ) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", nil + } + return "", err + } + defer key.Close() + + value, _, err := key.GetStringValue(name) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", nil + } + return "", err + } + return value, nil +} + func shouldSkipDesktopExe(path string) bool { base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) return isInstallerName(base) diff --git a/examples/apps/main.go b/examples/apps/main.go index d566c6e..c26338c 100644 --- a/examples/apps/main.go +++ b/examples/apps/main.go @@ -962,6 +962,20 @@ func writeLog(dir string, group appGroup, apps []appEntry, missing int, saveErr if pdfErr != nil { sb.WriteString(fmt.Sprintf("PDF errors: %v\n", pdfErr)) } + sb.WriteString("\nApps:\n") + for _, app := range apps { + iconStatus := "no" + iconPath := "" + if app.HasIcon { + iconStatus = "yes" + iconPath = app.IconPath + } + sb.WriteString(fmt.Sprintf("- Name: %s | Icon: %s | Path: %s", app.Name, iconStatus, app.Path)) + if iconPath != "" { + sb.WriteString(fmt.Sprintf(" | IconPath: %s", iconPath)) + } + sb.WriteString("\n") + } path := filepath.Join(dir, logFileName) return os.WriteFile(path, []byte(sb.String()), 0644) } From f62bba3c03e3beed35d76f862fa89e63dfaeebf3 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:27:28 +0800 Subject: [PATCH 15/37] Handle non-BMP Unicode input on macOS and Windows - emit surrogate pairs for code points above U+FFFF on Windows - pass 1-2 UTF-16 code units to CGEvent Unicode input on macOS --- key/keypress_c_macos.h | 21 ++++++++++++++++----- key/keypress_c_windows.h | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/key/keypress_c_macos.h b/key/keypress_c_macos.h index 4ddafc7..025a4b2 100644 --- a/key/keypress_c_macos.h +++ b/key/keypress_c_macos.h @@ -235,7 +235,7 @@ void toggleKey(char c, const bool down, MMKeyFlags flags, uintptr pid) { } } -void toggleUnicode(UniChar ch, const bool down, uintptr pid) { +void toggleUnicode(const UniChar *chars, size_t len, const bool down, uintptr pid) { CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, 0, down); if (keyEvent == NULL) { @@ -244,16 +244,27 @@ void toggleUnicode(UniChar ch, const bool down, uintptr pid) { return; } - CGEventKeyboardSetUnicodeString(keyEvent, 1, &ch); + CGEventKeyboardSetUnicodeString(keyEvent, (UniCharCount)len, chars); SendTo(pid, keyEvent); CFRelease(source); } void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { - UniChar ch = (UniChar)value; - toggleUnicode(ch, true, pid); + UniChar chars[2]; + size_t len = 1; + + if (value > 0xFFFF) { + uint32_t v = (uint32_t)value - 0x10000; + chars[0] = (UniChar)(0xD800 + (v >> 10)); + chars[1] = (UniChar)(0xDC00 + (v & 0x3FF)); + len = 2; + } else { + chars[0] = (UniChar)value; + } + + toggleUnicode(chars, len, true, pid); microsleep(5.0); - toggleUnicode(ch, false, pid); + toggleUnicode(chars, len, false, pid); } int input_utf(const char *utf) { diff --git a/key/keypress_c_windows.h b/key/keypress_c_windows.h index bf84596..c01de3f 100644 --- a/key/keypress_c_windows.h +++ b/key/keypress_c_windows.h @@ -233,10 +233,50 @@ void toggleKey(char c, const bool down, MMKeyFlags flags, uintptr pid) { void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { if (pid != 0) { HWND hwnd = getHwnd(pid, isPid); + if (value > 0xFFFF) { + uint32_t v = (uint32_t)value - 0x10000; + WCHAR hi = (WCHAR)(0xD800 + (v >> 10)); + WCHAR lo = (WCHAR)(0xDC00 + (v & 0x3FF)); + PostMessageW(hwnd, WM_CHAR, hi, 0); + PostMessageW(hwnd, WM_CHAR, lo, 0); + return; + } PostMessageW(hwnd, WM_CHAR, value, 0); return; } + if (value > 0xFFFF) { + uint32_t v = (uint32_t)value - 0x10000; + WORD hi = (WORD)(0xD800 + (v >> 10)); + WORD lo = (WORD)(0xDC00 + (v & 0x3FF)); + + INPUT input[4]; + memset(input, 0, sizeof(input)); + + input[0].type = INPUT_KEYBOARD; + input[0].ki.wVk = 0; + input[0].ki.wScan = hi; + input[0].ki.dwFlags = 0x4; // KEYEVENTF_UNICODE + + input[1].type = INPUT_KEYBOARD; + input[1].ki.wVk = 0; + input[1].ki.wScan = hi; + input[1].ki.dwFlags = KEYEVENTF_KEYUP | 0x4; + + input[2].type = INPUT_KEYBOARD; + input[2].ki.wVk = 0; + input[2].ki.wScan = lo; + input[2].ki.dwFlags = 0x4; // KEYEVENTF_UNICODE + + input[3].type = INPUT_KEYBOARD; + input[3].ki.wVk = 0; + input[3].ki.wScan = lo; + input[3].ki.dwFlags = KEYEVENTF_KEYUP | 0x4; + + SendInput(4, input, sizeof(INPUT)); + return; + } + INPUT input[2]; memset(input, 0, sizeof(input)); From 8151c998ec63912c7764fe4176899157fffa9419 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:32:02 +0800 Subject: [PATCH 16/37] Fix macOS alias resolution and type conversions - switch alias resolution to NSURL APIs with autorelease pool - match IOHIDSystem service by class name string to avoid header dependency - cast window layer to int32 for platform info struct --- apps/apps_darwin.go | 70 +++++++++++++++++------------------ keyboardstate/state_darwin.go | 4 +- window/list_windows_darwin.go | 2 +- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/apps/apps_darwin.go b/apps/apps_darwin.go index 8a9375c..6590121 100644 --- a/apps/apps_darwin.go +++ b/apps/apps_darwin.go @@ -12,44 +12,42 @@ package apps #include static char *resolve_alias_path(const char *path) { - if (!path) { - return NULL; - } - CFURLRef url = CFURLCreateFromFileSystemRepresentation(kCFAllocatorDefault, (const UInt8 *)path, (CFIndex)strlen(path), false); - if (!url) { - return NULL; - } - Boolean wasAliased = false; - CFErrorRef error = NULL; - CFURLRef resolved = CFURLCreateByResolvingAliasFile(kCFAllocatorDefault, url, 0, &wasAliased, &error); - CFRelease(url); - if (!resolved) { - if (error) { - CFRelease(error); + @autoreleasepool { + if (!path) { + return NULL; } - return NULL; - } - if (!wasAliased) { - CFRelease(resolved); - return NULL; - } - CFStringRef cfPath = CFURLCopyFileSystemPath(resolved, kCFURLPOSIXPathStyle); - CFRelease(resolved); - if (!cfPath) { - return NULL; - } - CFIndex maxSize = CFStringGetMaximumSizeForEncoding(CFStringGetLength(cfPath), kCFStringEncodingUTF8) + 1; - char *buffer = (char *)malloc(maxSize); - if (!buffer) { - CFRelease(cfPath); - return NULL; - } - if (!CFStringGetCString(cfPath, buffer, maxSize, kCFStringEncodingUTF8)) { - free(buffer); - buffer = NULL; + NSString *nsPath = [NSString stringWithUTF8String:path]; + if (!nsPath) { + return NULL; + } + NSURL *url = [NSURL fileURLWithPath:nsPath]; + if (!url) { + return NULL; + } + + NSNumber *isAlias = nil; + NSError *error = nil; + if (![url getResourceValue:&isAlias forKey:NSURLIsAliasFileKey error:&error]) { + return NULL; + } + if (![isAlias boolValue]) { + return NULL; + } + + NSURL *resolved = [NSURL URLByResolvingAliasFileAtURL:url options:0 error:&error]; + if (!resolved) { + return NULL; + } + NSString *resolvedPath = [resolved path]; + if (!resolvedPath) { + return NULL; + } + const char *resolvedC = [resolvedPath fileSystemRepresentation]; + if (!resolvedC) { + return NULL; + } + return strdup(resolvedC); } - CFRelease(cfPath); - return buffer; } static unsigned char *icon_rgba_for_path(const char *path, int *width, int *height) { diff --git a/keyboardstate/state_darwin.go b/keyboardstate/state_darwin.go index 88de1b9..3b26b0f 100644 --- a/keyboardstate/state_darwin.go +++ b/keyboardstate/state_darwin.go @@ -12,7 +12,6 @@ package keyboardstate #include #include #include -#include static uint64_t deskactKeyFlagsState(void) { return (uint64_t)CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); @@ -25,7 +24,7 @@ static int deskactGetLockStates(bool *caps, bool *num, bool *capsOk, bool *numOk *numOk = false; io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, - IOServiceMatching(kIOHIDSystemClass)); + IOServiceMatching("IOHIDSystem")); if (service == 0) { return -1; } @@ -96,4 +95,3 @@ func currentState() (stateSnapshot, error) { return state, nil } - diff --git a/window/list_windows_darwin.go b/window/list_windows_darwin.go index 797efae..a216068 100644 --- a/window/list_windows_darwin.go +++ b/window/list_windows_darwin.go @@ -216,7 +216,7 @@ func listWindows(options WindowOptions) ([]WindowInfo, error) { platform: &DarwinPlatformInfo{ WindowID: uint32(item.windowID), OwnerName: ownerName, - Layer: item.layer, + Layer: int32(item.layer), Alpha: float64(item.alpha), Onscreen: onScreen, }, From 4cd305aa0855f7aac821fa0fefbfcd912d3345e2 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:00:45 +0800 Subject: [PATCH 17/37] feat(examples): extend keyboard demo and CI build - add keyboard state display, key list, and delayed key tap - build keyboard example in CI artifacts --- .github/workflows/build-examples.yml | 3 + examples/keyboard/main.go | 361 +++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 examples/keyboard/main.go diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 642ad74..9a1afac 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -64,6 +64,7 @@ jobs: fi go build -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture go build -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display + go build -v -o "keyboard-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/keyboard go build -v -o "mouse-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/mouse go build -v -o "window-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/window go build -v -o "apps-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/apps @@ -77,6 +78,7 @@ jobs: path: | capture-${{ matrix.goos }}-${{ matrix.goarch }}* display-${{ matrix.goos }}-${{ matrix.goarch }}* + keyboard-${{ matrix.goos }}-${{ matrix.goarch }}* mouse-${{ matrix.goos }}-${{ matrix.goarch }}* window-${{ matrix.goos }}-${{ matrix.goarch }}* apps-${{ matrix.goos }}-${{ matrix.goarch }}* @@ -100,6 +102,7 @@ jobs: files=( capture-${{ matrix.goos }}-${{ matrix.goarch }}* display-${{ matrix.goos }}-${{ matrix.goarch }}* + keyboard-${{ matrix.goos }}-${{ matrix.goarch }}* mouse-${{ matrix.goos }}-${{ matrix.goarch }}* window-${{ matrix.goos }}-${{ matrix.goarch }}* apps-${{ matrix.goos }}-${{ matrix.goarch }}* diff --git a/examples/keyboard/main.go b/examples/keyboard/main.go new file mode 100644 index 0000000..9f5c919 --- /dev/null +++ b/examples/keyboard/main.go @@ -0,0 +1,361 @@ +package main + +import ( + "bufio" + "fmt" + "math/rand" + "os" + "runtime" + "strconv" + "strings" + "time" + + deskact "github.com/PekingSpades/DeskAct" +) + +const ( + chineseUnit = 2 + englishUnit = 1 + emojiUnit = 1 +) + +var commandCleaner = strings.NewReplacer(" ", "", "-", "", "_", "") + +type category struct { + name string + length int + runes []rune +} + +var ( + chineseRunes = []rune{ + '\u4f60', '\u597d', '\u4e16', '\u754c', '\u4e2d', + '\u56fd', '\u6587', '\u5b57', '\u6d4b', '\u8bd5', + } + englishRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + emojiRunes = []rune{ + '\U0001F600', '\U0001F60A', '\U0001F44D', + '\U0001F680', '\U0001F389', '\U0001F31F', + } +) + +func main() { + fmt.Println("========================================") + fmt.Println("DeskAct Keyboard Example") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("========================================") + fmt.Println("Note: input is sent to the active window.") + + reader := bufio.NewReader(os.Stdin) + settings := deskact.DefaultKeyboardSettings() + + for { + printMenu() + fmt.Print("Select command: ") + cmd := canonicalize(readLine(reader)) + if cmd == "" { + continue + } + if cmd == "q" || cmd == "quit" || cmd == "exit" { + break + } + + action, ok := normalizeCommand(cmd) + if !ok { + fmt.Println("Unknown command.") + continue + } + + switch action { + case "type": + runType(reader, settings) + case "state": + runState() + case "list_keys": + runListKeys() + case "delay_tap": + runDelayTap(reader, settings) + default: + fmt.Println("Unknown command.") + } + } + + fmt.Println("Done.") +} + +func printMenu() { + fmt.Println("\nCommands:") + fmt.Println(" 1) type") + fmt.Println(" 2) state") + fmt.Println(" 3) list supported keys") + fmt.Println(" 4) delay key tap") + fmt.Println(" q) exit") +} + +func canonicalize(input string) string { + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return "" + } + return commandCleaner.Replace(input) +} + +func normalizeCommand(cmd string) (string, bool) { + switch cmd { + case "1", "type": + return "type", true + case "2", "state", "status", "keyboardstate": + return "state", true + case "3", "keys", "listkeys", "supported", "supportedkeys": + return "list_keys", true + case "4", "tap", "delaytap", "delay", "keytap": + return "delay_tap", true + default: + return "", false + } +} + +func readLine(reader *bufio.Reader) string { + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +func readYesNo(reader *bufio.Reader, prompt string) bool { + for { + fmt.Print(prompt) + input := strings.TrimSpace(strings.ToLower(readLine(reader))) + switch input { + case "y", "yes": + return true + case "n", "no": + return false + default: + fmt.Println("Please enter y or n.") + } + } +} + +func readIntMin(reader *bufio.Reader, prompt string, minValue int) int { + for { + fmt.Print(prompt) + line := readLine(reader) + if line == "" { + fmt.Printf("Please enter a value >= %d.\n", minValue) + continue + } + value, err := strconv.Atoi(line) + if err != nil { + fmt.Println("Please enter a valid integer.") + continue + } + if value < minValue { + fmt.Printf("Please enter a value >= %d.\n", minValue) + continue + } + return value + } +} + +func runType(reader *bufio.Reader, settings deskact.KeyboardSettings) { + includeChinese := readYesNo(reader, "Include Chinese? (y/n): ") + includeEnglish := readYesNo(reader, "Include English? (y/n): ") + includeEmoji := readYesNo(reader, "Include Emoji? (y/n): ") + if !includeChinese && !includeEnglish && !includeEmoji { + fmt.Println("At least one category must be selected.") + return + } + + lengthUnits := readIntMin(reader, "Length (Chinese=2, English=1, Emoji=1): ", 1) + delayMs := readIntMin(reader, "Delay before typing (ms): ", 0) + + cats := buildCategories(includeChinese, includeEnglish, includeEmoji) + if includeChinese && !includeEnglish && !includeEmoji && lengthUnits%2 == 1 { + fmt.Printf("Length %d adjusted to %d because Chinese counts as 2.\n", lengthUnits, lengthUnits+1) + lengthUnits++ + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + text, units, err := buildText(lengthUnits, cats, rng) + if err != nil { + fmt.Printf("Failed to build text: %v\n", err) + return + } + + fmt.Printf("Generated text (units=%d): %s\n", units, text) + waitDelay(delayMs) + deskact.Type(text, 0, settings) + fmt.Println("Type done.") +} + +func buildCategories(includeChinese, includeEnglish, includeEmoji bool) []category { + cats := make([]category, 0, 3) + if includeChinese { + cats = append(cats, category{name: "chinese", length: chineseUnit, runes: chineseRunes}) + } + if includeEnglish { + cats = append(cats, category{name: "english", length: englishUnit, runes: englishRunes}) + } + if includeEmoji { + cats = append(cats, category{name: "emoji", length: emojiUnit, runes: emojiRunes}) + } + return cats +} + +func buildText(target int, cats []category, rng *rand.Rand) (string, int, error) { + if target <= 0 { + return "", 0, fmt.Errorf("length must be > 0") + } + if len(cats) == 0 { + return "", 0, fmt.Errorf("no categories selected") + } + + var b strings.Builder + remaining := target + + for remaining > 0 { + var fit []category + for _, c := range cats { + if c.length <= remaining { + fit = append(fit, c) + } + } + if len(fit) == 0 { + return b.String(), target - remaining, fmt.Errorf("cannot satisfy length %d with current selection", target) + } + + cat := fit[rng.Intn(len(fit))] + r := cat.runes[rng.Intn(len(cat.runes))] + b.WriteRune(r) + remaining -= cat.length + } + + return b.String(), target, nil +} + +func waitDelay(delay int) { + if delay <= 0 { + return + } + fmt.Printf("Waiting %d ms...\n", delay) + deskact.MilliSleep(delay) +} + +func waitDelaySeconds(seconds int) { + if seconds <= 0 { + return + } + fmt.Printf("Waiting %d second(s)...\n", seconds) + deskact.MilliSleep(seconds * 1000) +} + +func runState() { + snapshot, err := deskact.KeyboardStateCurrent() + if err != nil { + fmt.Printf("Keyboard state error: %v\n", err) + return + } + fmt.Println("Keyboard state:") + fmt.Printf(" Shift: %s\n", pressStateLabel(snapshot.Shift)) + fmt.Printf(" Ctrl: %s\n", pressStateLabel(snapshot.Ctrl)) + fmt.Printf(" Alt: %s\n", pressStateLabel(snapshot.Alt)) + fmt.Printf(" Cmd: %s\n", pressStateLabel(snapshot.Cmd)) + fmt.Printf(" CapsLock: %s\n", toggleStateLabel(snapshot.CapsLock)) + fmt.Printf(" NumLock: %s\n", toggleStateLabel(snapshot.NumLock)) + fmt.Printf(" ScrollLock: %s\n", toggleStateLabel(snapshot.ScrollLock)) +} + +func runListKeys() { + keys := deskact.SupportedKeyNames() + if len(keys) == 0 { + fmt.Println("No supported keys reported for this platform.") + return + } + fmt.Printf("Supported key names (%d):\n", len(keys)) + printWrappedList(keys, 90) +} + +func runDelayTap(reader *bufio.Reader, settings deskact.KeyboardSettings) { + keys := deskact.SupportedKeyNames() + if len(keys) == 0 { + fmt.Println("No supported keys reported for this platform.") + return + } + fmt.Println("Supported key names:") + printWrappedList(keys, 90) + + seconds := readIntMin(reader, "Delay before key tap (seconds): ", 0) + fmt.Print("Key to tap: ") + key := normalizeKeyInput(readLine(reader)) + if key == "" { + fmt.Println("Key cannot be empty.") + return + } + + waitDelaySeconds(seconds) + if err := deskact.KeyTap(key, nil, settings); err != nil { + fmt.Printf("Key tap error: %v\n", err) + return + } + fmt.Println("Key tap done.") +} + +func normalizeKeyInput(key string) string { + if key == "" { + return "" + } + if len([]rune(key)) == 1 { + return key + } + return strings.ToLower(key) +} + +func pressStateLabel(state deskact.KeyboardPressState) string { + switch state { + case deskact.KeyboardPressUp: + return "up" + case deskact.KeyboardPressDown: + return "down" + case deskact.KeyboardPressUnsupported: + return "unsupported" + default: + return fmt.Sprintf("unknown(%d)", state) + } +} + +func toggleStateLabel(state deskact.KeyboardToggleState) string { + switch state { + case deskact.KeyboardToggleOff: + return "off" + case deskact.KeyboardToggleOn: + return "on" + case deskact.KeyboardToggleUnsupported: + return "unsupported" + default: + return fmt.Sprintf("unknown(%d)", state) + } +} + +func printWrappedList(items []string, maxLine int) { + if maxLine < 20 { + maxLine = 20 + } + var line strings.Builder + for _, item := range items { + if line.Len() == 0 { + line.WriteString(item) + continue + } + if line.Len()+2+len(item) > maxLine { + fmt.Println(line.String()) + line.Reset() + line.WriteString(item) + continue + } + line.WriteString(", ") + line.WriteString(item) + } + if line.Len() > 0 { + fmt.Println(line.String()) + } +} From 414338198d6edef785e48991b9f7d2bc4f39ebb6 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:36:56 +0800 Subject: [PATCH 18/37] Fix darwin build: clean import and handle C.bool --- apps/apps_darwin.go | 1 - keyboardstate/state_darwin.go | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/apps_darwin.go b/apps/apps_darwin.go index 6590121..b89c84c 100644 --- a/apps/apps_darwin.go +++ b/apps/apps_darwin.go @@ -125,7 +125,6 @@ static void free_icon_data(void *data) { import "C" import ( - "errors" "image" "io/fs" "os" diff --git a/keyboardstate/state_darwin.go b/keyboardstate/state_darwin.go index 3b26b0f..a32d363 100644 --- a/keyboardstate/state_darwin.go +++ b/keyboardstate/state_darwin.go @@ -79,15 +79,15 @@ func currentState() (stateSnapshot, error) { var capsOk C.bool var numOk C.bool if C.deskactGetLockStates(&caps, &num, &capsOk, &numOk) == 0 { - if capsOk != 0 { + if bool(capsOk) { state.mask &^= bitCapsLock - if caps != 0 { + if bool(caps) { state.mask |= bitCapsLock } } - if numOk != 0 { + if bool(numOk) { state.supported |= bitNumLock - if num != 0 { + if bool(num) { state.mask |= bitNumLock } } From 687877eec7e367bb3c470646c64829baef396bc5 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:29:34 +0800 Subject: [PATCH 19/37] Fix linux build: include stdint.h for uint32_t --- keyboardstate/state_x11.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keyboardstate/state_x11.go b/keyboardstate/state_x11.go index 914c413..46abc83 100644 --- a/keyboardstate/state_x11.go +++ b/keyboardstate/state_x11.go @@ -8,6 +8,7 @@ package keyboardstate #cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst #include "../base/xdisplay_c.h" +#include #include #include @@ -127,4 +128,3 @@ func currentState() (stateSnapshot, error) { state.supported = uint32(supported) return state, nil } - From 29de06dd8f8d2cc997f8ee6a72df336c2d364295 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:35:31 +0800 Subject: [PATCH 20/37] Fix CI artifact uploads (mkdir + 3xx handling) --- .github/workflows/build-examples.yml | 38 ++++++++++++++++++++++++---- .github/workflows/build-tester.yml | 38 ++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 9a1afac..7d18a6d 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -98,6 +98,36 @@ jobs: commit_hash="${GITHUB_SHA}" base_path="/${repo_name}/${date_str}/${commit_hash}/" server_url="${ARTIFACT_SERVER_URL%/}" + auth="${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" + curl_status() { + curl --http1.1 --silent --output /dev/null -w "%{http_code}" "$@" + } + ensure_dir() { + local dir="$1" + local url="${server_url}${dir}" + local status + status=$(curl_status -u "${auth}" "${url}") + if [[ "${status}" == 2* ]]; then + echo "Dir exists: ${dir}" + return 0 + fi + if [[ "${status}" != "404" ]]; then + echo "Unexpected status ${status} when checking ${dir}" + return 1 + fi + local target="${dir%/}" + local parent="${target%/*}/" + local name="${target##*/}" + echo "Creating dir: ${dir}" + status=$(curl_status -u "${auth}" -F "mkdir=${name}" "${server_url}/upload?path=${parent}") + if [[ "${status}" != 2* && "${status}" != 3* ]]; then + echo "Failed to create ${dir} (HTTP ${status})" + return 1 + fi + } + ensure_dir "/${repo_name}/" + ensure_dir "/${repo_name}/${date_str}/" + ensure_dir "/${repo_name}/${date_str}/${commit_hash}/" shopt -s nullglob files=( capture-${{ matrix.goos }}-${{ matrix.goarch }}* @@ -117,14 +147,12 @@ jobs: relative_path="${repo_name}/${date_str}/${commit_hash}/${filename}" echo "Uploading ${filename}..." for attempt in 1 2 3; do - if curl --http1.1 -f --max-time 300 --silent --output /dev/null \ - -u "${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" \ - -F "path=@${file}" \ - "${server_url}/upload?path=${base_path}"; then + status=$(curl_status --max-time 300 -u "${auth}" -F "file=@${file}" "${server_url}/upload?path=${base_path}") + if [[ "${status}" == 2* || "${status}" == 3* ]]; then echo "${relative_path}" break else - echo "Attempt ${attempt} failed for ${filename}" + echo "Attempt ${attempt} failed for ${filename} (HTTP ${status})" if [ "$attempt" -eq 3 ]; then echo "Failed to upload ${filename} after 3 attempts" exit 1 diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml index 2dafcd0..8f7b8f2 100644 --- a/.github/workflows/build-tester.yml +++ b/.github/workflows/build-tester.yml @@ -88,6 +88,36 @@ jobs: commit_hash="${GITHUB_SHA}" base_path="/${repo_name}/${date_str}/${commit_hash}/" server_url="${ARTIFACT_SERVER_URL%/}" + auth="${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" + curl_status() { + curl --http1.1 --silent --output /dev/null -w "%{http_code}" "$@" + } + ensure_dir() { + local dir="$1" + local url="${server_url}${dir}" + local status + status=$(curl_status -u "${auth}" "${url}") + if [[ "${status}" == 2* ]]; then + echo "Dir exists: ${dir}" + return 0 + fi + if [[ "${status}" != "404" ]]; then + echo "Unexpected status ${status} when checking ${dir}" + return 1 + fi + local target="${dir%/}" + local parent="${target%/*}/" + local name="${target##*/}" + echo "Creating dir: ${dir}" + status=$(curl_status -u "${auth}" -F "mkdir=${name}" "${server_url}/upload?path=${parent}") + if [[ "${status}" != 2* && "${status}" != 3* ]]; then + echo "Failed to create ${dir} (HTTP ${status})" + return 1 + fi + } + ensure_dir "/${repo_name}/" + ensure_dir "/${repo_name}/${date_str}/" + ensure_dir "/${repo_name}/${date_str}/${commit_hash}/" shopt -s nullglob files=(deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}*) if [ ${#files[@]} -eq 0 ]; then @@ -100,14 +130,12 @@ jobs: relative_path="${repo_name}/${date_str}/${commit_hash}/${filename}" echo "Uploading ${filename}..." for attempt in 1 2 3; do - if curl --http1.1 -f --max-time 300 --silent --output /dev/null \ - -u "${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" \ - -F "path=@${file}" \ - "${server_url}/upload?path=${base_path}"; then + status=$(curl_status --max-time 300 -u "${auth}" -F "file=@${file}" "${server_url}/upload?path=${base_path}") + if [[ "${status}" == 2* || "${status}" == 3* ]]; then echo "${relative_path}" break else - echo "Attempt ${attempt} failed for ${filename}" + echo "Attempt ${attempt} failed for ${filename} (HTTP ${status})" if [ "$attempt" -eq 3 ]; then echo "Failed to upload ${filename} after 3 attempts" exit 1 From 850518dce52c890b4d6039e02991aadbe7d41dbb Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:45:11 +0800 Subject: [PATCH 21/37] chore(ci): force Go rebuilds to avoid stale C cache artifacts --- .github/workflows/build-examples.yml | 12 ++++++------ .github/workflows/build-tester.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 7d18a6d..efeebb3 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -62,12 +62,12 @@ jobs: if [ "${{ matrix.goos }}" = "windows" ]; then suffix=".exe" fi - go build -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture - go build -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display - go build -v -o "keyboard-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/keyboard - go build -v -o "mouse-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/mouse - go build -v -o "window-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/window - go build -v -o "apps-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/apps + go build -a -v -o "capture-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/capture + go build -a -v -o "display-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/display + go build -a -v -o "keyboard-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/keyboard + go build -a -v -o "mouse-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/mouse + go build -a -v -o "window-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/window + go build -a -v -o "apps-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./examples/apps - name: Upload artifacts id: upload_github_artifacts diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml index 8f7b8f2..b5a5d80 100644 --- a/.github/workflows/build-tester.yml +++ b/.github/workflows/build-tester.yml @@ -62,7 +62,7 @@ jobs: if [ "${{ matrix.goos }}" = "windows" ]; then suffix=".exe" fi - go build -v -o "deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./cmd/deskact-tester + go build -a -v -o "deskact-tester-${{ matrix.goos }}-${{ matrix.goarch }}${suffix}" ./cmd/deskact-tester - name: Upload artifact id: upload_github_artifact From a3f56cc6632ced26b844362fd3f88802d31fd28b Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:38:53 +0800 Subject: [PATCH 22/37] ci: use PR head sha for server upload paths --- .github/workflows/build-examples.yml | 3 ++- .github/workflows/build-tester.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index efeebb3..fa775fd 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -91,11 +91,12 @@ jobs: ARTIFACT_SERVER_URL: ${{ secrets.ARTIFACT_SERVER_URL }} ARTIFACT_USER: ${{ secrets.ARTIFACT_SERVER_USER }} ARTIFACT_PASSWORD: ${{ secrets.ARTIFACT_SERVER_PASSWORD }} + COMMIT_HASH: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail repo_name="${GITHUB_REPOSITORY##*/}" date_str="$(date -u +%Y-%m-%d)" - commit_hash="${GITHUB_SHA}" + commit_hash="${COMMIT_HASH}" base_path="/${repo_name}/${date_str}/${commit_hash}/" server_url="${ARTIFACT_SERVER_URL%/}" auth="${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml index b5a5d80..6b31df3 100644 --- a/.github/workflows/build-tester.yml +++ b/.github/workflows/build-tester.yml @@ -81,11 +81,12 @@ jobs: ARTIFACT_SERVER_URL: ${{ secrets.ARTIFACT_SERVER_URL }} ARTIFACT_USER: ${{ secrets.ARTIFACT_SERVER_USER }} ARTIFACT_PASSWORD: ${{ secrets.ARTIFACT_SERVER_PASSWORD }} + COMMIT_HASH: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail repo_name="${GITHUB_REPOSITORY##*/}" date_str="$(date -u +%Y-%m-%d)" - commit_hash="${GITHUB_SHA}" + commit_hash="${COMMIT_HASH}" base_path="/${repo_name}/${date_str}/${commit_hash}/" server_url="${ARTIFACT_SERVER_URL%/}" auth="${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" From d7263ed1dcb18052b7ed630bb2fb2acdddb9f01f Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:03:17 +0800 Subject: [PATCH 23/37] feat(display): add ElectronID field compatible with Electron screen API Add electronId to Display that matches the id returned by Electron's screen.getAllDisplays(), enabling direct ID interop between DeskAct and Electron apps. Platform implementations: - Windows: QueryDisplayConfig + SuperFastHash of adapter/target info - macOS: CGDirectDisplayID cast (already Electron-compatible) - Linux: XRandR EDID parsing (manufacturer_id, display_name hash, output_id) Also add an Electron example app (examples/display/electron/) with GUI window for verifying ID match, and a CI workflow to build it for win-x64 and mac-arm64. --- .github/workflows/build-electron-display.yml | 142 ++++++++++++++++++ .gitignore | 9 +- display/display.go | 24 ++- display/display_c_macos.h | 2 + display/display_c_windows.h | 84 ++++++++++- display/display_c_x11.h | 145 +++++++++++++++++++ display/display_darwin.go | 45 +++--- display/display_linux.go | 47 +++--- display/display_windows.go | 45 +++--- display/superfasthash.h | 98 +++++++++++++ examples/display/electron/index.html | 97 +++++++++++++ examples/display/electron/main.js | 78 ++++++++++ examples/display/electron/package.json | 39 +++++ examples/display/main.go | 15 +- 14 files changed, 787 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/build-electron-display.yml create mode 100644 display/superfasthash.h create mode 100644 examples/display/electron/index.html create mode 100644 examples/display/electron/main.js create mode 100644 examples/display/electron/package.json diff --git a/.github/workflows/build-electron-display.yml b/.github/workflows/build-electron-display.yml new file mode 100644 index 0000000..4da7631 --- /dev/null +++ b/.github/workflows/build-electron-display.yml @@ -0,0 +1,142 @@ +name: Build Electron Display + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - os: windows-latest + platform: win + arch: x64 + artifact-name: electron-display-win-x64 + - os: macos-15 + platform: mac + arch: arm64 + artifact-name: electron-display-mac-arm64 + + runs-on: ${{ matrix.os }} + + defaults: + run: + working-directory: examples/display/electron + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install + + - name: Build + shell: bash + run: npx electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never + + - name: Compress build output + shell: bash + run: | + unpacked_dir="" + for d in dist/*-unpacked; do + if [ -d "$d" ]; then + unpacked_dir="$d" + break + fi + done + if [ -z "$unpacked_dir" ]; then + echo "No unpacked directory found" + exit 1 + fi + archive_name="${{ matrix.artifact-name }}.zip" + if command -v zip &>/dev/null; then + (cd "$unpacked_dir" && zip -r "../../${archive_name}" .) + else + powershell -Command "Compress-Archive -Path '${unpacked_dir}\*' -DestinationPath '${archive_name}'" + fi + echo "Compressed: ${archive_name}" + ls -lh "${archive_name}" + + - name: Upload artifacts + id: upload_github_artifacts + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: ${{ matrix.artifact-name }} + path: examples/display/electron/${{ matrix.artifact-name }}.zip + retention-days: 7 + + - name: Upload artifacts to server + shell: bash + continue-on-error: true + env: + ARTIFACT_SERVER_URL: ${{ secrets.ARTIFACT_SERVER_URL }} + ARTIFACT_USER: ${{ secrets.ARTIFACT_SERVER_USER }} + ARTIFACT_PASSWORD: ${{ secrets.ARTIFACT_SERVER_PASSWORD }} + COMMIT_HASH: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + repo_name="${GITHUB_REPOSITORY##*/}" + date_str="$(date -u +%Y-%m-%d)" + commit_hash="${COMMIT_HASH}" + base_path="/${repo_name}/${date_str}/${commit_hash}/" + server_url="${ARTIFACT_SERVER_URL%/}" + auth="${ARTIFACT_USER}:${ARTIFACT_PASSWORD}" + + curl_status() { + curl --http1.1 --silent --output /dev/null -w "%{http_code}" "$@" + } + + ensure_dir() { + local dir="$1" + local url="${server_url}${dir}" + local status + status=$(curl_status -u "${auth}" "${url}") + if [[ "${status}" == 2* ]]; then + echo "Dir exists: ${dir}" + return 0 + fi + if [[ "${status}" != "404" ]]; then + echo "Unexpected status ${status} when checking ${dir}" + return 1 + fi + local target="${dir%/}" + local parent="${target%/*}/" + local name="${target##*/}" + echo "Creating dir: ${dir}" + status=$(curl_status -u "${auth}" -F "mkdir=${name}" "${server_url}/upload?path=${parent}") + if [[ "${status}" != 2* && "${status}" != 3* ]]; then + echo "Failed to create ${dir} (HTTP ${status})" + return 1 + fi + } + + ensure_dir "/${repo_name}/" + ensure_dir "/${repo_name}/${date_str}/" + ensure_dir "/${repo_name}/${date_str}/${commit_hash}/" + + archive_name="${{ matrix.artifact-name }}.zip" + echo "Uploading ${archive_name}..." + for attempt in 1 2 3; do + status=$(curl_status --max-time 600 -u "${auth}" -F "file=@${archive_name}" "${server_url}/upload?path=${base_path}") + if [[ "${status}" == 2* || "${status}" == 3* ]]; then + echo "${repo_name}/${date_str}/${commit_hash}/${archive_name}" + break + else + echo "Attempt ${attempt} failed (HTTP ${status})" + if [ "$attempt" -eq 3 ]; then + echo "Failed to upload after 3 attempts" + exit 1 + fi + sleep 5 + fi + done diff --git a/.gitignore b/.gitignore index 3d8fbcd..a523cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,11 @@ go.work.sum .apps/* display_*.png -window_*.png \ No newline at end of file +window_*.png + +# Electron example +node_modules/ +dist/ +package-lock.json +.claude/settings.json +.claude/settings.local.json diff --git a/display/display.go b/display/display.go index 2ae92fe..df6708e 100644 --- a/display/display.go +++ b/display/display.go @@ -8,19 +8,21 @@ type PlatformInfo interface { // Display represents a physical display/monitor. type Display struct { - id int // Platform-specific display ID - index int // Display index (0 is main display) - isMain bool // Whether this is the main display - origin Rect // Bounds in platform's coordinate system (position + logical size) - size Size // Physical pixel size - scale float64 // Scale factor (physical pixels / virtual points) - platform PlatformInfo // Platform-specific information (nil if not set) - dpiAware bool // Windows-only DPI awareness flag + id int // Platform-specific display ID + electronId int64 // Electron/Chromium-compatible display ID + index int // Display index (0 is main display) + isMain bool // Whether this is the main display + origin Rect // Bounds in platform's coordinate system (position + logical size) + size Size // Physical pixel size + scale float64 // Scale factor (physical pixels / virtual points) + platform PlatformInfo // Platform-specific information (nil if not set) + dpiAware bool // Windows-only DPI awareness flag } // DisplayInfo contains detailed information about a display. type DisplayInfo struct { ID int // Platform-specific ID + ElectronID int64 // Electron/Chromium-compatible display ID Index int // Index IsMain bool // Whether this is the main display Origin Rect // Bounds in platform's coordinate system @@ -33,6 +35,11 @@ func (d *Display) ID() int { return d.id } +// ElectronID returns an Electron/Chromium-compatible display identifier. +func (d *Display) ElectronID() int64 { + return d.electronId +} + // Index returns the display index (0 is the main display). func (d *Display) Index() int { return d.index @@ -77,6 +84,7 @@ func (d *Display) GetPlatformInfo() PlatformInfo { func (d *Display) Info() DisplayInfo { return DisplayInfo{ ID: d.id, + ElectronID: d.electronId, Index: d.index, IsMain: d.isMain, Origin: d.origin, diff --git a/display/display_c_macos.h b/display/display_c_macos.h index 8350456..2bb3e88 100644 --- a/display/display_c_macos.h +++ b/display/display_c_macos.h @@ -22,6 +22,7 @@ typedef struct { int32_t x, y; // Virtual (scaled) coordinates for position int32_t w, h; // Physical pixel size (not virtual) double scale; // Scale factor (pixel/virtual) + int64_t electronId; // Electron/Chromium-compatible display ID } DisplayInfoC; // Get display count @@ -43,6 +44,7 @@ static DisplayInfoC getDisplayInfoById(CGDirectDisplayID displayID, int32_t inde info.handle = (uintptr)displayID; info.index = index; info.isMain = (displayID == CGMainDisplayID()) ? 1 : 0; + info.electronId = (int64_t)displayID; // Position uses virtual coordinates (for locating display in virtual desktop) info.x = (int32_t)bounds.origin.x; diff --git a/display/display_c_windows.h b/display/display_c_windows.h index d0e41b3..258219d 100644 --- a/display/display_c_windows.h +++ b/display/display_c_windows.h @@ -12,7 +12,9 @@ #define DISPLAY_C_WINDOWS_H #include "../base/types.h" +#include "superfasthash.h" #include +#include // MDT_EFFECTIVE_DPI for GetDpiForMonitor #ifndef MDT_EFFECTIVE_DPI @@ -54,6 +56,7 @@ typedef struct { int32_t vx, vy; // Virtual (logical) coordinates origin int32_t vw, vh; // Virtual (logical) size double scale; // Scale factor (physical/logical) + int64_t electronId; // Electron/Chromium-compatible display ID } DisplayInfoC; // EnumDisplayContext is the enumeration context @@ -65,6 +68,80 @@ typedef struct { int32_t foundCount; // Found count } EnumDisplayContext; +// Compute Electron/Chromium-compatible display ID for a monitor. +// Algorithm from chromium/ui/display/win/display_info.cc:71-90: +// 1. QueryDisplayConfig to get active paths +// 2. Match GDI device name to find the path +// 3. Hash "adapterId.LowPart/adapterId.HighPart/targetInfo.id" with SuperFastHash +// 4. Fallback: hash the device name string +static int64_t computeElectronDisplayId(MONITORINFOEXW* monInfo) { + UINT32 pathCount = 0, modeCount = 0; + if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) { + goto fallback; + } + if (pathCount == 0 || modeCount == 0) { + goto fallback; + } + + { + DISPLAYCONFIG_PATH_INFO* paths = (DISPLAYCONFIG_PATH_INFO*)calloc(pathCount, sizeof(DISPLAYCONFIG_PATH_INFO)); + DISPLAYCONFIG_MODE_INFO* modes = (DISPLAYCONFIG_MODE_INFO*)calloc(modeCount, sizeof(DISPLAYCONFIG_MODE_INFO)); + if (!paths || !modes) { + free(paths); + free(modes); + goto fallback; + } + + if (QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths, &modeCount, modes, NULL) != ERROR_SUCCESS) { + free(paths); + free(modes); + goto fallback; + } + + for (UINT32 i = 0; i < pathCount; i++) { + DISPLAYCONFIG_SOURCE_DEVICE_NAME sourceName; + sourceName.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; + sourceName.header.size = sizeof(sourceName); + sourceName.header.adapterId = paths[i].sourceInfo.adapterId; + sourceName.header.id = paths[i].sourceInfo.id; + + if (DisplayConfigGetDeviceInfo(&sourceName.header) != ERROR_SUCCESS) { + continue; + } + + if (wcscmp(sourceName.viewGdiDeviceName, monInfo->szDevice) == 0) { + // Found matching path - compute hash from adapter ID + target ID + char buf[128]; + int len = sprintf(buf, "%lu/%li/%u", + (unsigned long)paths[i].targetInfo.adapterId.LowPart, + (long)paths[i].targetInfo.adapterId.HighPart, + (unsigned int)paths[i].targetInfo.id); + + uint32_t hash = SuperFastHash_(buf, len); + free(paths); + free(modes); + return (int64_t)hash; + } + } + + free(paths); + free(modes); + } + +fallback: + { + // Fallback: hash the GDI device name (converted to UTF-8) + char deviceNameUtf8[256]; + int utf8Len = WideCharToMultiByte(CP_UTF8, 0, monInfo->szDevice, -1, + deviceNameUtf8, sizeof(deviceNameUtf8), NULL, NULL); + if (utf8Len > 1) { + // utf8Len includes null terminator; hash without it + return (int64_t)SuperFastHash_(deviceNameUtf8, utf8Len - 1); + } + return 0; + } +} + // Get monitor real physical size (bypassing DPI virtualization) // Reference: screenshot library's getMonitorRealSize implementation static int getMonitorRealSize(HMONITOR hMonitor, RECT* outRect) { @@ -112,15 +189,16 @@ static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRE RECT physRect; int hasPhysical = getMonitorRealSize(hMonitor, &physRect); - // Get monitor info (to check if main display) - MONITORINFO mi = {0}; + // Get monitor info (to check if main display and get device name for electron ID) + MONITORINFOEXW mi = {0}; mi.cbSize = sizeof(mi); - GetMonitorInfoW(hMonitor, &mi); + GetMonitorInfoW(hMonitor, (MONITORINFO*)&mi); DisplayInfoC* info = &ctx->displays[ctx->currentIndex]; info->handle = (uintptr)hMonitor; info->index = ctx->currentIndex; info->isMain = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0; + info->electronId = computeElectronDisplayId(&mi); // Always store virtual (logical) coordinates info->vx = lprcMonitor->left; diff --git a/display/display_c_x11.h b/display/display_c_x11.h index 9fe7d68..451af05 100644 --- a/display/display_c_x11.h +++ b/display/display_c_x11.h @@ -13,10 +13,14 @@ #include "../base/types.h" #include "../base/xdisplay_c.h" +#include "superfasthash.h" #include #include +#include #include +#include #include +#include // DisplayInfoC contains display information in C struct typedef struct { @@ -25,8 +29,146 @@ typedef struct { int8_t isMain; // Is main display int32_t x, y, w, h; // Physical coordinates and size double scale; // Scale factor (from Xft.dpi) + int64_t electronId; // Electron/Chromium-compatible display ID } DisplayInfoC; +// Compute Electron/Chromium-compatible display IDs using XRandR + EDID. +// Algorithm from chromium/ui/display/util/edid_parser.cc and display_util.cc: +// 1. Parse EDID for manufacturer_id (bytes 8-9) and display_name (descriptor 0xFC) +// 2. product_code_hash = SuperFastHash(display_name) or 0 if empty +// 3. display_id = (manufacturer_id << 40) | (product_code_hash << 8) | (output_id & 0xFF) +// 4. Match XRandR outputs to Xinerama screens via CRTC geometry +static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int32_t count) { + int major, minor; + if (!XRRQueryVersion(dpy, &major, &minor)) { + // XRandR not available, use fallback indices + for (int32_t i = 0; i < count; i++) { + displays[i].electronId = (int64_t)i; + } + return; + } + + Window root = DefaultRootWindow(dpy); + XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, root); + if (!res) { + for (int32_t i = 0; i < count; i++) { + displays[i].electronId = (int64_t)i; + } + return; + } + + // Track which Xinerama screens have been matched + int8_t* matched = (int8_t*)calloc(count, sizeof(int8_t)); + if (!matched) { + XRRFreeScreenResources(res); + for (int32_t i = 0; i < count; i++) { + displays[i].electronId = (int64_t)i; + } + return; + } + + for (int o = 0; o < res->noutput; o++) { + XRROutputInfo* outInfo = XRRGetOutputInfo(dpy, res, res->outputs[o]); + if (!outInfo) continue; + + if (outInfo->connection != RR_Connected || outInfo->crtc == None) { + XRRFreeOutputInfo(outInfo); + continue; + } + + XRRCrtcInfo* crtcInfo = XRRGetCrtcInfo(dpy, res, outInfo->crtc); + if (!crtcInfo) { + XRRFreeOutputInfo(outInfo); + continue; + } + + // Parse EDID + uint16_t manufacturer_id = 0; + char display_name[14]; + int display_name_len = 0; + memset(display_name, 0, sizeof(display_name)); + + Atom edidAtom = XInternAtom(dpy, "EDID", True); + if (edidAtom != None) { + Atom actualType; + int actualFormat; + unsigned long nitems, bytesAfter; + unsigned char* edid = NULL; + + if (XRRGetOutputProperty(dpy, res->outputs[o], edidAtom, + 0, 128, False, False, AnyPropertyType, + &actualType, &actualFormat, &nitems, &bytesAfter, &edid) == Success + && edid && nitems >= 128) { + + // Manufacturer ID: bytes 8-9, big-endian + manufacturer_id = ((uint16_t)edid[8] << 8) | (uint16_t)edid[9]; + + // Find display name in descriptor blocks (starting at offset 54, each 18 bytes) + for (int d = 0; d < 4; d++) { + int offset = 54 + d * 18; + if (offset + 18 > (int)nitems) break; + + // Check for monitor name descriptor: bytes 0-2 = 0, byte 3 = 0xFC + if (edid[offset] == 0 && edid[offset+1] == 0 && + edid[offset+2] == 0 && edid[offset+3] == 0xFC) { + // Name is in bytes 5-17 of the descriptor + for (int k = 0; k < 13; k++) { + char c = (char)edid[offset + 5 + k]; + if (c == '\n' || c == '\0') break; + display_name[display_name_len++] = c; + } + break; + } + } + } + if (edid) XFree(edid); + } + + // Compute product_code_hash + uint32_t product_code_hash = 0; + if (display_name_len > 0) { + product_code_hash = SuperFastHash_(display_name, display_name_len); + } + + // output_id: use RROutput value, but if > 0xFF set display_id = 0 (per Chromium logic) + uint32_t output_id = (uint32_t)res->outputs[o]; + int64_t electron_id; + if (output_id > 0xFF) { + electron_id = 0; + } else { + electron_id = ((int64_t)manufacturer_id << 40) | + ((int64_t)product_code_hash << 8) | + (int64_t)(output_id & 0xFF); + } + + // Match CRTC geometry to Xinerama screen + for (int32_t s = 0; s < count; s++) { + if (matched[s]) continue; + if ((int)crtcInfo->x == displays[s].x && + (int)crtcInfo->y == displays[s].y && + (int)crtcInfo->width == displays[s].w && + (int)crtcInfo->height == displays[s].h) { + displays[s].electronId = (electron_id != 0) ? electron_id : (int64_t)s; + matched[s] = 1; + break; + } + } + + XRRFreeCrtcInfo(crtcInfo); + XRRFreeOutputInfo(outInfo); + } + + // Fallback for unmatched screens + for (int32_t i = 0; i < count; i++) { + if (!matched[i]) { + displays[i].electronId = (int64_t)i; + } + } + + free(matched); + XRRFreeScreenResources(res); +} + // Get scale factor from Xft.dpi static double getX11Scale() { Display *dpy = XOpenDisplay(NULL); @@ -97,6 +239,7 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { displays[0].w = DisplayWidth(mainDpy, screen); displays[0].h = DisplayHeight(mainDpy, screen); displays[0].scale = 1.0; + displays[0].electronId = 0; return 1; } } @@ -125,6 +268,7 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { } XFree(screens); + computeElectronDisplayIds(dpy, displays, resultCount); XCloseDisplay(dpy); return resultCount; } @@ -141,6 +285,7 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { displays[0].w = DisplayWidth(dpy, screen); displays[0].h = DisplayHeight(dpy, screen); displays[0].scale = scale; + displays[0].electronId = 0; XCloseDisplay(dpy); return 1; } diff --git a/display/display_darwin.go b/display/display_darwin.go index c1bde7c..aa5f1ec 100644 --- a/display/display_darwin.go +++ b/display/display_darwin.go @@ -34,13 +34,14 @@ func MainDisplay(options DisplayOptions) *Display { } return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, } } @@ -67,13 +68,14 @@ func AllDisplays(options DisplayOptions) []*Display { } displays[i] = &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, } } @@ -102,13 +104,14 @@ func DisplayAt(index int, options DisplayOptions) *Display { } return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: options.DPIAware, } } diff --git a/display/display_linux.go b/display/display_linux.go index a570fa7..e5c6904 100644 --- a/display/display_linux.go +++ b/display/display_linux.go @@ -5,7 +5,7 @@ package display /* #cgo linux CFLAGS: -I/usr/src -#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst -lXinerama +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst -lXinerama -lXrandr #include "display_c.h" */ @@ -23,13 +23,14 @@ func MainDisplay(options DisplayOptions) *Display { info := C.getMainDisplay() physW, physH := int(info.w), int(info.h) return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, - size: Size{W: physW, H: physH}, - scale: float64(info.scale), - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, } } @@ -48,13 +49,14 @@ func AllDisplays(options DisplayOptions) []*Display { info := cDisplays[i] physW, physH := int(info.w), int(info.h) displays[i] = &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, - size: Size{W: physW, H: physH}, - scale: float64(info.scale), - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, } } @@ -75,13 +77,14 @@ func DisplayAt(index int, options DisplayOptions) *Display { physW, physH := int(info.w), int(info.h) return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, - size: Size{W: physW, H: physH}, - scale: float64(info.scale), - dpiAware: options.DPIAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, + size: Size{W: physW, H: physH}, + scale: float64(info.scale), + dpiAware: options.DPIAware, } } diff --git a/display/display_windows.go b/display/display_windows.go index bc46a42..14f4e31 100644 --- a/display/display_windows.go +++ b/display/display_windows.go @@ -65,13 +65,14 @@ func MainDisplay(options DisplayOptions) *Display { } return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: origin, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: dpiAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, platform: &WindowsPlatformInfo{ PhysicalOrigin: physicalOrigin, }, @@ -121,13 +122,14 @@ func AllDisplays(options DisplayOptions) []*Display { } displays[i] = &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: origin, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: dpiAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, platform: &WindowsPlatformInfo{ PhysicalOrigin: physicalOrigin, }, @@ -179,13 +181,14 @@ func DisplayAt(index int, options DisplayOptions) *Display { } return &Display{ - id: int(info.handle), - index: int(info.index), - isMain: info.isMain != 0, - origin: origin, - size: Size{W: physW, H: physH}, - scale: scale, - dpiAware: dpiAware, + id: int(info.handle), + electronId: int64(info.electronId), + index: int(info.index), + isMain: info.isMain != 0, + origin: origin, + size: Size{W: physW, H: physH}, + scale: scale, + dpiAware: dpiAware, platform: &WindowsPlatformInfo{ PhysicalOrigin: physicalOrigin, }, diff --git a/display/superfasthash.h b/display/superfasthash.h new file mode 100644 index 0000000..4368bae --- /dev/null +++ b/display/superfasthash.h @@ -0,0 +1,98 @@ +/* + * SuperFastHash - Paul Hsieh's hash function + * + * Ported from Chromium base/third_party/superfasthash/superfasthash.c + * + * Copyright (c) 2010, Paul Hsieh + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither my name, Paul Hsieh, nor the names of any other contributors to + * the code use may not be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef SUPERFASTHASH_H +#define SUPERFASTHASH_H + +#include + +#undef get16bits +#if (defined(__GNUC__) && defined(__i386__)) || defined(__WATCOMC__) || \ + defined(_MSC_VER) || defined(__BORLANDC__) || defined(__TURBOC__) +#define get16bits(d) (*((const uint16_t *)(d))) +#else +#define get16bits(d) \ + ((((uint32_t)(((const uint8_t *)(d))[1])) << 8) \ + + (uint32_t)(((const uint8_t *)(d))[0])) +#endif + +static uint32_t SuperFastHash_(const char *data, int len) { + uint32_t hash = (uint32_t)len, tmp; + int rem; + + if (len <= 0 || data == NULL) + return 0; + + rem = len & 3; + len >>= 2; + + /* Main loop */ + for (; len > 0; len--) { + hash += get16bits(data); + tmp = (get16bits(data + 2) << 11) ^ hash; + hash = (hash << 16) ^ tmp; + data += 2 * sizeof(uint16_t); + hash += hash >> 11; + } + + /* Handle end cases */ + switch (rem) { + case 3: + hash += get16bits(data); + hash ^= hash << 16; + hash ^= ((int8_t)data[sizeof(uint16_t)]) << 18; + hash += hash >> 11; + break; + case 2: + hash += get16bits(data); + hash ^= hash << 11; + hash += hash >> 17; + break; + case 1: + hash += (int8_t)*data; + hash ^= hash << 10; + hash += hash >> 1; + } + + /* Force "avalanching" of final 127 bits */ + hash ^= hash << 3; + hash += hash >> 5; + hash ^= hash << 4; + hash += hash >> 17; + hash ^= hash << 25; + hash += hash >> 6; + + return hash; +} + +#endif /* SUPERFASTHASH_H */ diff --git a/examples/display/electron/index.html b/examples/display/electron/index.html new file mode 100644 index 0000000..a2d1a23 --- /dev/null +++ b/examples/display/electron/index.html @@ -0,0 +1,97 @@ + + + + +DeskAct - Electron Display Info + + + +

      Electron Display Info

      +
      +
      + + + + diff --git a/examples/display/electron/main.js b/examples/display/electron/main.js new file mode 100644 index 0000000..7d02bc1 --- /dev/null +++ b/examples/display/electron/main.js @@ -0,0 +1,78 @@ +const { app, BrowserWindow, screen, ipcMain } = require("electron"); +const path = require("path"); + +app.disableHardwareAcceleration(); + +function getDisplayData() { + const displays = screen.getAllDisplays(); + const primary = screen.getPrimaryDisplay(); + + return displays.map((d, i) => ({ + index: i, + label: d.label || `Display ${i}`, + id: d.id, + isPrimary: d.id === primary.id, + bounds: d.bounds, + size: d.size, + workArea: d.workArea, + workAreaSize: d.workAreaSize, + scaleFactor: d.scaleFactor, + rotation: d.rotation, + internal: d.internal, + colorSpace: d.colorSpace, + depthPerComponent: d.depthPerComponent, + colorDepth: d.colorDepth, + })); +} + +function printToConsole(displays) { + console.log("========================================"); + console.log("Electron Display Info"); + console.log("Electron version:", process.versions.electron); + console.log("Chromium version:", process.versions.chrome); + console.log("========================================"); + console.log(`\nTotal displays: ${displays.length}`); + console.log("----------------------------------------"); + + for (const d of displays) { + console.log(`Display #${d.index} (${d.label})`); + console.log(` id: ${d.id}`); + console.log(` isPrimary: ${d.isPrimary}`); + console.log(` bounds: ${JSON.stringify(d.bounds)}`); + console.log(` size: ${JSON.stringify(d.size)}`); + console.log(` workArea: ${JSON.stringify(d.workArea)}`); + console.log(` workAreaSize: ${JSON.stringify(d.workAreaSize)}`); + console.log(` scaleFactor: ${d.scaleFactor}`); + console.log(` rotation: ${d.rotation}`); + console.log(` internal: ${d.internal}`); + console.log(` colorDepth: ${d.colorDepth}`); + console.log("----------------------------------------"); + } +} + +app.whenReady().then(() => { + const displays = getDisplayData(); + printToConsole(displays); + + const win = new BrowserWindow({ + width: 720, + height: 560, + title: "DeskAct - Electron Display Info", + webPreferences: { + contextIsolation: false, + nodeIntegration: true, + }, + }); + + win.loadFile(path.join(__dirname, "index.html")); + + ipcMain.handle("get-display-data", () => ({ + displays, + electron: process.versions.electron, + chrome: process.versions.chrome, + platform: process.platform, + arch: process.arch, + })); +}); + +app.on("window-all-closed", () => app.quit()); diff --git a/examples/display/electron/package.json b/examples/display/electron/package.json new file mode 100644 index 0000000..4e1da87 --- /dev/null +++ b/examples/display/electron/package.json @@ -0,0 +1,39 @@ +{ + "name": "deskact-electron-display", + "version": "1.0.0", + "main": "main.js", + "scripts": { + "start": "electron .", + "build:win": "electron-builder --win --x64", + "build:mac": "electron-builder --mac --arm64", + "build": "electron-builder" + }, + "build": { + "appId": "com.deskact.electron-display", + "productName": "DeskAct Display Info", + "files": [ + "main.js", + "index.html" + ], + "win": { + "target": [ + { + "target": "dir", + "arch": ["x64"] + } + ] + }, + "mac": { + "target": [ + { + "target": "dir", + "arch": ["arm64"] + } + ] + } + }, + "devDependencies": { + "electron": "^35.0.0", + "electron-builder": "^26.0.0" + } +} diff --git a/examples/display/main.go b/examples/display/main.go index f7b2ce0..47d74f8 100644 --- a/examples/display/main.go +++ b/examples/display/main.go @@ -32,12 +32,13 @@ func main() { for _, d := range displays { info := d.Info() fmt.Printf("Display #%d\n", info.Index) - fmt.Printf(" ID: %d\n", info.ID) - fmt.Printf(" IsMain: %v\n", info.IsMain) - fmt.Printf(" Origin: {X: %d, Y: %d, W: %d, H: %d}\n", + fmt.Printf(" ID: %d\n", info.ID) + fmt.Printf(" ElectronID: %d\n", info.ElectronID) + fmt.Printf(" IsMain: %v\n", info.IsMain) + fmt.Printf(" Origin: {X: %d, Y: %d, W: %d, H: %d}\n", info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H) - fmt.Printf(" Size: {W: %d, H: %d}\n", info.Size.W, info.Size.H) - fmt.Printf(" Scale: %.2f\n", info.ScaleFactor) + fmt.Printf(" Size: {W: %d, H: %d}\n", info.Size.W, info.Size.H) + fmt.Printf(" Scale: %.2f\n", info.ScaleFactor) fmt.Println("----------------------------------------") } @@ -103,8 +104,8 @@ func main() { logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) for _, d := range displays { info := d.Info() - logBuilder.WriteString(fmt.Sprintf("Display #%d: ID=%d, IsMain=%v, Origin=(%d,%d,%d,%d), Size=%dx%d, Scale=%.2f\n", - info.Index, info.ID, info.IsMain, info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H, info.Size.W, info.Size.H, info.ScaleFactor)) + logBuilder.WriteString(fmt.Sprintf("Display #%d: ID=%d, ElectronID=%d, IsMain=%v, Origin=(%d,%d,%d,%d), Size=%dx%d, Scale=%.2f\n", + info.Index, info.ID, info.ElectronID, info.IsMain, info.Origin.X, info.Origin.Y, info.Origin.W, info.Origin.H, info.Size.W, info.Size.H, info.ScaleFactor)) } fmt.Println("\nPress 's' to save log and exit, or 'e' to exit directly:") From ba903dc3617cbff3075c386c2101156e8676da81 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:14:13 +0800 Subject: [PATCH 24/37] fix(ci): handle macOS electron-builder output directory naming electron-builder uses dist/win-unpacked/ on Windows but dist/mac-arm64/ on macOS (no -unpacked suffix). Update the compress step glob to match both patterns. --- .github/workflows/build-electron-display.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-electron-display.yml b/.github/workflows/build-electron-display.yml index 4da7631..917cc52 100644 --- a/.github/workflows/build-electron-display.yml +++ b/.github/workflows/build-electron-display.yml @@ -46,22 +46,27 @@ jobs: - name: Compress build output shell: bash run: | - unpacked_dir="" - for d in dist/*-unpacked; do + # electron-builder output directories: + # Windows: dist/win-unpacked/ + # macOS: dist/mac-arm64/ (contains .app bundle) + ls -la dist/ + output_dir="" + for d in dist/*-unpacked dist/mac-*; do if [ -d "$d" ]; then - unpacked_dir="$d" + output_dir="$d" break fi done - if [ -z "$unpacked_dir" ]; then - echo "No unpacked directory found" + if [ -z "$output_dir" ]; then + echo "No build output directory found" exit 1 fi + echo "Found output dir: ${output_dir}" archive_name="${{ matrix.artifact-name }}.zip" if command -v zip &>/dev/null; then - (cd "$unpacked_dir" && zip -r "../../${archive_name}" .) + (cd "$output_dir" && zip -ry "../../${archive_name}" .) else - powershell -Command "Compress-Archive -Path '${unpacked_dir}\*' -DestinationPath '${archive_name}'" + powershell -Command "Compress-Archive -Path '${output_dir}\*' -DestinationPath '${archive_name}'" fi echo "Compressed: ${archive_name}" ls -lh "${archive_name}" @@ -73,6 +78,7 @@ jobs: with: name: ${{ matrix.artifact-name }} path: examples/display/electron/${{ matrix.artifact-name }}.zip + compression-level: 0 retention-days: 7 - name: Upload artifacts to server From 58a301cc56fe4669b2eabc111fc7bcceac23a674 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:27:19 +0800 Subject: [PATCH 25/37] fix(ci): add libxrandr-dev for Linux builds The new XRandR-based electron ID computation requires Xrandr.h, which is provided by libxrandr-dev on Ubuntu. --- .github/workflows/build-examples.yml | 1 + .github/workflows/build-tester.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index fa775fd..f9301fe 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -44,6 +44,7 @@ jobs: libx11-dev \ libxtst-dev \ libxinerama-dev \ + libxrandr-dev \ libpng-dev \ xclip \ xsel diff --git a/.github/workflows/build-tester.yml b/.github/workflows/build-tester.yml index 6b31df3..55469cb 100644 --- a/.github/workflows/build-tester.yml +++ b/.github/workflows/build-tester.yml @@ -44,6 +44,7 @@ jobs: libx11-dev \ libxtst-dev \ libxinerama-dev \ + libxrandr-dev \ libpng-dev \ xclip \ xsel From 55176d0451211b770889ca117bcb740a312625bf Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:22:47 +0800 Subject: [PATCH 26/37] refactor(display): move SuperFastHash from C to Go for electronId computation Replace the C SuperFastHash implementation with an equivalent Go port. C headers now pass raw data (hash input strings, EDID fields) to Go, which computes the Electron display ID entirely in Go code. - Add display/superfasthash.go (Go port of Paul Hsieh's SuperFastHash) - Remove display/superfasthash.h (C implementation no longer needed) - Windows: C passes raw adapter/target string, Go hashes it - macOS: use CGDirectDisplayID handle directly (no hash needed) - Linux: C passes raw EDID fields, Go computes hash and display ID --- display/display_c_macos.h | 2 - display/display_c_windows.h | 35 ++++++------- display/display_c_x11.h | 68 +++++++------------------ display/display_darwin.go | 6 +-- display/display_linux.go | 35 +++++++++++-- display/display_windows.go | 17 +++++-- display/superfasthash.go | 59 ++++++++++++++++++++++ display/superfasthash.h | 98 ------------------------------------- 8 files changed, 141 insertions(+), 179 deletions(-) create mode 100644 display/superfasthash.go delete mode 100644 display/superfasthash.h diff --git a/display/display_c_macos.h b/display/display_c_macos.h index 2bb3e88..8350456 100644 --- a/display/display_c_macos.h +++ b/display/display_c_macos.h @@ -22,7 +22,6 @@ typedef struct { int32_t x, y; // Virtual (scaled) coordinates for position int32_t w, h; // Physical pixel size (not virtual) double scale; // Scale factor (pixel/virtual) - int64_t electronId; // Electron/Chromium-compatible display ID } DisplayInfoC; // Get display count @@ -44,7 +43,6 @@ static DisplayInfoC getDisplayInfoById(CGDirectDisplayID displayID, int32_t inde info.handle = (uintptr)displayID; info.index = index; info.isMain = (displayID == CGMainDisplayID()) ? 1 : 0; - info.electronId = (int64_t)displayID; // Position uses virtual coordinates (for locating display in virtual desktop) info.x = (int32_t)bounds.origin.x; diff --git a/display/display_c_windows.h b/display/display_c_windows.h index 258219d..d1c0f50 100644 --- a/display/display_c_windows.h +++ b/display/display_c_windows.h @@ -12,7 +12,6 @@ #define DISPLAY_C_WINDOWS_H #include "../base/types.h" -#include "superfasthash.h" #include #include @@ -56,7 +55,8 @@ typedef struct { int32_t vx, vy; // Virtual (logical) coordinates origin int32_t vw, vh; // Virtual (logical) size double scale; // Scale factor (physical/logical) - int64_t electronId; // Electron/Chromium-compatible display ID + char electronIdHashInput[256]; // Raw string for SuperFastHash (electron ID) + int32_t electronIdHashInputLen; // Length of the hash input string } DisplayInfoC; // EnumDisplayContext is the enumeration context @@ -68,13 +68,13 @@ typedef struct { int32_t foundCount; // Found count } EnumDisplayContext; -// Compute Electron/Chromium-compatible display ID for a monitor. +// Get the hash input string for computing Electron/Chromium display ID. // Algorithm from chromium/ui/display/win/display_info.cc:71-90: // 1. QueryDisplayConfig to get active paths // 2. Match GDI device name to find the path -// 3. Hash "adapterId.LowPart/adapterId.HighPart/targetInfo.id" with SuperFastHash -// 4. Fallback: hash the device name string -static int64_t computeElectronDisplayId(MONITORINFOEXW* monInfo) { +// 3. Output "adapterId.LowPart/adapterId.HighPart/targetInfo.id" +// 4. Fallback: output UTF-8 device name +static void getElectronIdHashInput(MONITORINFOEXW* monInfo, char* output, int32_t* outputLen) { UINT32 pathCount = 0, modeCount = 0; if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) { goto fallback; @@ -110,17 +110,15 @@ static int64_t computeElectronDisplayId(MONITORINFOEXW* monInfo) { } if (wcscmp(sourceName.viewGdiDeviceName, monInfo->szDevice) == 0) { - // Found matching path - compute hash from adapter ID + target ID - char buf[128]; - int len = sprintf(buf, "%lu/%li/%u", + // Found matching path - format adapter ID + target ID + int len = sprintf(output, "%lu/%li/%u", (unsigned long)paths[i].targetInfo.adapterId.LowPart, (long)paths[i].targetInfo.adapterId.HighPart, (unsigned int)paths[i].targetInfo.id); - - uint32_t hash = SuperFastHash_(buf, len); + *outputLen = (int32_t)len; free(paths); free(modes); - return (int64_t)hash; + return; } } @@ -130,15 +128,14 @@ static int64_t computeElectronDisplayId(MONITORINFOEXW* monInfo) { fallback: { - // Fallback: hash the GDI device name (converted to UTF-8) - char deviceNameUtf8[256]; + // Fallback: convert GDI device name to UTF-8 int utf8Len = WideCharToMultiByte(CP_UTF8, 0, monInfo->szDevice, -1, - deviceNameUtf8, sizeof(deviceNameUtf8), NULL, NULL); + output, 256, NULL, NULL); if (utf8Len > 1) { - // utf8Len includes null terminator; hash without it - return (int64_t)SuperFastHash_(deviceNameUtf8, utf8Len - 1); + *outputLen = (int32_t)(utf8Len - 1); // exclude null terminator + } else { + *outputLen = 0; } - return 0; } } @@ -198,7 +195,7 @@ static BOOL CALLBACK MonitorInfoEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRE info->handle = (uintptr)hMonitor; info->index = ctx->currentIndex; info->isMain = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0; - info->electronId = computeElectronDisplayId(&mi); + getElectronIdHashInput(&mi, info->electronIdHashInput, &info->electronIdHashInputLen); // Always store virtual (logical) coordinates info->vx = lprcMonitor->left; diff --git a/display/display_c_x11.h b/display/display_c_x11.h index 451af05..ff49fec 100644 --- a/display/display_c_x11.h +++ b/display/display_c_x11.h @@ -13,7 +13,6 @@ #include "../base/types.h" #include "../base/xdisplay_c.h" -#include "superfasthash.h" #include #include #include @@ -29,41 +28,33 @@ typedef struct { int8_t isMain; // Is main display int32_t x, y, w, h; // Physical coordinates and size double scale; // Scale factor (from Xft.dpi) - int64_t electronId; // Electron/Chromium-compatible display ID + // EDID data for electron ID computation (populated by XRandR) + uint16_t edidManufacturerId; + char edidDisplayName[14]; + int32_t edidDisplayNameLen; + uint32_t edidOutputId; + int8_t edidValid; // 1 if matched via XRandR, 0 otherwise } DisplayInfoC; -// Compute Electron/Chromium-compatible display IDs using XRandR + EDID. -// Algorithm from chromium/ui/display/util/edid_parser.cc and display_util.cc: -// 1. Parse EDID for manufacturer_id (bytes 8-9) and display_name (descriptor 0xFC) -// 2. product_code_hash = SuperFastHash(display_name) or 0 if empty -// 3. display_id = (manufacturer_id << 40) | (product_code_hash << 8) | (output_id & 0xFF) -// 4. Match XRandR outputs to Xinerama screens via CRTC geometry -static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int32_t count) { +// Populate EDID data for electron ID computation using XRandR. +// Parses EDID for manufacturer_id and display_name, gets output_id, +// and matches XRandR outputs to Xinerama screens via CRTC geometry. +// The actual hash and ID computation is done in Go. +static void populateEdidData(Display* dpy, DisplayInfoC* displays, int32_t count) { int major, minor; if (!XRRQueryVersion(dpy, &major, &minor)) { - // XRandR not available, use fallback indices - for (int32_t i = 0; i < count; i++) { - displays[i].electronId = (int64_t)i; - } return; } Window root = DefaultRootWindow(dpy); XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, root); if (!res) { - for (int32_t i = 0; i < count; i++) { - displays[i].electronId = (int64_t)i; - } return; } - // Track which Xinerama screens have been matched int8_t* matched = (int8_t*)calloc(count, sizeof(int8_t)); if (!matched) { XRRFreeScreenResources(res); - for (int32_t i = 0; i < count; i++) { - displays[i].electronId = (int64_t)i; - } return; } @@ -100,18 +91,13 @@ static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int3 &actualType, &actualFormat, &nitems, &bytesAfter, &edid) == Success && edid && nitems >= 128) { - // Manufacturer ID: bytes 8-9, big-endian manufacturer_id = ((uint16_t)edid[8] << 8) | (uint16_t)edid[9]; - // Find display name in descriptor blocks (starting at offset 54, each 18 bytes) for (int d = 0; d < 4; d++) { int offset = 54 + d * 18; if (offset + 18 > (int)nitems) break; - - // Check for monitor name descriptor: bytes 0-2 = 0, byte 3 = 0xFC if (edid[offset] == 0 && edid[offset+1] == 0 && edid[offset+2] == 0 && edid[offset+3] == 0xFC) { - // Name is in bytes 5-17 of the descriptor for (int k = 0; k < 13; k++) { char c = (char)edid[offset + 5 + k]; if (c == '\n' || c == '\0') break; @@ -124,22 +110,7 @@ static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int3 if (edid) XFree(edid); } - // Compute product_code_hash - uint32_t product_code_hash = 0; - if (display_name_len > 0) { - product_code_hash = SuperFastHash_(display_name, display_name_len); - } - - // output_id: use RROutput value, but if > 0xFF set display_id = 0 (per Chromium logic) uint32_t output_id = (uint32_t)res->outputs[o]; - int64_t electron_id; - if (output_id > 0xFF) { - electron_id = 0; - } else { - electron_id = ((int64_t)manufacturer_id << 40) | - ((int64_t)product_code_hash << 8) | - (int64_t)(output_id & 0xFF); - } // Match CRTC geometry to Xinerama screen for (int32_t s = 0; s < count; s++) { @@ -148,7 +119,11 @@ static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int3 (int)crtcInfo->y == displays[s].y && (int)crtcInfo->width == displays[s].w && (int)crtcInfo->height == displays[s].h) { - displays[s].electronId = (electron_id != 0) ? electron_id : (int64_t)s; + displays[s].edidManufacturerId = manufacturer_id; + memcpy(displays[s].edidDisplayName, display_name, display_name_len); + displays[s].edidDisplayNameLen = (int32_t)display_name_len; + displays[s].edidOutputId = output_id; + displays[s].edidValid = 1; matched[s] = 1; break; } @@ -158,13 +133,6 @@ static void computeElectronDisplayIds(Display* dpy, DisplayInfoC* displays, int3 XRRFreeOutputInfo(outInfo); } - // Fallback for unmatched screens - for (int32_t i = 0; i < count; i++) { - if (!matched[i]) { - displays[i].electronId = (int64_t)i; - } - } - free(matched); XRRFreeScreenResources(res); } @@ -239,7 +207,6 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { displays[0].w = DisplayWidth(mainDpy, screen); displays[0].h = DisplayHeight(mainDpy, screen); displays[0].scale = 1.0; - displays[0].electronId = 0; return 1; } } @@ -268,7 +235,7 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { } XFree(screens); - computeElectronDisplayIds(dpy, displays, resultCount); + populateEdidData(dpy, displays, resultCount); XCloseDisplay(dpy); return resultCount; } @@ -285,7 +252,6 @@ static int32_t getAllDisplays(DisplayInfoC* displays, int32_t maxCount) { displays[0].w = DisplayWidth(dpy, screen); displays[0].h = DisplayHeight(dpy, screen); displays[0].scale = scale; - displays[0].electronId = 0; XCloseDisplay(dpy); return 1; } diff --git a/display/display_darwin.go b/display/display_darwin.go index aa5f1ec..133339b 100644 --- a/display/display_darwin.go +++ b/display/display_darwin.go @@ -35,7 +35,7 @@ func MainDisplay(options DisplayOptions) *Display { return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: int64(info.handle), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, @@ -69,7 +69,7 @@ func AllDisplays(options DisplayOptions) []*Display { displays[i] = &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: int64(info.handle), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, @@ -105,7 +105,7 @@ func DisplayAt(index int, options DisplayOptions) *Display { return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: int64(info.handle), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: logicalW, H: logicalH}}, diff --git a/display/display_linux.go b/display/display_linux.go index e5c6904..29e2f04 100644 --- a/display/display_linux.go +++ b/display/display_linux.go @@ -13,18 +13,47 @@ import "C" import ( "image" + "unsafe" "github.com/PekingSpades/DeskAct/mouse" "github.com/PekingSpades/DeskAct/screenshot" ) +// linuxElectronId computes the Electron display ID from EDID data. +// Algorithm: (manufacturer_id << 40) | (SuperFastHash(display_name) << 8) | (output_id & 0xFF) +// If output_id > 0xFF, returns 0. If edidValid is false, returns fallback index. +func linuxElectronId(info *C.DisplayInfoC, fallbackIndex int) int64 { + if info.edidValid == 0 { + return int64(fallbackIndex) + } + + outputId := uint32(info.edidOutputId) + if outputId > 0xFF { + return 0 + } + + manufacturerId := uint64(info.edidManufacturerId) + nameLen := int(info.edidDisplayNameLen) + var productCodeHash uint32 + if nameLen > 0 { + name := C.GoBytes(unsafe.Pointer(&info.edidDisplayName[0]), C.int(nameLen)) + productCodeHash = SuperFastHash(name) + } + + displayId := int64((manufacturerId << 40) | (uint64(productCodeHash) << 8) | uint64(outputId&0xFF)) + if displayId == 0 { + return int64(fallbackIndex) + } + return displayId +} + // MainDisplay returns the main display. func MainDisplay(options DisplayOptions) *Display { info := C.getMainDisplay() physW, physH := int(info.w), int(info.h) return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: linuxElectronId(&info, int(info.index)), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, @@ -50,7 +79,7 @@ func AllDisplays(options DisplayOptions) []*Display { physW, physH := int(info.w), int(info.h) displays[i] = &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: linuxElectronId(&cDisplays[i], i), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, @@ -78,7 +107,7 @@ func DisplayAt(index int, options DisplayOptions) *Display { physW, physH := int(info.w), int(info.h) return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: linuxElectronId(&info, index), index: int(info.index), isMain: info.isMain != 0, origin: Rect{Point: Point{X: int(info.x), Y: int(info.y)}, Size: Size{W: physW, H: physH}}, diff --git a/display/display_windows.go b/display/display_windows.go index 14f4e31..1d974c5 100644 --- a/display/display_windows.go +++ b/display/display_windows.go @@ -13,11 +13,22 @@ import "C" import ( "errors" "image" + "unsafe" "github.com/PekingSpades/DeskAct/mouse" "github.com/PekingSpades/DeskAct/screenshot" ) +// windowsElectronId computes the Electron display ID from the C hash input. +func windowsElectronId(info *C.DisplayInfoC) int64 { + inputLen := int(info.electronIdHashInputLen) + if inputLen <= 0 { + return 0 + } + input := C.GoBytes(unsafe.Pointer(&info.electronIdHashInput[0]), C.int(inputLen)) + return int64(SuperFastHash(input)) +} + // WindowsPlatformInfo contains Windows-specific display information. type WindowsPlatformInfo struct { // PhysicalOrigin is the top-left corner in physical pixel coordinates. @@ -66,7 +77,7 @@ func MainDisplay(options DisplayOptions) *Display { return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: windowsElectronId(&info), index: int(info.index), isMain: info.isMain != 0, origin: origin, @@ -123,7 +134,7 @@ func AllDisplays(options DisplayOptions) []*Display { displays[i] = &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: windowsElectronId(&cDisplays[i]), index: int(info.index), isMain: info.isMain != 0, origin: origin, @@ -182,7 +193,7 @@ func DisplayAt(index int, options DisplayOptions) *Display { return &Display{ id: int(info.handle), - electronId: int64(info.electronId), + electronId: windowsElectronId(&info), index: int(info.index), isMain: info.isMain != 0, origin: origin, diff --git a/display/superfasthash.go b/display/superfasthash.go new file mode 100644 index 0000000..a16cc93 --- /dev/null +++ b/display/superfasthash.go @@ -0,0 +1,59 @@ +// SuperFastHash - Paul Hsieh's hash function (Go port) +// +// Ported from Chromium base/third_party/superfasthash/superfasthash.c +// This is the same algorithm used by Chromium/Electron to compute display IDs. +// +// Copyright (c) 2010, Paul Hsieh. All rights reserved. +// BSD license — see superfasthash.h for full license text. + +package display + +// SuperFastHash computes Paul Hsieh's SuperFastHash. +// This must produce identical results to the C version in Chromium +// for Electron display ID compatibility. +func SuperFastHash(data []byte) uint32 { + length := len(data) + if length <= 0 { + return 0 + } + + hash := uint32(length) + rem := length & 3 + length >>= 2 + + i := 0 + for ; length > 0; length-- { + hash += uint32(data[i]) | uint32(data[i+1])<<8 + tmp := (uint32(data[i+2]) | uint32(data[i+3])<<8) << 11 ^ hash + hash = (hash << 16) ^ tmp + i += 4 + hash += hash >> 11 + } + + switch rem { + case 3: + hash += uint32(data[i]) | uint32(data[i+1])<<8 + hash ^= hash << 16 + // int8 cast for sign extension, matching C: (int8_t)data[2] + hash ^= uint32(int32(int8(data[i+2])) << 18) + hash += hash >> 11 + case 2: + hash += uint32(data[i]) | uint32(data[i+1])<<8 + hash ^= hash << 11 + hash += hash >> 17 + case 1: + // int8 cast for sign extension, matching C: (int8_t)*data + hash += uint32(int8(data[i])) + hash ^= hash << 10 + hash += hash >> 1 + } + + hash ^= hash << 3 + hash += hash >> 5 + hash ^= hash << 4 + hash += hash >> 17 + hash ^= hash << 25 + hash += hash >> 6 + + return hash +} diff --git a/display/superfasthash.h b/display/superfasthash.h deleted file mode 100644 index 4368bae..0000000 --- a/display/superfasthash.h +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SuperFastHash - Paul Hsieh's hash function - * - * Ported from Chromium base/third_party/superfasthash/superfasthash.c - * - * Copyright (c) 2010, Paul Hsieh - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * * Neither my name, Paul Hsieh, nor the names of any other contributors to - * the code use may not be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - -#ifndef SUPERFASTHASH_H -#define SUPERFASTHASH_H - -#include - -#undef get16bits -#if (defined(__GNUC__) && defined(__i386__)) || defined(__WATCOMC__) || \ - defined(_MSC_VER) || defined(__BORLANDC__) || defined(__TURBOC__) -#define get16bits(d) (*((const uint16_t *)(d))) -#else -#define get16bits(d) \ - ((((uint32_t)(((const uint8_t *)(d))[1])) << 8) \ - + (uint32_t)(((const uint8_t *)(d))[0])) -#endif - -static uint32_t SuperFastHash_(const char *data, int len) { - uint32_t hash = (uint32_t)len, tmp; - int rem; - - if (len <= 0 || data == NULL) - return 0; - - rem = len & 3; - len >>= 2; - - /* Main loop */ - for (; len > 0; len--) { - hash += get16bits(data); - tmp = (get16bits(data + 2) << 11) ^ hash; - hash = (hash << 16) ^ tmp; - data += 2 * sizeof(uint16_t); - hash += hash >> 11; - } - - /* Handle end cases */ - switch (rem) { - case 3: - hash += get16bits(data); - hash ^= hash << 16; - hash ^= ((int8_t)data[sizeof(uint16_t)]) << 18; - hash += hash >> 11; - break; - case 2: - hash += get16bits(data); - hash ^= hash << 11; - hash += hash >> 17; - break; - case 1: - hash += (int8_t)*data; - hash ^= hash << 10; - hash += hash >> 1; - } - - /* Force "avalanching" of final 127 bits */ - hash ^= hash << 3; - hash += hash >> 5; - hash ^= hash << 4; - hash += hash >> 17; - hash ^= hash << 25; - hash += hash >> 6; - - return hash; -} - -#endif /* SUPERFASTHASH_H */ From 4aaa7ff9e4eb50bb44e56b87e36822e042b2d154 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:51:54 +0800 Subject: [PATCH 27/37] fix(mouse): pass explicit start coordinates through Drag call chain The Drag/DragSmooth functions previously relied on C-level location() to determine the drag start point. On macOS, CGEventPost is async so location() could return stale positions, causing incorrect drag origins. Changes: - Drag/DragSmooth signatures now take (fromX, fromY, toX, toY) explicitly - C smoothlyMoveMouse/smoothlyDragMouse accept startPoint parameter instead of calling location() internally - MoveSmooth calls Location() in Go and passes it to C as startPoint - macOS moveMouse/dragMouse poll waitForCursorSync after CGEventPost to ensure position is updated before subsequent operations - Display.Drag/DragTo unified across platforms into display.go --- display/display.go | 18 ++++++++++++-- display/display_darwin.go | 14 ----------- display/display_linux.go | 29 ----------------------- display/display_windows.go | 29 ----------------------- mouse/mouse.go | 48 +++++++++++++++++++++----------------- mouse/mouse_c_macos.h | 27 +++++++++++++++++---- mouse/mouse_c_windows.h | 4 ++-- mouse/mouse_c_x11.h | 4 ++-- mouse/platform_darwin.go | 17 +++++++------- mouse/platform_linux.go | 8 +++---- mouse/platform_windows.go | 8 +++---- mouse_exports.go | 8 +++---- 12 files changed, 90 insertions(+), 124 deletions(-) diff --git a/display/display.go b/display/display.go index df6708e..58ccb1e 100644 --- a/display/display.go +++ b/display/display.go @@ -1,5 +1,7 @@ package display +import "github.com/PekingSpades/DeskAct/mouse" + // PlatformInfo is an interface for platform-specific display information. type PlatformInfo interface { // Platform returns the platform name (e.g., "windows", "darwin", "linux"). @@ -93,14 +95,26 @@ func (d *Display) Info() DisplayInfo { } } +// Drag drags the mouse from one position to another on this display. +func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + fromAbsX, fromAbsY := d.ToAbsolute(fromX, fromY) + toAbsX, toAbsY := d.ToAbsolute(toX, toY) + return mouse.DragSmooth(fromAbsX, fromAbsY, toAbsX, toAbsY, button, settings) +} + +// DragTo drags the mouse from the current position to the specified position on this display. +func (d *Display) DragTo(x, y int, button MouseButton, settings MouseSettings) error { + curX, curY := mouse.Location() + toAbsX, toAbsY := d.ToAbsolute(x, y) + return mouse.DragSmooth(curX, curY, toAbsX, toAbsY, button, settings) +} + // Platform-specific methods: // - ToAbsolute(x, y int) (absX, absY int) // - ToRelative(absX, absY int) (x, y int, ok bool) // - Contains(absX, absY int) bool // - Move(x, y int, settings MouseSettings) error // - MoveSmooth(x, y int, settings MouseSettings) error -// - Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error -// - DragTo(x, y int, button MouseButton, settings MouseSettings) error // - CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) // - MouseLocation() (x, y int, ok bool) // - ContainsMouse() bool diff --git a/display/display_darwin.go b/display/display_darwin.go index 133339b..bce0c9a 100644 --- a/display/display_darwin.go +++ b/display/display_darwin.go @@ -173,20 +173,6 @@ func (d *Display) MoveSmooth(physX, physY int, settings MouseSettings) error { return mouse.MoveSmooth(virtAbsX, virtAbsY, settings) } -// Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { - if err := d.Move(fromX, fromY, settings); err != nil { - return err - } - return d.DragTo(toX, toY, button, settings) -} - -// DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(physX, physY int, button MouseButton, settings MouseSettings) error { - virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) - return mouse.DragSmooth(virtAbsX, virtAbsY, button, settings) -} - // CaptureRect captures a rectangular region of this display. func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (*image.RGBA, error) { virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) diff --git a/display/display_linux.go b/display/display_linux.go index 29e2f04..fe09ec7 100644 --- a/display/display_linux.go +++ b/display/display_linux.go @@ -153,35 +153,6 @@ func (d *Display) MoveSmooth(x, y int, settings MouseSettings) error { return mouse.MoveSmooth(absX, absY, settings) } -// Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { - if err := d.Move(fromX, fromY, settings); err != nil { - return err - } - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(toX, toY, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) -} - -// DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(x, y int, button MouseButton, settings MouseSettings) error { - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(x, y, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) -} - // CaptureRect captures a rectangular region of this display. func (d *Display) CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) { absX, absY := d.ToAbsolute(x, y) diff --git a/display/display_windows.go b/display/display_windows.go index 1d974c5..41224f0 100644 --- a/display/display_windows.go +++ b/display/display_windows.go @@ -270,35 +270,6 @@ func (d *Display) MoveSmooth(x, y int, settings MouseSettings) error { return mouse.MoveSmooth(absX, absY, settings) } -// Drag drags the mouse from one position to another on this display. -func (d *Display) Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { - if err := d.Move(fromX, fromY, settings); err != nil { - return err - } - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(toX, toY, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) -} - -// DragTo drags the mouse from the current position to the specified position on this display. -func (d *Display) DragTo(x, y int, button MouseButton, settings MouseSettings) error { - if err := mouse.Toggle(button, true, false, settings); err != nil { - return err - } - mouse.MilliSleep(50) - if err := d.MoveSmooth(x, y, settings); err != nil { - _ = mouse.Toggle(button, false, false, settings) - return err - } - return mouse.Toggle(button, false, false, settings) -} - // CaptureRect captures a rectangular region of this display. func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (*image.RGBA, error) { pi, ok := d.platform.(*WindowsPlatformInfo) diff --git a/mouse/mouse.go b/mouse/mouse.go index 71df0a8..d97cc1d 100644 --- a/mouse/mouse.go +++ b/mouse/mouse.go @@ -15,48 +15,52 @@ func Move(x, y int, settings MouseSettings) error { return nil } -// Drag drags the mouse to (x, y) from the current position. -func Drag(x, y int, button MouseButton, settings MouseSettings) error { +// MoveSmooth smoothly moves the mouse to (x, y). +func MoveSmooth(x, y int, settings MouseSettings) error { + sx, sy := Location() + cx := C.int32_t(x) + cy := C.int32_t(y) + + startPt := C.MMPointInt32Make(C.int32_t(sx), C.int32_t(sy)) + low := C.double(settings.MoveSmoothLow) + high := C.double(settings.MoveSmoothHigh) + + cbool := C.smoothlyMoveMouse(startPt, C.MMPointInt32Make(cx, cy), low, high) + MilliSleep(settings.Sleep + settings.MoveSmoothDelay) + if !bool(cbool) { + return wrapMouseError(MouseOpMoveSmooth, ErrMouseActionFailed, 0, 0, 0, "smooth move returned false", 0) + } + return nil +} + +// Drag drags the mouse to (toX, toY) from (fromX, fromY). +func Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + Move(fromX, fromY, settings) if err := Toggle(button, true, false, settings); err != nil { return err } MilliSleep(50) - if err := dragTo(x, y, button, settings); err != nil { + if err := dragTo(fromX, fromY, toX, toY, button, settings); err != nil { _ = Toggle(button, false, false, settings) return err } return Toggle(button, false, false, settings) } -// DragSmooth drags the mouse smoothly to (x, y) from the current position. -func DragSmooth(x, y int, button MouseButton, settings MouseSettings) error { +// DragSmooth drags the mouse smoothly to (toX, toY) from (fromX, fromY). +func DragSmooth(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + Move(fromX, fromY, settings) if err := Toggle(button, true, false, settings); err != nil { return err } MilliSleep(50) - if err := dragSmoothTo(x, y, button, settings); err != nil { + if err := dragSmoothTo(fromX, fromY, toX, toY, button, settings); err != nil { _ = Toggle(button, false, false, settings) return err } return Toggle(button, false, false, settings) } -// MoveSmooth smoothly moves the mouse to (x, y). -func MoveSmooth(x, y int, settings MouseSettings) error { - cx := C.int32_t(x) - cy := C.int32_t(y) - - low := C.double(settings.MoveSmoothLow) - high := C.double(settings.MoveSmoothHigh) - - cbool := C.smoothlyMoveMouse(C.MMPointInt32Make(cx, cy), low, high) - MilliSleep(settings.Sleep + settings.MoveSmoothDelay) - if !bool(cbool) { - return wrapMouseError(MouseOpMoveSmooth, ErrMouseActionFailed, 0, 0, 0, "smooth move returned false", 0) - } - return nil -} - // MoveArgs get the mouse relative args. func MoveArgs(x, y int) (int, int) { mx, my := Location() diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index d36a356..263dbab 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -61,6 +61,21 @@ void calculateDeltas(CGEventRef *event, MMPointInt32 point) { CFRelease(get); } +/* Poll until the system reports the cursor at the expected position, + or until maxWaitMs milliseconds have elapsed. */ +static void waitForCursorSync(MMPointInt32 target, double maxWaitMs) { + const double pollIntervalMs = 1.0; + double elapsed = 0.0; + while (elapsed < maxWaitMs) { + MMPointInt32 cur = location(); + if (cur.x == target.x && cur.y == target.y) { + return; + } + microsleep(pollIntervalMs); + elapsed += pollIntervalMs; + } +} + /* Move the mouse to a specific point. */ void moveMouse(MMPointInt32 point){ CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); @@ -72,6 +87,8 @@ void moveMouse(MMPointInt32 point){ CGEventPost(kCGHIDEventTap, move); CFRelease(move); CFRelease(source); + + waitForCursorSync(point, 50.0); } void dragMouse(MMPointInt32 point, const MMMouseButton button){ @@ -85,6 +102,8 @@ void dragMouse(MMPointInt32 point, const MMMouseButton button){ CGEventPost(kCGHIDEventTap, drag); CFRelease(drag); CFRelease(source); + + waitForCursorSync(point, 50.0); } MMPointInt32 location() { @@ -192,8 +211,8 @@ static double crude_hypot(double x, double y){ return ((M_SQRT2 - 1.0) * small) + big; } -bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ - MMPointInt32 pos = location(); +bool smoothlyMoveMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; // MMSizeInt32 screenSize = getMainDisplaySize(); double velo_x = 0.0, velo_y = 0.0; double distance; @@ -227,8 +246,8 @@ bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed) return true; } -bool smoothlyDragMouse(MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed){ - MMPointInt32 pos = location(); +bool smoothlyDragMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; double velo_x = 0.0, velo_y = 0.0; double distance; diff --git a/mouse/mouse_c_windows.h b/mouse/mouse_c_windows.h index 2e4e5b6..eb45b8e 100644 --- a/mouse/mouse_c_windows.h +++ b/mouse/mouse_c_windows.h @@ -136,8 +136,8 @@ static double crude_hypot(double x, double y){ return ((M_SQRT2 - 1.0) * small) + big; } -bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ - MMPointInt32 pos = location(); +bool smoothlyMoveMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; // MMSizeInt32 screenSize = getMainDisplaySize(); double velo_x = 0.0, velo_y = 0.0; double distance; diff --git a/mouse/mouse_c_x11.h b/mouse/mouse_c_x11.h index 183dc88..dd55a4a 100644 --- a/mouse/mouse_c_x11.h +++ b/mouse/mouse_c_x11.h @@ -113,8 +113,8 @@ static double crude_hypot(double x, double y){ return ((M_SQRT2 - 1.0) * small) + big; } -bool smoothlyMoveMouse(MMPointInt32 endPoint, double lowSpeed, double highSpeed){ - MMPointInt32 pos = location(); +bool smoothlyMoveMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; // MMSizeInt32 screenSize = getMainDisplaySize(); double velo_x = 0.0, velo_y = 0.0; double distance; diff --git a/mouse/platform_darwin.go b/mouse/platform_darwin.go index e93bd10..d14b8e7 100644 --- a/mouse/platform_darwin.go +++ b/mouse/platform_darwin.go @@ -7,7 +7,7 @@ package mouse #include "mouse.h" void dragMouse(MMPointInt32 point, const MMMouseButton button); -bool smoothlyDragMouse(MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed); +bool smoothlyDragMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed); */ import "C" @@ -33,29 +33,30 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } -func dragTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { cbtn, err := mouseButtonToC(button) if err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - cx := C.int32_t(x) - cy := C.int32_t(y) + cx := C.int32_t(toX) + cy := C.int32_t(toY) C.dragMouse(C.MMPointInt32Make(cx, cy), cbtn) MilliSleep(settings.Sleep) return nil } -func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragSmoothTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { cbtn, err := mouseButtonToC(button) if err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - cx := C.int32_t(x) - cy := C.int32_t(y) + cx := C.int32_t(toX) + cy := C.int32_t(toY) + startPt := C.MMPointInt32Make(C.int32_t(fromX), C.int32_t(fromY)) low := C.double(settings.MoveSmoothLow) high := C.double(settings.MoveSmoothHigh) - cbool := C.smoothlyDragMouse(C.MMPointInt32Make(cx, cy), cbtn, low, high) + cbool := C.smoothlyDragMouse(startPt, C.MMPointInt32Make(cx, cy), cbtn, low, high) MilliSleep(settings.Sleep + settings.MoveSmoothDelay) if !bool(cbool) { return wrapMouseError(MouseOpDrag, ErrMouseActionFailed, button, 0, 0, "smooth drag returned false", 0) diff --git a/mouse/platform_linux.go b/mouse/platform_linux.go index 5293e95..556a8dd 100644 --- a/mouse/platform_linux.go +++ b/mouse/platform_linux.go @@ -40,18 +40,18 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } -func dragTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { if _, err := mouseButtonToC(button); err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - return Move(x, y, settings) + return Move(toX, toY, settings) } -func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragSmoothTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { if _, err := mouseButtonToC(button); err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - return MoveSmooth(x, y, settings) + return MoveSmooth(toX, toY, settings) } func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { diff --git a/mouse/platform_windows.go b/mouse/platform_windows.go index dc73f00..9cbce80 100644 --- a/mouse/platform_windows.go +++ b/mouse/platform_windows.go @@ -34,18 +34,18 @@ func mouseButtonToC(button MouseButton) (C.MMMouseButton, error) { return 0, ErrMouseInvalidButton } -func dragTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { if _, err := mouseButtonToC(button); err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - return Move(x, y, settings) + return Move(toX, toY, settings) } -func dragSmoothTo(x, y int, button MouseButton, settings MouseSettings) error { +func dragSmoothTo(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { if _, err := mouseButtonToC(button); err != nil { return wrapMouseError(MouseOpDrag, err, button, 0, 0, "", 0) } - return MoveSmooth(x, y, settings) + return MoveSmooth(toX, toY, settings) } func scrollDeltaToC(delta ScrollDelta) (C.int, C.int, C.MMScrollUnit, error) { diff --git a/mouse_exports.go b/mouse_exports.go index 2932823..07bcc7f 100644 --- a/mouse_exports.go +++ b/mouse_exports.go @@ -62,12 +62,12 @@ func Move(x, y int, settings MouseSettings) error { return m.Move(x, y, settings) } -func Drag(x, y int, button MouseButton, settings MouseSettings) error { - return m.Drag(x, y, button, settings) +func Drag(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + return m.Drag(fromX, fromY, toX, toY, button, settings) } -func DragSmooth(x, y int, button MouseButton, settings MouseSettings) error { - return m.DragSmooth(x, y, button, settings) +func DragSmooth(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + return m.DragSmooth(fromX, fromY, toX, toY, button, settings) } func MoveSmooth(x, y int, settings MouseSettings) error { From b1be287e5b222542cba2cf149a8c744df89c5c8c Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:42:23 +0800 Subject: [PATCH 28/37] Fix darwin build: add forward declaration for location() clang on macOS 15 rejects implicit function declarations under C99. waitForCursorSync() called location() before its definition, causing a compile error on darwin/arm64. --- mouse/mouse_c_macos.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index 263dbab..e79db93 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -61,6 +61,9 @@ void calculateDeltas(CGEventRef *event, MMPointInt32 point) { CFRelease(get); } +/* Forward declaration for location() used below. */ +MMPointInt32 location(); + /* Poll until the system reports the cursor at the expected position, or until maxWaitMs milliseconds have elapsed. */ static void waitForCursorSync(MMPointInt32 target, double maxWaitMs) { From b774488b2c9617fe5cfce25313cb3450c7b5944f Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:34:05 +0800 Subject: [PATCH 29/37] Add capture backends and DXGI desktop duplication --- capture/capture.go | 33 ++ display/capture.go | 20 + display/capture_defaults_darwin.go | 12 + display/capture_defaults_darwin_test.go | 12 + display/capture_defaults_other.go | 8 + display/capture_defaults_other_test.go | 18 + display/capture_defaults_windows.go | 12 + display/capture_defaults_windows_test.go | 12 + display/defaults.go | 10 - display/display_darwin.go | 10 +- display/display_linux.go | 10 +- display/display_windows.go | 10 +- display_exports.go | 14 + examples/capture/main.go | 87 ++++ mouse/mouse_c_macos.h | 2 +- mouse/mouse_c_windows.h | 2 +- mouse/mouse_c_x11.h | 2 +- screenshot/common.go | 33 ++ screenshot/common_test.go | 40 ++ screenshot/darwin.go | 263 +++++++--- screenshot/dxgi_helpers.go | 156 ++++++ screenshot/dxgi_helpers_test.go | 283 ++++++++++ screenshot/nix_dbus_available.go | 14 +- screenshot/unsupported.go | 14 +- screenshot/windows.go | 98 +--- screenshot/windows_dxgi.go | 634 +++++++++++++++++++++++ screenshot/windows_gdi.go | 97 ++++ 27 files changed, 1724 insertions(+), 182 deletions(-) create mode 100644 capture/capture.go create mode 100644 display/capture.go create mode 100644 display/capture_defaults_darwin.go create mode 100644 display/capture_defaults_darwin_test.go create mode 100644 display/capture_defaults_other.go create mode 100644 display/capture_defaults_other_test.go create mode 100644 display/capture_defaults_windows.go create mode 100644 display/capture_defaults_windows_test.go create mode 100644 screenshot/common.go create mode 100644 screenshot/common_test.go create mode 100644 screenshot/dxgi_helpers.go create mode 100644 screenshot/dxgi_helpers_test.go create mode 100644 screenshot/windows_dxgi.go create mode 100644 screenshot/windows_gdi.go diff --git a/capture/capture.go b/capture/capture.go new file mode 100644 index 0000000..994f400 --- /dev/null +++ b/capture/capture.go @@ -0,0 +1,33 @@ +package capture + +import "errors" + +type CaptureBackend string + +const ( + CaptureBackendDefault CaptureBackend = "" + CaptureBackendGDI CaptureBackend = "gdi" + CaptureBackendDXGI CaptureBackend = "dxgi" + CaptureBackendScreenCaptureKit CaptureBackend = "screencapturekit" + CaptureBackendCGDisplay CaptureBackend = "cgdisplay" +) + +type CaptureOptions struct { + WaylandToken uint64 + Backend CaptureBackend + ExcludedWindowIDs []uint64 +} + +type Request struct { + DisplayID int + X int + Y int + Width int + Height int + Options CaptureOptions +} + +var ( + ErrCaptureBackendUnavailable = errors.New("capture backend unavailable") + ErrWindowExclusionUnsupported = errors.New("window exclusion is unsupported by the selected capture backend") +) diff --git a/display/capture.go b/display/capture.go new file mode 100644 index 0000000..1b5ec0b --- /dev/null +++ b/display/capture.go @@ -0,0 +1,20 @@ +package display + +import cap "github.com/PekingSpades/DeskAct/capture" + +type CaptureBackend = cap.CaptureBackend + +const ( + CaptureBackendDefault = cap.CaptureBackendDefault + CaptureBackendGDI = cap.CaptureBackendGDI + CaptureBackendDXGI = cap.CaptureBackendDXGI + CaptureBackendScreenCaptureKit = cap.CaptureBackendScreenCaptureKit + CaptureBackendCGDisplay = cap.CaptureBackendCGDisplay +) + +type CaptureOptions = cap.CaptureOptions + +var ( + ErrCaptureBackendUnavailable = cap.ErrCaptureBackendUnavailable + ErrWindowExclusionUnsupported = cap.ErrWindowExclusionUnsupported +) diff --git a/display/capture_defaults_darwin.go b/display/capture_defaults_darwin.go new file mode 100644 index 0000000..baaf7b8 --- /dev/null +++ b/display/capture_defaults_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package display + +import cap "github.com/PekingSpades/DeskAct/capture" + +// DefaultCaptureOptions returns the default capture options. +func DefaultCaptureOptions() CaptureOptions { + return CaptureOptions{ + Backend: cap.CaptureBackendScreenCaptureKit, + } +} diff --git a/display/capture_defaults_darwin_test.go b/display/capture_defaults_darwin_test.go new file mode 100644 index 0000000..b43debf --- /dev/null +++ b/display/capture_defaults_darwin_test.go @@ -0,0 +1,12 @@ +//go:build darwin + +package display + +import "testing" + +func TestDefaultCaptureOptionsOnDarwin(t *testing.T) { + options := DefaultCaptureOptions() + if options.Backend != CaptureBackendScreenCaptureKit { + t.Fatalf("expected default backend %q, got %q", CaptureBackendScreenCaptureKit, options.Backend) + } +} diff --git a/display/capture_defaults_other.go b/display/capture_defaults_other.go new file mode 100644 index 0000000..c7a778d --- /dev/null +++ b/display/capture_defaults_other.go @@ -0,0 +1,8 @@ +//go:build !windows && !darwin + +package display + +// DefaultCaptureOptions returns the default capture options. +func DefaultCaptureOptions() CaptureOptions { + return CaptureOptions{} +} diff --git a/display/capture_defaults_other_test.go b/display/capture_defaults_other_test.go new file mode 100644 index 0000000..515032e --- /dev/null +++ b/display/capture_defaults_other_test.go @@ -0,0 +1,18 @@ +//go:build !windows && !darwin + +package display + +import "testing" + +func TestDefaultCaptureOptionsOnOtherPlatforms(t *testing.T) { + options := DefaultCaptureOptions() + if options.Backend != CaptureBackendDefault { + t.Fatalf("expected default backend %q, got %q", CaptureBackendDefault, options.Backend) + } + if options.WaylandToken != 0 { + t.Fatalf("expected zero Wayland token, got %d", options.WaylandToken) + } + if len(options.ExcludedWindowIDs) != 0 { + t.Fatalf("expected no excluded window IDs, got %v", options.ExcludedWindowIDs) + } +} diff --git a/display/capture_defaults_windows.go b/display/capture_defaults_windows.go new file mode 100644 index 0000000..8d5d4af --- /dev/null +++ b/display/capture_defaults_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package display + +import cap "github.com/PekingSpades/DeskAct/capture" + +// DefaultCaptureOptions returns the default capture options. +func DefaultCaptureOptions() CaptureOptions { + return CaptureOptions{ + Backend: cap.CaptureBackendDXGI, + } +} diff --git a/display/capture_defaults_windows_test.go b/display/capture_defaults_windows_test.go new file mode 100644 index 0000000..258ab4a --- /dev/null +++ b/display/capture_defaults_windows_test.go @@ -0,0 +1,12 @@ +//go:build windows + +package display + +import "testing" + +func TestDefaultCaptureOptionsOnWindows(t *testing.T) { + options := DefaultCaptureOptions() + if options.Backend != CaptureBackendDXGI { + t.Fatalf("expected default backend %q, got %q", CaptureBackendDXGI, options.Backend) + } +} diff --git a/display/defaults.go b/display/defaults.go index 4a1da84..ef38326 100644 --- a/display/defaults.go +++ b/display/defaults.go @@ -8,13 +8,3 @@ const ( type DisplayOptions struct { DPIAware bool } - -// CaptureOptions defines platform-specific capture options. -type CaptureOptions struct { - WaylandToken uint64 -} - -// DefaultCaptureOptions returns the default capture options. -func DefaultCaptureOptions() CaptureOptions { - return CaptureOptions{} -} diff --git a/display/display_darwin.go b/display/display_darwin.go index bce0c9a..1ec094e 100644 --- a/display/display_darwin.go +++ b/display/display_darwin.go @@ -16,6 +16,7 @@ import "C" import ( "image" + cap "github.com/PekingSpades/DeskAct/capture" "github.com/PekingSpades/DeskAct/mouse" "github.com/PekingSpades/DeskAct/screenshot" ) @@ -183,7 +184,14 @@ func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (* virtW = int(float64(w) / d.scale) virtH = int(float64(h) / d.scale) } - return screenshot.Capture(virtAbsX, virtAbsY, virtW, virtH, options.WaylandToken) + return screenshot.Capture(cap.Request{ + DisplayID: d.id, + X: virtAbsX, + Y: virtAbsY, + Width: virtW, + Height: virtH, + Options: options, + }) } // MouseLocation gets the mouse location in physical pixels relative to this display. diff --git a/display/display_linux.go b/display/display_linux.go index fe09ec7..1b93732 100644 --- a/display/display_linux.go +++ b/display/display_linux.go @@ -15,6 +15,7 @@ import ( "image" "unsafe" + cap "github.com/PekingSpades/DeskAct/capture" "github.com/PekingSpades/DeskAct/mouse" "github.com/PekingSpades/DeskAct/screenshot" ) @@ -156,7 +157,14 @@ func (d *Display) MoveSmooth(x, y int, settings MouseSettings) error { // CaptureRect captures a rectangular region of this display. func (d *Display) CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) { absX, absY := d.ToAbsolute(x, y) - return screenshot.Capture(absX, absY, w, h, options.WaylandToken) + return screenshot.Capture(cap.Request{ + DisplayID: d.id, + X: absX, + Y: absY, + Width: w, + Height: h, + Options: options, + }) } // MouseLocation gets the mouse location relative to this display. diff --git a/display/display_windows.go b/display/display_windows.go index 41224f0..08a33bd 100644 --- a/display/display_windows.go +++ b/display/display_windows.go @@ -15,6 +15,7 @@ import ( "image" "unsafe" + cap "github.com/PekingSpades/DeskAct/capture" "github.com/PekingSpades/DeskAct/mouse" "github.com/PekingSpades/DeskAct/screenshot" ) @@ -278,7 +279,14 @@ func (d *Display) CaptureRect(physX, physY, w, h int, options CaptureOptions) (* } absX := pi.PhysicalOrigin.X + physX absY := pi.PhysicalOrigin.Y + physY - return screenshot.Capture(absX, absY, w, h, options.WaylandToken) + return screenshot.Capture(cap.Request{ + DisplayID: d.id, + X: absX, + Y: absY, + Width: w, + Height: h, + Options: options, + }) } // MouseLocation gets the mouse location relative to this display. diff --git a/display_exports.go b/display_exports.go index bd2868b..ba54a82 100644 --- a/display_exports.go +++ b/display_exports.go @@ -6,6 +6,7 @@ type PlatformInfo = display.PlatformInfo type Display = display.Display type DisplayInfo = display.DisplayInfo type DisplayOptions = display.DisplayOptions +type CaptureBackend = display.CaptureBackend type CaptureOptions = display.CaptureOptions type Point = display.Point type Size = display.Size @@ -13,6 +14,19 @@ type Rect = display.Rect const DefaultDPIAware = display.DefaultDPIAware +const ( + CaptureBackendDefault = display.CaptureBackendDefault + CaptureBackendGDI = display.CaptureBackendGDI + CaptureBackendDXGI = display.CaptureBackendDXGI + CaptureBackendScreenCaptureKit = display.CaptureBackendScreenCaptureKit + CaptureBackendCGDisplay = display.CaptureBackendCGDisplay +) + +var ( + ErrCaptureBackendUnavailable = display.ErrCaptureBackendUnavailable + ErrWindowExclusionUnsupported = display.ErrWindowExclusionUnsupported +) + func DefaultDisplayOptions() DisplayOptions { return display.DefaultDisplayOptions() } diff --git a/examples/capture/main.go b/examples/capture/main.go index ce0e255..3182064 100644 --- a/examples/capture/main.go +++ b/examples/capture/main.go @@ -29,6 +29,10 @@ func main() { fmt.Printf("DPI init result: %s\n", dpiResult) displayOptions := deskact.DefaultDisplayOptions() captureOptions := deskact.DefaultCaptureOptions() + backendSelected, backendResult := promptCaptureBackend(captureOptions.Backend) + captureOptions.Backend = backendResult + fmt.Printf("Capture backend selected: %v\n", backendSelected) + fmt.Printf("Capture backend result: %s\n", captureBackendLabel(captureOptions.Backend)) displays := deskact.AllDisplays(displayOptions) count := len(displays) @@ -144,6 +148,8 @@ func main() { logBuilder.WriteString(fmt.Sprintf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)) logBuilder.WriteString(fmt.Sprintf("DPI init selected: %v\n", dpiSelected)) logBuilder.WriteString(fmt.Sprintf("DPI init result: %s\n", dpiResult)) + logBuilder.WriteString(fmt.Sprintf("Capture backend selected: %v\n", backendSelected)) + logBuilder.WriteString(fmt.Sprintf("Capture backend result: %s\n", captureBackendLabel(captureOptions.Backend))) logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) for _, d := range displays { info := d.Info() @@ -189,6 +195,87 @@ func promptDPIInit() (bool, string) { } } +func promptCaptureBackend(defaultBackend deskact.CaptureBackend) (bool, deskact.CaptureBackend) { + reader := bufio.NewReader(os.Stdin) + options := availableCaptureBackends() + if len(options) == 0 { + return false, defaultBackend + } + + fmt.Printf("\nSelect capture backend [Enter for %s]:\n", captureBackendLabel(defaultBackend)) + for _, option := range options { + fmt.Printf(" %s) %s\n", option.key, captureBackendLabel(option.backend)) + } + + for { + fmt.Print("Backend: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return false, defaultBackend + } + for _, option := range options { + if input == option.key { + return true, option.backend + } + } + fmt.Printf("Please press Enter for %s or choose one of: ", captureBackendLabel(defaultBackend)) + for i, option := range options { + if i > 0 { + fmt.Print(", ") + } + fmt.Print(option.key) + } + fmt.Println() + } +} + +func availableCaptureBackends() []struct { + key string + backend deskact.CaptureBackend +} { + switch runtime.GOOS { + case "windows": + return []struct { + key string + backend deskact.CaptureBackend + }{ + {key: "d", backend: deskact.CaptureBackendDXGI}, + {key: "g", backend: deskact.CaptureBackendGDI}, + } + case "darwin": + return []struct { + key string + backend deskact.CaptureBackend + }{ + {key: "s", backend: deskact.CaptureBackendScreenCaptureKit}, + {key: "c", backend: deskact.CaptureBackendCGDisplay}, + } + default: + return []struct { + key string + backend deskact.CaptureBackend + }{} + } +} + +func captureBackendLabel(backend deskact.CaptureBackend) string { + switch backend { + case deskact.CaptureBackendDefault: + return "default" + case deskact.CaptureBackendDXGI: + return "dxgi" + case deskact.CaptureBackendGDI: + return "gdi" + case deskact.CaptureBackendScreenCaptureKit: + return "screencapturekit" + case deskact.CaptureBackendCGDisplay: + return "cgdisplay" + default: + return string(backend) + } +} + func savePNG(img image.Image, path string) error { file, err := os.Create(path) if err != nil { diff --git a/mouse/mouse_c_macos.h b/mouse/mouse_c_macos.h index e79db93..761c751 100644 --- a/mouse/mouse_c_macos.h +++ b/mouse/mouse_c_macos.h @@ -9,7 +9,7 @@ // except according to those terms. #include "mouse.h" -#include "../base/deadbeef_rand.h" +#include "../base/deadbeef_rand_c.h" #include "../base/microsleep.h" #include /* For floor() */ diff --git a/mouse/mouse_c_windows.h b/mouse/mouse_c_windows.h index eb45b8e..006d286 100644 --- a/mouse/mouse_c_windows.h +++ b/mouse/mouse_c_windows.h @@ -9,7 +9,7 @@ // except according to those terms. #include "mouse.h" -#include "../base/deadbeef_rand.h" +#include "../base/deadbeef_rand_c.h" #include "../base/microsleep.h" #include /* For floor() */ diff --git a/mouse/mouse_c_x11.h b/mouse/mouse_c_x11.h index dd55a4a..63660b3 100644 --- a/mouse/mouse_c_x11.h +++ b/mouse/mouse_c_x11.h @@ -9,7 +9,7 @@ // except according to those terms. #include "mouse.h" -#include "../base/deadbeef_rand.h" +#include "../base/deadbeef_rand_c.h" #include "../base/microsleep.h" #include "../base/xdisplay_c.h" diff --git a/screenshot/common.go b/screenshot/common.go new file mode 100644 index 0000000..f627805 --- /dev/null +++ b/screenshot/common.go @@ -0,0 +1,33 @@ +package screenshot + +import ( + "fmt" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +func backendUnavailableError(backend cap.CaptureBackend, format string, args ...any) error { + detail := fmt.Sprintf(format, args...) + if detail == "" { + detail = fmt.Sprintf("backend %q is unavailable", backend) + } + return fmt.Errorf("%w: %s", cap.ErrCaptureBackendUnavailable, detail) +} + +func windowExclusionUnsupportedError(backend cap.CaptureBackend) error { + if backend == cap.CaptureBackendDefault { + return fmt.Errorf("%w: the default backend does not support excluded window IDs", cap.ErrWindowExclusionUnsupported) + } + return fmt.Errorf("%w: backend %q does not support excluded window IDs", cap.ErrWindowExclusionUnsupported, backend) +} + +func normalizeRequestedBackend(requested, defaultBackend cap.CaptureBackend) cap.CaptureBackend { + if requested == cap.CaptureBackendDefault { + return defaultBackend + } + return requested +} + +func hasExcludedWindowIDs(options cap.CaptureOptions) bool { + return len(options.ExcludedWindowIDs) > 0 +} diff --git a/screenshot/common_test.go b/screenshot/common_test.go new file mode 100644 index 0000000..82bee77 --- /dev/null +++ b/screenshot/common_test.go @@ -0,0 +1,40 @@ +package screenshot + +import ( + "errors" + "testing" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +func TestBackendUnavailableErrorWrapsSentinel(t *testing.T) { + err := backendUnavailableError(cap.CaptureBackendDXGI, "backend %q is unavailable", cap.CaptureBackendDXGI) + if !errors.Is(err, cap.ErrCaptureBackendUnavailable) { + t.Fatalf("expected ErrCaptureBackendUnavailable, got %v", err) + } +} + +func TestWindowExclusionUnsupportedErrorWrapsSentinel(t *testing.T) { + err := windowExclusionUnsupportedError(cap.CaptureBackendScreenCaptureKit) + if !errors.Is(err, cap.ErrWindowExclusionUnsupported) { + t.Fatalf("expected ErrWindowExclusionUnsupported, got %v", err) + } +} + +func TestNormalizeRequestedBackend(t *testing.T) { + if got := normalizeRequestedBackend(cap.CaptureBackendDefault, cap.CaptureBackendDXGI); got != cap.CaptureBackendDXGI { + t.Fatalf("expected default backend to normalize to %q, got %q", cap.CaptureBackendDXGI, got) + } + if got := normalizeRequestedBackend(cap.CaptureBackendGDI, cap.CaptureBackendDXGI); got != cap.CaptureBackendGDI { + t.Fatalf("expected explicit backend to be preserved, got %q", got) + } +} + +func TestHasExcludedWindowIDs(t *testing.T) { + if hasExcludedWindowIDs(cap.CaptureOptions{}) { + t.Fatal("expected empty options to report no excluded window IDs") + } + if !hasExcludedWindowIDs(cap.CaptureOptions{ExcludedWindowIDs: []uint64{42}}) { + t.Fatal("expected excluded window IDs to be detected") + } +} diff --git a/screenshot/darwin.go b/screenshot/darwin.go index de51da1..5a6391a 100644 --- a/screenshot/darwin.go +++ b/screenshot/darwin.go @@ -4,82 +4,129 @@ package screenshot /* #cgo CFLAGS: -x objective-c -#cgo LDFLAGS: -framework CoreGraphics -framework CoreFoundation +#cgo LDFLAGS: -framework CoreGraphics -framework CoreFoundation -framework Foundation #cgo LDFLAGS: -weak_framework ScreenCaptureKit #include #include +#include +#include #if __has_include() #include #define HAS_SCREENCAPTUREKIT 1 #endif -static CGImageRef capture(CGDirectDisplayID id, CGRect diIntersectDisplayLocal, CGColorSpaceRef colorSpace) { +typedef enum { + CaptureStatusOK = 0, + CaptureStatusBackendUnavailable = 1, + CaptureStatusWindowExclusionUnsupported = 2, + CaptureStatusCaptureFailed = 3, +} CaptureStatus; + +typedef struct { + CGImageRef image; + CaptureStatus status; +} CaptureResult; + +static CaptureResult captureWithCGDisplay(CGDirectDisplayID id, CGRect diIntersectDisplayLocal, CGColorSpaceRef colorSpace) { + CaptureResult result = {0}; + CGImageRef img = CGDisplayCreateImageForRect(id, diIntersectDisplayLocal); + if (!img) { + result.status = CaptureStatusCaptureFailed; + return result; + } + result.image = CGImageCreateCopyWithColorSpace(img, colorSpace); + CGImageRelease(img); + result.status = result.image ? CaptureStatusOK : CaptureStatusCaptureFailed; + return result; +} + +static CaptureResult captureWithScreenCaptureKit(CGDirectDisplayID id, + CGRect diIntersectDisplayLocal, + CGColorSpaceRef colorSpace, + const uint64_t* excludedWindowIDs, + size_t excludedWindowCount) { + CaptureResult result = {0}; #if defined(HAS_SCREENCAPTUREKIT) if (@available(macOS 14.4, *)) { - dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - __block CGImageRef result = nil; - [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) { - @autoreleasepool { - if (error) { - dispatch_semaphore_signal(semaphore); - return; + } else { + result.status = CaptureStatusBackendUnavailable; + return result; + } + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block CaptureStatus status = CaptureStatusCaptureFailed; + __block CGImageRef capturedImage = nil; + + [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) { + @autoreleasepool { + if (error || !content) { + dispatch_semaphore_signal(semaphore); + return; + } + + SCDisplay* target = nil; + for (SCDisplay* display in content.displays) { + if (display.displayID == id) { + target = display; + break; } - SCDisplay* target = nil; - for (SCDisplay *display in content.displays) { - if (display.displayID == id) { - target = display; - break; + } + if (!target) { + dispatch_semaphore_signal(semaphore); + return; + } + + NSArray* excludedWindows = @[]; + if (excludedWindowCount > 0) { + NSMutableArray* matches = [NSMutableArray array]; + for (SCWindow* window in content.windows) { + uint64_t windowID = (uint64_t)window.windowID; + for (size_t i = 0; i < excludedWindowCount; i++) { + if (windowID == excludedWindowIDs[i]) { + [matches addObject:window]; + break; + } } } - if (!target) { - dispatch_semaphore_signal(semaphore); - return; - } - SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:target excludingWindows:@[]]; - SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init]; - config.sourceRect = diIntersectDisplayLocal; - config.width = diIntersectDisplayLocal.size.width; - config.height = diIntersectDisplayLocal.size.height; - config.showsCursor = NO; - [SCScreenshotManager captureImageWithFilter:filter - configuration:config - completionHandler:^(CGImageRef img, NSError* error) { - if (!error) { - result = CGImageCreateCopyWithColorSpace(img, colorSpace); + excludedWindows = matches; + } + + SCContentFilter* filter = [[[SCContentFilter alloc] initWithDisplay:target excludingWindows:excludedWindows] autorelease]; + SCStreamConfiguration* config = [[[SCStreamConfiguration alloc] init] autorelease]; + config.sourceRect = diIntersectDisplayLocal; + config.width = (NSInteger)diIntersectDisplayLocal.size.width; + config.height = (NSInteger)diIntersectDisplayLocal.size.height; + config.showsCursor = NO; + + [SCScreenshotManager captureImageWithFilter:filter + configuration:config + completionHandler:^(CGImageRef image, NSError* error) { + @autoreleasepool { + if (!error && image) { + capturedImage = CGImageCreateCopyWithColorSpace(image, colorSpace); + if (capturedImage) { + status = CaptureStatusOK; + } } dispatch_semaphore_signal(semaphore); - }]; - } - }]; - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); - dispatch_release(semaphore); - return result; - } -#if __MAC_OS_X_VERSION_MIN_REQUIRED < 150000 - CGImageRef img = CGDisplayCreateImageForRect(id, diIntersectDisplayLocal); - if (!img) { - return nil; - } - CGImageRef copy = CGImageCreateCopyWithColorSpace(img, colorSpace); - CGImageRelease(img); - if (!copy) { - return nil; - } - return copy; -#endif + } + }]; + } + }]; + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + result.image = capturedImage; + result.status = status; + return result; #else - CGImageRef img = CGDisplayCreateImageForRect(id, diIntersectDisplayLocal); - if (!img) { - return nil; - } - CGImageRef copy = CGImageCreateCopyWithColorSpace(img, colorSpace); - CGImageRelease(img); - if (!copy) { - return nil; - } - return copy; + (void)id; + (void)diIntersectDisplayLocal; + (void)colorSpace; + (void)excludedWindowIDs; + (void)excludedWindowCount; + result.status = CaptureStatusBackendUnavailable; + return result; #endif - return nil; } */ import "C" @@ -88,15 +135,27 @@ import ( "errors" "image" "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" ) -func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { - _ = waylandToken - if width <= 0 || height <= 0 { +func Capture(req cap.Request) (*image.RGBA, error) { + if req.Width <= 0 || req.Height <= 0 { return nil, errors.New("width or height should be > 0") } - rect := image.Rect(0, 0, width, height) + backend := normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendScreenCaptureKit) + if hasExcludedWindowIDs(req.Options) && backend != cap.CaptureBackendScreenCaptureKit { + return nil, windowExclusionUnsupportedError(backend) + } + + switch backend { + case cap.CaptureBackendScreenCaptureKit, cap.CaptureBackendCGDisplay: + default: + return nil, backendUnavailableError(backend, "backend %q is not supported on macOS", backend) + } + + rect := image.Rect(0, 0, req.Width, req.Height) img, err := createImage(rect) if err != nil { return nil, err @@ -108,13 +167,13 @@ func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) cgMainDisplayBounds := getCoreGraphicsCoordinateOfDisplay(C.CGMainDisplayID()) - winBottomLeft := C.CGPointMake(C.CGFloat(x), C.CGFloat(y+height)) + winBottomLeft := C.CGPointMake(C.CGFloat(req.X), C.CGFloat(req.Y+req.Height)) cgBottomLeft := getCoreGraphicsCoordinateFromWindowsCoordinate(winBottomLeft, cgMainDisplayBounds) - cgCaptureBounds := C.CGRectMake(cgBottomLeft.x, cgBottomLeft.y, C.CGFloat(width), C.CGFloat(height)) + cgCaptureBounds := C.CGRectMake(cgBottomLeft.x, cgBottomLeft.y, C.CGFloat(req.Width), C.CGFloat(req.Height)) ids := activeDisplayList() - ctx := createBitmapContext(width, height, (*C.uint32_t)(unsafe.Pointer(&img.Pix[0])), img.Stride) + ctx := createBitmapContext(req.Width, req.Height, (*C.uint32_t)(unsafe.Pointer(&img.Pix[0])), img.Stride) if ctx == 0 { return nil, errors.New("cannot create bitmap context") } @@ -125,6 +184,14 @@ func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) } defer C.CGColorSpaceRelease(colorSpace) + var excludedWindowIDs []C.uint64_t + if backend == cap.CaptureBackendScreenCaptureKit && len(req.Options.ExcludedWindowIDs) > 0 { + excludedWindowIDs = make([]C.uint64_t, len(req.Options.ExcludedWindowIDs)) + for i, windowID := range req.Options.ExcludedWindowIDs { + excludedWindowIDs[i] = C.uint64_t(windowID) + } + } + for _, id := range ids { cgBounds := getCoreGraphicsCoordinateOfDisplay(id) cgIntersect := C.CGRectIntersection(cgBounds, cgCaptureBounds) @@ -135,7 +202,7 @@ func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) continue } - // CGDisplayCreateImageForRect potentially fail in case width/height is odd number. + // CGDisplayCreateImageForRect potentially fails in case width/height is odd number. if int(cgIntersect.size.width)%2 != 0 { cgIntersect.size.width = C.CGFloat(int(cgIntersect.size.width) + 1) } @@ -143,25 +210,32 @@ func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) cgIntersect.size.height = C.CGFloat(int(cgIntersect.size.height) + 1) } - diIntersectDisplayLocal := C.CGRectMake(cgIntersect.origin.x-cgBounds.origin.x, + diIntersectDisplayLocal := C.CGRectMake( + cgIntersect.origin.x-cgBounds.origin.x, cgBounds.origin.y+cgBounds.size.height-(cgIntersect.origin.y+cgIntersect.size.height), - cgIntersect.size.width, cgIntersect.size.height) + cgIntersect.size.width, + cgIntersect.size.height, + ) - image := C.capture(id, diIntersectDisplayLocal, colorSpace) - if unsafe.Pointer(image) == nil { - return nil, errors.New("cannot capture display") + imageRef, err := captureDarwinImage(id, diIntersectDisplayLocal, colorSpace, backend, excludedWindowIDs) + if err != nil { + return nil, err } - defer C.CGImageRelease(image) + defer C.CGImageRelease(imageRef) - cgDrawRect := C.CGRectMake(cgIntersect.origin.x-cgCaptureBounds.origin.x, cgIntersect.origin.y-cgCaptureBounds.origin.y, - cgIntersect.size.width, cgIntersect.size.height) - C.CGContextDrawImage(ctx, cgDrawRect, image) + cgDrawRect := C.CGRectMake( + cgIntersect.origin.x-cgCaptureBounds.origin.x, + cgIntersect.origin.y-cgCaptureBounds.origin.y, + cgIntersect.size.width, + cgIntersect.size.height, + ) + C.CGContextDrawImage(ctx, cgDrawRect, imageRef) } i := 0 - for iy := 0; iy < height; iy++ { + for iy := 0; iy < req.Height; iy++ { j := i - for ix := 0; ix < width; ix++ { + for ix := 0; ix < req.Width; ix++ { // ARGB => RGBA, and set A to 255 img.Pix[j], img.Pix[j+1], img.Pix[j+2], img.Pix[j+3] = img.Pix[j+1], img.Pix[j+2], img.Pix[j+3], 255 j += 4 @@ -172,6 +246,39 @@ func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) return img, nil } +func captureDarwinImage(id C.CGDirectDisplayID, rect C.CGRect, colorSpace C.CGColorSpaceRef, backend cap.CaptureBackend, excludedWindowIDs []C.uint64_t) (C.CGImageRef, error) { + switch backend { + case cap.CaptureBackendScreenCaptureKit: + var ptr *C.uint64_t + if len(excludedWindowIDs) > 0 { + ptr = &excludedWindowIDs[0] + } + result := C.captureWithScreenCaptureKit(id, rect, colorSpace, ptr, C.size_t(len(excludedWindowIDs))) + return darwinCaptureResult(result, backend) + case cap.CaptureBackendCGDisplay: + result := C.captureWithCGDisplay(id, rect, colorSpace) + return darwinCaptureResult(result, backend) + default: + return nil, backendUnavailableError(backend, "backend %q is not supported on macOS", backend) + } +} + +func darwinCaptureResult(result C.CaptureResult, backend cap.CaptureBackend) (C.CGImageRef, error) { + switch result.status { + case C.CaptureStatusOK: + if unsafe.Pointer(result.image) == nil { + return nil, errors.New("cannot capture display") + } + return result.image, nil + case C.CaptureStatusBackendUnavailable: + return nil, backendUnavailableError(backend, "backend %q is unavailable on this macOS version", backend) + case C.CaptureStatusWindowExclusionUnsupported: + return nil, windowExclusionUnsupportedError(backend) + default: + return nil, errors.New("cannot capture display") + } +} + func NumActiveDisplays() int { var count C.uint32_t = 0 if C.CGGetActiveDisplayList(0, nil, &count) == C.kCGErrorSuccess { diff --git a/screenshot/dxgi_helpers.go b/screenshot/dxgi_helpers.go new file mode 100644 index 0000000..c4002a9 --- /dev/null +++ b/screenshot/dxgi_helpers.go @@ -0,0 +1,156 @@ +package screenshot + +import ( + "errors" + "fmt" + "image" + "sync" + "time" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +const ( + dxgiRotationUnspecified uint32 = 0 + dxgiRotationIdentity uint32 = 1 + dxgiRotationRotate90 uint32 = 2 + dxgiRotationRotate180 uint32 = 3 + dxgiRotationRotate270 uint32 = 4 + + dxgiInitialFrameMaxWait = 2 * time.Second +) + +var ( + errDXGIAccessLost = errors.New("dxgi duplication access lost") + errDXGIFrameTimeout = errors.New("dxgi frame wait timeout") +) + +type dxgiWaitTimeoutAction int + +const ( + dxgiWaitTimeoutUseCachedFrame dxgiWaitTimeoutAction = iota + dxgiWaitTimeoutRetryAcquire + dxgiWaitTimeoutFail +) + +type dxgiCaptureSession interface { + capture(req cap.Request) (*image.RGBA, error) + close() +} + +type dxgiSessionFactory func(displayID int) (dxgiCaptureSession, error) + +type dxgiDuplicationManager struct { + mu sync.Mutex + entries map[int]*dxgiDuplicationEntry + newSession dxgiSessionFactory +} + +type dxgiDuplicationEntry struct { + mu sync.Mutex + session dxgiCaptureSession +} + +func newDXGIDuplicationManager(factory dxgiSessionFactory) *dxgiDuplicationManager { + return &dxgiDuplicationManager{ + entries: make(map[int]*dxgiDuplicationEntry), + newSession: factory, + } +} + +func (m *dxgiDuplicationManager) Capture(req cap.Request) (*image.RGBA, error) { + entry := m.entry(req.DisplayID) + entry.mu.Lock() + defer entry.mu.Unlock() + + session, err := m.ensureSessionLocked(entry, req.DisplayID) + if err != nil { + return nil, err + } + + img, err := session.capture(req) + if err == nil { + return img, nil + } + if !errors.Is(err, errDXGIAccessLost) { + return nil, err + } + + session.close() + entry.session = nil + + session, err = m.ensureSessionLocked(entry, req.DisplayID) + if err != nil { + return nil, err + } + return session.capture(req) +} + +func (m *dxgiDuplicationManager) entry(displayID int) *dxgiDuplicationEntry { + m.mu.Lock() + defer m.mu.Unlock() + + entry := m.entries[displayID] + if entry == nil { + entry = &dxgiDuplicationEntry{} + m.entries[displayID] = entry + } + return entry +} + +func (m *dxgiDuplicationManager) ensureSessionLocked(entry *dxgiDuplicationEntry, displayID int) (dxgiCaptureSession, error) { + if entry.session != nil { + return entry.session, nil + } + + session, err := m.newSession(displayID) + if err != nil { + return nil, err + } + if session == nil { + return nil, errors.New("DXGI session factory returned nil session") + } + entry.session = session + return session, nil +} + +func classifyDXGIWaitTimeout(hasFrame bool, now, deadline time.Time) dxgiWaitTimeoutAction { + if hasFrame { + return dxgiWaitTimeoutUseCachedFrame + } + if !now.After(deadline) { + return dxgiWaitTimeoutRetryAcquire + } + return dxgiWaitTimeoutFail +} + +func normalizeDXGIRotation(rotation uint32) uint32 { + if rotation == dxgiRotationUnspecified { + return dxgiRotationIdentity + } + return rotation +} + +func dxgiExpectedSurfaceSize(rotation uint32, desktopWidth, desktopHeight int) (int, int, error) { + switch normalizeDXGIRotation(rotation) { + case dxgiRotationIdentity, dxgiRotationRotate180: + return desktopWidth, desktopHeight, nil + case dxgiRotationRotate90, dxgiRotationRotate270: + return desktopHeight, desktopWidth, nil + default: + return 0, 0, fmt.Errorf("unsupported DXGI rotation value %d", rotation) + } +} + +func mapDesktopPointToDXGISurface(rotation uint32, desktopWidth, desktopHeight, x, y int) image.Point { + switch normalizeDXGIRotation(rotation) { + case dxgiRotationRotate90: + return image.Pt(y, desktopWidth-1-x) + case dxgiRotationRotate180: + return image.Pt(desktopWidth-1-x, desktopHeight-1-y) + case dxgiRotationRotate270: + return image.Pt(desktopHeight-1-y, x) + default: + return image.Pt(x, y) + } +} diff --git a/screenshot/dxgi_helpers_test.go b/screenshot/dxgi_helpers_test.go new file mode 100644 index 0000000..776629d --- /dev/null +++ b/screenshot/dxgi_helpers_test.go @@ -0,0 +1,283 @@ +package screenshot + +import ( + "errors" + "image" + "sync" + "sync/atomic" + "testing" + "time" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +func TestDXGIExpectedSurfaceSize(t *testing.T) { + tests := []struct { + name string + rotation uint32 + desktopWidth int + desktopHeight int + wantWidth int + wantHeight int + wantErr bool + }{ + {name: "identity", rotation: dxgiRotationIdentity, desktopWidth: 1920, desktopHeight: 1080, wantWidth: 1920, wantHeight: 1080}, + {name: "unspecified", rotation: dxgiRotationUnspecified, desktopWidth: 1920, desktopHeight: 1080, wantWidth: 1920, wantHeight: 1080}, + {name: "rotate90", rotation: dxgiRotationRotate90, desktopWidth: 768, desktopHeight: 1024, wantWidth: 1024, wantHeight: 768}, + {name: "rotate180", rotation: dxgiRotationRotate180, desktopWidth: 1920, desktopHeight: 1080, wantWidth: 1920, wantHeight: 1080}, + {name: "rotate270", rotation: dxgiRotationRotate270, desktopWidth: 768, desktopHeight: 1024, wantWidth: 1024, wantHeight: 768}, + {name: "unknown", rotation: 99, desktopWidth: 10, desktopHeight: 20, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotWidth, gotHeight, err := dxgiExpectedSurfaceSize(tt.rotation, tt.desktopWidth, tt.desktopHeight) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error for unsupported rotation") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight { + t.Fatalf("expected %dx%d, got %dx%d", tt.wantWidth, tt.wantHeight, gotWidth, gotHeight) + } + }) + } +} + +func TestMapDesktopPointToDXGISurface(t *testing.T) { + tests := []struct { + name string + rotation uint32 + desktopWidth int + desktopHeight int + x int + y int + want image.Point + }{ + {name: "identity top left", rotation: dxgiRotationIdentity, desktopWidth: 3, desktopHeight: 4, x: 0, y: 0, want: image.Pt(0, 0)}, + {name: "rotate90 top left", rotation: dxgiRotationRotate90, desktopWidth: 3, desktopHeight: 4, x: 0, y: 0, want: image.Pt(0, 2)}, + {name: "rotate90 bottom right", rotation: dxgiRotationRotate90, desktopWidth: 3, desktopHeight: 4, x: 2, y: 3, want: image.Pt(3, 0)}, + {name: "rotate180 middle", rotation: dxgiRotationRotate180, desktopWidth: 3, desktopHeight: 4, x: 1, y: 2, want: image.Pt(1, 1)}, + {name: "rotate270 top left", rotation: dxgiRotationRotate270, desktopWidth: 3, desktopHeight: 4, x: 0, y: 0, want: image.Pt(3, 0)}, + {name: "rotate270 bottom right", rotation: dxgiRotationRotate270, desktopWidth: 3, desktopHeight: 4, x: 2, y: 3, want: image.Pt(0, 2)}, + {name: "unspecified behaves like identity", rotation: dxgiRotationUnspecified, desktopWidth: 3, desktopHeight: 4, x: 2, y: 1, want: image.Pt(2, 1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mapDesktopPointToDXGISurface(tt.rotation, tt.desktopWidth, tt.desktopHeight, tt.x, tt.y); got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +func TestClassifyDXGIWaitTimeout(t *testing.T) { + now := time.Now() + deadline := now.Add(250 * time.Millisecond) + + if got := classifyDXGIWaitTimeout(true, now, deadline); got != dxgiWaitTimeoutUseCachedFrame { + t.Fatalf("expected cached-frame action, got %v", got) + } + if got := classifyDXGIWaitTimeout(false, now, deadline); got != dxgiWaitTimeoutRetryAcquire { + t.Fatalf("expected retry action before deadline, got %v", got) + } + if got := classifyDXGIWaitTimeout(false, deadline.Add(time.Millisecond), deadline); got != dxgiWaitTimeoutFail { + t.Fatalf("expected fail action after deadline, got %v", got) + } +} + +func TestDXGIDuplicationManagerRecreatesOnAccessLost(t *testing.T) { + var factoryCalls int32 + var firstClosed int32 + var secondCaptures int32 + + manager := newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + call := atomic.AddInt32(&factoryCalls, 1) + switch call { + case 1: + return &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + return nil, errDXGIAccessLost + }, + closeFn: func() { + atomic.AddInt32(&firstClosed, 1) + }, + }, nil + case 2: + return &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + atomic.AddInt32(&secondCaptures, 1) + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + }, + }, nil + default: + return nil, errors.New("unexpected extra factory call") + } + }) + + img, err := manager.Capture(cap.Request{DisplayID: 7}) + if err != nil { + t.Fatalf("expected recreate to succeed, got %v", err) + } + if img == nil { + t.Fatal("expected an image after recreate") + } + if atomic.LoadInt32(&factoryCalls) != 2 { + t.Fatalf("expected 2 factory calls, got %d", factoryCalls) + } + if atomic.LoadInt32(&firstClosed) != 1 { + t.Fatalf("expected first session to be closed once, got %d", firstClosed) + } + if atomic.LoadInt32(&secondCaptures) != 1 { + t.Fatalf("expected replacement session to capture once, got %d", secondCaptures) + } +} + +func TestDXGIDuplicationManagerSerializesSameDisplay(t *testing.T) { + var factoryCalls int32 + var captureCalls int32 + + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + secondEntered := make(chan struct{}) + + session := &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + call := atomic.AddInt32(&captureCalls, 1) + if call == 1 { + close(firstEntered) + <-releaseFirst + } else { + close(secondEntered) + } + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + }, + } + + manager := newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + atomic.AddInt32(&factoryCalls, 1) + return session, nil + }) + + var wg sync.WaitGroup + errs := make(chan error, 2) + req := cap.Request{DisplayID: 9} + + wg.Add(1) + go func() { + defer wg.Done() + _, err := manager.Capture(req) + errs <- err + }() + + <-firstEntered + + wg.Add(1) + go func() { + defer wg.Done() + _, err := manager.Capture(req) + errs <- err + }() + + select { + case <-secondEntered: + t.Fatal("second capture entered the session before the first finished") + case <-time.After(100 * time.Millisecond): + } + + close(releaseFirst) + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("unexpected capture error: %v", err) + } + } + if atomic.LoadInt32(&factoryCalls) != 1 { + t.Fatalf("expected one shared session for the same display, got %d", factoryCalls) + } + if atomic.LoadInt32(&captureCalls) != 2 { + t.Fatalf("expected two capture calls, got %d", captureCalls) + } +} + +func TestDXGIDuplicationManagerAllowsDifferentDisplays(t *testing.T) { + displayOneEntered := make(chan struct{}) + releaseDisplayOne := make(chan struct{}) + displayTwoEntered := make(chan struct{}) + + manager := newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + switch displayID { + case 1: + return &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + close(displayOneEntered) + <-releaseDisplayOne + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + }, + }, nil + case 2: + return &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + close(displayTwoEntered) + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + }, + }, nil + default: + return nil, errors.New("unexpected display ID") + } + }) + + doneOne := make(chan error, 1) + go func() { + _, err := manager.Capture(cap.Request{DisplayID: 1}) + doneOne <- err + }() + + <-displayOneEntered + + doneTwo := make(chan error, 1) + go func() { + _, err := manager.Capture(cap.Request{DisplayID: 2}) + doneTwo <- err + }() + + select { + case <-displayTwoEntered: + case <-time.After(250 * time.Millisecond): + t.Fatal("second display capture did not proceed while the first display was blocked") + } + + close(releaseDisplayOne) + + if err := <-doneOne; err != nil { + t.Fatalf("unexpected error for display 1: %v", err) + } + if err := <-doneTwo; err != nil { + t.Fatalf("unexpected error for display 2: %v", err) + } +} + +type fakeDXGISession struct { + captureFn func(req cap.Request) (*image.RGBA, error) + closeFn func() +} + +func (s *fakeDXGISession) capture(req cap.Request) (*image.RGBA, error) { + if s.captureFn == nil { + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil + } + return s.captureFn(req) +} + +func (s *fakeDXGISession) close() { + if s.closeFn != nil { + s.closeFn() + } +} diff --git a/screenshot/nix_dbus_available.go b/screenshot/nix_dbus_available.go index 0044364..8039a13 100644 --- a/screenshot/nix_dbus_available.go +++ b/screenshot/nix_dbus_available.go @@ -3,6 +3,7 @@ package screenshot import ( + cap "github.com/PekingSpades/DeskAct/capture" "image" "os" ) @@ -10,11 +11,18 @@ import ( // Capture returns screen capture of specified desktop region. // x and y represent distance from the upper-left corner of primary display. // Y-axis is downward direction. This means coordinates system is similar to Windows OS. -func Capture(x, y, width, height int, waylandToken uint64) (img *image.RGBA, e error) { +func Capture(req cap.Request) (img *image.RGBA, e error) { + if req.Options.Backend != cap.CaptureBackendDefault { + return nil, backendUnavailableError(req.Options.Backend, "backend %q is not supported on Linux/X11/Wayland capture", req.Options.Backend) + } + if hasExcludedWindowIDs(req.Options) { + return nil, windowExclusionUnsupportedError(req.Options.Backend) + } + sessionType := os.Getenv("XDG_SESSION_TYPE") if sessionType == "wayland" { - return captureDbus(x, y, width, height, waylandToken) + return captureDbus(req.X, req.Y, req.Width, req.Height, req.Options.WaylandToken) } else { - return captureXinerama(x, y, width, height) + return captureXinerama(req.X, req.Y, req.Width, req.Height) } } diff --git a/screenshot/unsupported.go b/screenshot/unsupported.go index 6fd509b..abb4621 100644 --- a/screenshot/unsupported.go +++ b/screenshot/unsupported.go @@ -2,12 +2,20 @@ package screenshot -import "image" +import ( + cap "github.com/PekingSpades/DeskAct/capture" + "image" +) // Capture returns screen capture of specified desktop region. // x and y represent distance from the upper-left corner of primary display. // Y-axis is downward direction. This means coordinates system is similar to Windows OS. -func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { - _ = waylandToken +func Capture(req cap.Request) (*image.RGBA, error) { + if req.Options.Backend != cap.CaptureBackendDefault { + return nil, backendUnavailableError(req.Options.Backend, "backend %q is not supported on this platform", req.Options.Backend) + } + if hasExcludedWindowIDs(req.Options) { + return nil, windowExclusionUnsupportedError(req.Options.Backend) + } return nil, errUnsupported() } diff --git a/screenshot/windows.go b/screenshot/windows.go index 4b32a1a..d52485e 100644 --- a/screenshot/windows.go +++ b/screenshot/windows.go @@ -4,93 +4,27 @@ package screenshot import ( "errors" - "github.com/lxn/win" "image" - "syscall" - "unsafe" -) - -func Capture(x, y, width, height int, waylandToken uint64) (*image.RGBA, error) { - _ = waylandToken - rect := image.Rect(0, 0, width, height) - img, err := createImage(rect) - if err != nil { - return nil, err - } - - hwnd := getDesktopWindow() - hdc := win.GetDC(hwnd) - if hdc == 0 { - return nil, errors.New("GetDC failed") - } - defer win.ReleaseDC(hwnd, hdc) - - memory_device := win.CreateCompatibleDC(hdc) - if memory_device == 0 { - return nil, errors.New("CreateCompatibleDC failed") - } - defer win.DeleteDC(memory_device) - - bitmap := win.CreateCompatibleBitmap(hdc, int32(width), int32(height)) - if bitmap == 0 { - return nil, errors.New("CreateCompatibleBitmap failed") - } - defer win.DeleteObject(win.HGDIOBJ(bitmap)) - - var header win.BITMAPINFOHEADER - header.BiSize = uint32(unsafe.Sizeof(header)) - header.BiPlanes = 1 - header.BiBitCount = 32 - header.BiWidth = int32(width) - header.BiHeight = int32(-height) - header.BiCompression = win.BI_RGB - header.BiSizeImage = 0 - // GetDIBits balks at using Go memory on some systems. The MSDN example uses - // GlobalAlloc, so we'll do that too. See: - // https://docs.microsoft.com/en-gb/windows/desktop/gdi/capturing-an-image - bitmapDataSize := uintptr(((int64(width)*int64(header.BiBitCount) + 31) / 32) * 4 * int64(height)) - hmem := win.GlobalAlloc(win.GMEM_MOVEABLE, bitmapDataSize) - defer win.GlobalFree(hmem) - memptr := win.GlobalLock(hmem) - defer win.GlobalUnlock(hmem) + cap "github.com/PekingSpades/DeskAct/capture" +) - old := win.SelectObject(memory_device, win.HGDIOBJ(bitmap)) - if old == 0 { - return nil, errors.New("SelectObject failed") - } - defer win.SelectObject(memory_device, old) +var windowsDXGIManager = newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + return newDXGIDuplicationSession(displayID) +}) - if !win.BitBlt(memory_device, 0, 0, int32(width), int32(height), hdc, int32(x), int32(y), win.SRCCOPY) { - return nil, errors.New("BitBlt failed") +func Capture(req cap.Request) (*image.RGBA, error) { + if req.Width <= 0 || req.Height <= 0 { + return nil, errors.New("width or height should be > 0") } - if win.GetDIBits(hdc, bitmap, 0, uint32(height), (*uint8)(memptr), (*win.BITMAPINFO)(unsafe.Pointer(&header)), win.DIB_RGB_COLORS) == 0 { - return nil, errors.New("GetDIBits failed") + switch normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendDXGI) { + case cap.CaptureBackendDXGI: + return windowsDXGIManager.Capture(req) + case cap.CaptureBackendGDI: + return captureGDI(req) + default: + backend := normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendDXGI) + return nil, backendUnavailableError(backend, "backend %q is not supported on Windows", backend) } - - i := 0 - src := uintptr(memptr) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - v0 := *(*uint8)(unsafe.Pointer(src)) - v1 := *(*uint8)(unsafe.Pointer(src + 1)) - v2 := *(*uint8)(unsafe.Pointer(src + 2)) - - // BGRA => RGBA, and set A to 255 - img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = v2, v1, v0, 255 - - i += 4 - src += 4 - } - } - - return img, nil -} - -func getDesktopWindow() win.HWND { - user32 := syscall.NewLazyDLL("user32.dll") - proc := user32.NewProc("GetDesktopWindow") - ret, _, _ := proc.Call() - return win.HWND(ret) } diff --git a/screenshot/windows_dxgi.go b/screenshot/windows_dxgi.go new file mode 100644 index 0000000..0a92f91 --- /dev/null +++ b/screenshot/windows_dxgi.go @@ -0,0 +1,634 @@ +//go:build windows + +package screenshot + +import ( + "errors" + "fmt" + "image" + "syscall" + "time" + "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" + "golang.org/x/sys/windows" +) + +const ( + dxgiFactory1EnumAdapters1Method = 12 + dxgiAdapterEnumOutputsMethod = 7 + dxgiOutputGetDescMethod = 7 + dxgiOutput1DuplicateOutputMethod = 22 + dxgiOutputDuplicationAcquireNextFrame = 8 + dxgiOutputDuplicationReleaseFrame = 14 + d3d11DeviceCreateTexture2DMethod = 5 + d3d11DeviceContextMapMethod = 10 + d3d11DeviceContextUnmapMethod = 11 + d3d11DeviceContextCopyResourceMethod = 43 + d3d11Texture2DGetDescMethod = 10 + dxgiAcquireFrameTimeoutMillis uint = 250 + d3dDriverTypeUnknown uint = 0 + d3d11CreateDeviceBGRASupport uint = 0x20 + d3d11SDKVersion uint = 7 + d3d11UsageStaging uint32 = 3 + d3d11CPUAccessRead uint32 = 0x20000 + d3d11MapRead uint32 = 1 + dxgiErrorNotFound uint32 = 0x887A0002 + dxgiErrorUnsupported uint32 = 0x887A0004 + dxgiErrorNotCurrentlyAvailable uint32 = 0x887A0022 + dxgiErrorAccessLost uint32 = 0x887A0026 + dxgiErrorWaitTimeout uint32 = 0x887A0027 + dxgiErrorSessionDisconnected uint32 = 0x887A0028 + eAccessDenied uint32 = 0x80070005 +) + +var ( + modDXGI = windows.NewLazySystemDLL("dxgi.dll") + procCreateDXGIFactory1 = modDXGI.NewProc("CreateDXGIFactory1") + modD3D11 = windows.NewLazySystemDLL("d3d11.dll") + procD3D11CreateDevice = modD3D11.NewProc("D3D11CreateDevice") + + iidIDXGIFactory1 = windows.GUID{Data1: 0x770aae78, Data2: 0xf26f, Data3: 0x4dba, Data4: [8]byte{0xa8, 0x29, 0x25, 0x3c, 0x83, 0xd1, 0xb3, 0x87}} + iidIDXGIOutput1 = windows.GUID{Data1: 0x00cddea8, Data2: 0x939b, Data3: 0x4b83, Data4: [8]byte{0xa3, 0x40, 0xa6, 0x85, 0x22, 0x66, 0x66, 0xcc}} + iidID3D11Texture2D = windows.GUID{Data1: 0x6f15aaf2, Data2: 0xd208, Data3: 0x4e89, Data4: [8]byte{0x9a, 0xb4, 0x48, 0x95, 0x35, 0xd3, 0x4f, 0x9c}} +) + +type dxgiFactory1 struct{ lpVtbl uintptr } +type dxgiAdapter1 struct{ lpVtbl uintptr } +type dxgiOutput struct{ lpVtbl uintptr } +type dxgiOutput1 struct{ lpVtbl uintptr } +type dxgiOutputDuplication struct{ lpVtbl uintptr } +type dxgiResource struct{ lpVtbl uintptr } +type d3d11Device struct{ lpVtbl uintptr } +type d3d11DeviceContext struct{ lpVtbl uintptr } +type d3d11Texture2D struct{ lpVtbl uintptr } + +type dxgiRect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +type dxgiPoint struct { + X int32 + Y int32 +} + +type dxgiOutputDesc struct { + DeviceName [32]uint16 + DesktopCoordinates dxgiRect + AttachedToDesktop int32 + Rotation uint32 + Monitor windows.Handle +} + +type dxgiOutduplPointerPosition struct { + Position dxgiPoint + Visible int32 +} + +type dxgiOutduplFrameInfo struct { + LastPresentTime int64 + LastMouseUpdateTime int64 + AccumulatedFrames uint32 + RectsCoalesced int32 + ProtectedContentMaskedOut int32 + PointerPosition dxgiOutduplPointerPosition + TotalMetadataBufferSize uint32 + PointerShapeBufferSize uint32 +} + +type d3d11SampleDesc struct { + Count uint32 + Quality uint32 +} + +type d3d11Texture2DDesc struct { + Width uint32 + Height uint32 + MipLevels uint32 + ArraySize uint32 + Format uint32 + SampleDesc d3d11SampleDesc + Usage uint32 + BindFlags uint32 + CPUAccessFlags uint32 + MiscFlags uint32 +} + +type d3d11MappedSubresource struct { + PData unsafe.Pointer + RowPitch uint32 + DepthPitch uint32 +} + +type dxgiDuplicationSession struct { + outputRect dxgiRect + rotation uint32 + device *d3d11Device + context *d3d11DeviceContext + duplication *dxgiOutputDuplication + staging *d3d11Texture2D + stagingDesc d3d11Texture2DDesc + hasFrame bool +} + +func newDXGIDuplicationSession(displayID int) (*dxgiDuplicationSession, error) { + factory, err := createDXGIFactory1() + if err != nil { + return nil, err + } + defer comRelease(factory) + + adapter, output, outputDesc, err := findDXGIOutput(factory, windows.Handle(uintptr(displayID))) + if err != nil { + return nil, err + } + defer comRelease(output) + defer comRelease(adapter) + + output1, err := queryDXGIOutput1(output) + if err != nil { + return nil, err + } + defer comRelease(output1) + + device, context, err := createD3D11Device(adapter) + if err != nil { + return nil, err + } + + duplication, err := duplicateOutput(output1, device) + if err != nil { + comRelease(context) + comRelease(device) + return nil, err + } + + return &dxgiDuplicationSession{ + outputRect: outputDesc.DesktopCoordinates, + rotation: outputDesc.Rotation, + device: device, + context: context, + duplication: duplication, + }, nil +} + +func (s *dxgiDuplicationSession) close() { + comRelease(s.staging) + s.staging = nil + comRelease(s.duplication) + s.duplication = nil + comRelease(s.context) + s.context = nil + comRelease(s.device) + s.device = nil +} + +func (s *dxgiDuplicationSession) capture(req cap.Request) (*image.RGBA, error) { + desktopWidth := int(s.outputRect.Right - s.outputRect.Left) + desktopHeight := int(s.outputRect.Bottom - s.outputRect.Top) + localX := req.X - int(s.outputRect.Left) + localY := req.Y - int(s.outputRect.Top) + if localX < 0 || localY < 0 || localX+req.Width > desktopWidth || localY+req.Height > desktopHeight { + return nil, fmt.Errorf("capture rect %dx%d at (%d,%d) is outside display bounds", req.Width, req.Height, req.X, req.Y) + } + + if err := s.refreshFrame(); err != nil { + return nil, err + } + return s.readFrame(localX, localY, req.Width, req.Height, desktopWidth, desktopHeight) +} + +func (s *dxgiDuplicationSession) refreshFrame() error { + deadline := time.Now().Add(dxgiInitialFrameMaxWait) + for { + err := s.refreshFrameOnce() + if err == nil { + return nil + } + if !errors.Is(err, errDXGIFrameTimeout) { + return err + } + + switch classifyDXGIWaitTimeout(s.hasFrame, time.Now(), deadline) { + case dxgiWaitTimeoutUseCachedFrame: + return nil + case dxgiWaitTimeoutRetryAcquire: + continue + default: + return fmt.Errorf("DXGI initial frame was not available within %s", dxgiInitialFrameMaxWait) + } + } +} + +func (s *dxgiDuplicationSession) refreshFrameOnce() error { + var frameInfo dxgiOutduplFrameInfo + var resource *dxgiResource + + hr := dxgiOutputDuplicationAcquireFrame(s.duplication, uint32(dxgiAcquireFrameTimeoutMillis), &frameInfo, &resource) + switch hresultCode(hr) { + case 0: + case dxgiErrorWaitTimeout: + return errDXGIFrameTimeout + case dxgiErrorAccessLost: + return fmt.Errorf("%w: AcquireNextFrame returned %s", errDXGIAccessLost, formatHRESULT(hr)) + default: + return fmt.Errorf("IDXGIOutputDuplication::AcquireNextFrame failed: %s", formatHRESULT(hr)) + } + + defer comRelease(resource) + defer func() { + releaseHR := dxgiOutputDuplicationReleaseCurrentFrame(s.duplication) + if hresultCode(releaseHR) == dxgiErrorAccessLost { + // Access loss is handled by the caller on the next capture attempt. + s.hasFrame = false + } + }() + + texture, err := queryD3D11Texture(resource) + if err != nil { + return err + } + defer comRelease(texture) + + if err := s.ensureStagingTexture(texture); err != nil { + return err + } + + d3d11DeviceContextCopyResource(s.context, s.staging, texture) + s.hasFrame = true + return nil +} + +func (s *dxgiDuplicationSession) ensureStagingTexture(source *d3d11Texture2D) error { + sourceDesc := d3d11Texture2DDescription(source) + if sourceDesc.Width == 0 || sourceDesc.Height == 0 { + return errors.New("DXGI frame texture has invalid dimensions") + } + + if s.staging != nil && s.stagingDesc.Width == sourceDesc.Width && s.stagingDesc.Height == sourceDesc.Height && s.stagingDesc.Format == sourceDesc.Format { + return nil + } + + comRelease(s.staging) + s.staging = nil + + stagingDesc := sourceDesc + stagingDesc.BindFlags = 0 + stagingDesc.CPUAccessFlags = d3d11CPUAccessRead + stagingDesc.MiscFlags = 0 + stagingDesc.Usage = d3d11UsageStaging + + staging, err := d3d11DeviceCreateTexture2D(s.device, &stagingDesc) + if err != nil { + return err + } + + s.staging = staging + s.stagingDesc = stagingDesc + return nil +} + +func (s *dxgiDuplicationSession) readFrame(localX, localY, width, height, desktopWidth, desktopHeight int) (*image.RGBA, error) { + rect := image.Rect(0, 0, width, height) + img, err := createImage(rect) + if err != nil { + return nil, err + } + + expectedSurfaceWidth, expectedSurfaceHeight, err := dxgiExpectedSurfaceSize(s.rotation, desktopWidth, desktopHeight) + if err != nil { + return nil, err + } + if int(s.stagingDesc.Width) != expectedSurfaceWidth || int(s.stagingDesc.Height) != expectedSurfaceHeight { + return nil, fmt.Errorf( + "DXGI staging texture dimensions %dx%d do not match expected %dx%d for rotation %d", + s.stagingDesc.Width, + s.stagingDesc.Height, + expectedSurfaceWidth, + expectedSurfaceHeight, + s.rotation, + ) + } + + var mapped d3d11MappedSubresource + hr := d3d11DeviceContextMap(s.context, s.staging, &mapped) + if hresultFailed(hr) { + return nil, fmt.Errorf("ID3D11DeviceContext::Map failed: %s", formatHRESULT(hr)) + } + defer d3d11DeviceContextUnmap(s.context, s.staging) + + rowPitch := int(mapped.RowPitch) + for row := 0; row < height; row++ { + dst := img.Pix[row*img.Stride:] + desktopY := localY + row + for col := 0; col < width; col++ { + desktopX := localX + col + srcPoint := mapDesktopPointToDXGISurface(s.rotation, desktopWidth, desktopHeight, desktopX, desktopY) + src := uintptr(mapped.PData) + uintptr(srcPoint.Y*rowPitch+srcPoint.X*4) + dstIndex := col * 4 + b := *(*uint8)(unsafe.Pointer(src)) + g := *(*uint8)(unsafe.Pointer(src + 1)) + r := *(*uint8)(unsafe.Pointer(src + 2)) + dst[dstIndex], dst[dstIndex+1], dst[dstIndex+2], dst[dstIndex+3] = r, g, b, 255 + } + } + + return img, nil +} + +func createDXGIFactory1() (*dxgiFactory1, error) { + addr, err := procAddress(procCreateDXGIFactory1) + if err != nil { + return nil, backendUnavailableError(cap.CaptureBackendDXGI, "CreateDXGIFactory1 is unavailable: %v", err) + } + + var factory *dxgiFactory1 + hr, _, _ := syscall.SyscallN( + addr, + uintptr(unsafe.Pointer(&iidIDXGIFactory1)), + uintptr(unsafe.Pointer(&factory)), + ) + if hresultFailed(hr) { + return nil, wrapDXGIInitError("CreateDXGIFactory1", hr) + } + return factory, nil +} + +func findDXGIOutput(factory *dxgiFactory1, monitor windows.Handle) (*dxgiAdapter1, *dxgiOutput, dxgiOutputDesc, error) { + for adapterIndex := uint32(0); ; adapterIndex++ { + var adapter *dxgiAdapter1 + hr := dxgiFactoryEnumAdapters1(factory, adapterIndex, &adapter) + if hresultCode(hr) == dxgiErrorNotFound { + break + } + if hresultFailed(hr) { + return nil, nil, dxgiOutputDesc{}, fmt.Errorf("IDXGIFactory1::EnumAdapters1 failed: %s", formatHRESULT(hr)) + } + + for outputIndex := uint32(0); ; outputIndex++ { + var output *dxgiOutput + hr = dxgiAdapterEnumOutputs(adapter, outputIndex, &output) + if hresultCode(hr) == dxgiErrorNotFound { + break + } + if hresultFailed(hr) { + comRelease(adapter) + return nil, nil, dxgiOutputDesc{}, fmt.Errorf("IDXGIAdapter::EnumOutputs failed: %s", formatHRESULT(hr)) + } + + desc, err := dxgiOutputDescription(output) + if err != nil { + comRelease(output) + comRelease(adapter) + return nil, nil, dxgiOutputDesc{}, err + } + if desc.Monitor == monitor { + return adapter, output, desc, nil + } + comRelease(output) + } + + comRelease(adapter) + } + + return nil, nil, dxgiOutputDesc{}, backendUnavailableError(cap.CaptureBackendDXGI, "no DXGI output matched display %d", uintptr(monitor)) +} + +func queryDXGIOutput1(output *dxgiOutput) (*dxgiOutput1, error) { + var output1 *dxgiOutput1 + hr := comQueryInterface(unsafe.Pointer(output), &iidIDXGIOutput1, unsafe.Pointer(&output1)) + if hresultFailed(hr) { + return nil, wrapDXGIInitError("IDXGIOutput::QueryInterface(IDXGIOutput1)", hr) + } + return output1, nil +} + +func createD3D11Device(adapter *dxgiAdapter1) (*d3d11Device, *d3d11DeviceContext, error) { + addr, err := procAddress(procD3D11CreateDevice) + if err != nil { + return nil, nil, backendUnavailableError(cap.CaptureBackendDXGI, "D3D11CreateDevice is unavailable: %v", err) + } + + var device *d3d11Device + var context *d3d11DeviceContext + hr, _, _ := syscall.SyscallN( + addr, + uintptr(unsafe.Pointer(adapter)), + uintptr(d3dDriverTypeUnknown), + 0, + uintptr(d3d11CreateDeviceBGRASupport), + 0, + 0, + uintptr(d3d11SDKVersion), + uintptr(unsafe.Pointer(&device)), + 0, + uintptr(unsafe.Pointer(&context)), + ) + if hresultFailed(hr) { + return nil, nil, wrapDXGIInitError("D3D11CreateDevice", hr) + } + return device, context, nil +} + +func duplicateOutput(output *dxgiOutput1, device *d3d11Device) (*dxgiOutputDuplication, error) { + var duplication *dxgiOutputDuplication + hr := comCall( + unsafe.Pointer(output), + dxgiOutput1DuplicateOutputMethod, + uintptr(unsafe.Pointer(device)), + uintptr(unsafe.Pointer(&duplication)), + ) + if hresultFailed(hr) { + return nil, wrapDXGIInitError("IDXGIOutput1::DuplicateOutput", hr) + } + return duplication, nil +} + +func dxgiFactoryEnumAdapters1(factory *dxgiFactory1, index uint32, adapter **dxgiAdapter1) uintptr { + return comCall(unsafe.Pointer(factory), dxgiFactory1EnumAdapters1Method, uintptr(index), uintptr(unsafe.Pointer(adapter))) +} + +func dxgiAdapterEnumOutputs(adapter *dxgiAdapter1, index uint32, output **dxgiOutput) uintptr { + return comCall(unsafe.Pointer(adapter), dxgiAdapterEnumOutputsMethod, uintptr(index), uintptr(unsafe.Pointer(output))) +} + +func dxgiOutputDescription(output *dxgiOutput) (dxgiOutputDesc, error) { + var desc dxgiOutputDesc + hr := comCall(unsafe.Pointer(output), dxgiOutputGetDescMethod, uintptr(unsafe.Pointer(&desc))) + if hresultFailed(hr) { + return dxgiOutputDesc{}, fmt.Errorf("IDXGIOutput::GetDesc failed: %s", formatHRESULT(hr)) + } + if desc.Monitor == 0 { + return dxgiOutputDesc{}, errors.New("IDXGIOutput::GetDesc returned an invalid monitor handle") + } + return desc, nil +} + +func dxgiOutputDuplicationAcquireFrame(duplication *dxgiOutputDuplication, timeoutMillis uint32, frameInfo *dxgiOutduplFrameInfo, resource **dxgiResource) uintptr { + return comCall( + unsafe.Pointer(duplication), + dxgiOutputDuplicationAcquireNextFrame, + uintptr(timeoutMillis), + uintptr(unsafe.Pointer(frameInfo)), + uintptr(unsafe.Pointer(resource)), + ) +} + +func dxgiOutputDuplicationReleaseCurrentFrame(duplication *dxgiOutputDuplication) uintptr { + return comCall(unsafe.Pointer(duplication), dxgiOutputDuplicationReleaseFrame) +} + +func queryD3D11Texture(resource *dxgiResource) (*d3d11Texture2D, error) { + var texture *d3d11Texture2D + hr := comQueryInterface(unsafe.Pointer(resource), &iidID3D11Texture2D, unsafe.Pointer(&texture)) + if hresultFailed(hr) { + return nil, fmt.Errorf("IDXGIResource::QueryInterface(ID3D11Texture2D) failed: %s", formatHRESULT(hr)) + } + return texture, nil +} + +func d3d11Texture2DDescription(texture *d3d11Texture2D) d3d11Texture2DDesc { + var desc d3d11Texture2DDesc + comCall(unsafe.Pointer(texture), d3d11Texture2DGetDescMethod, uintptr(unsafe.Pointer(&desc))) + return desc +} + +func d3d11DeviceCreateTexture2D(device *d3d11Device, desc *d3d11Texture2DDesc) (*d3d11Texture2D, error) { + var texture *d3d11Texture2D + hr := comCall( + unsafe.Pointer(device), + d3d11DeviceCreateTexture2DMethod, + uintptr(unsafe.Pointer(desc)), + 0, + uintptr(unsafe.Pointer(&texture)), + ) + if hresultFailed(hr) { + return nil, fmt.Errorf("ID3D11Device::CreateTexture2D failed: %s", formatHRESULT(hr)) + } + return texture, nil +} + +func d3d11DeviceContextCopyResource(context *d3d11DeviceContext, dst, src *d3d11Texture2D) { + comCall( + unsafe.Pointer(context), + d3d11DeviceContextCopyResourceMethod, + uintptr(unsafe.Pointer(dst)), + uintptr(unsafe.Pointer(src)), + ) +} + +func d3d11DeviceContextMap(context *d3d11DeviceContext, resource *d3d11Texture2D, mapped *d3d11MappedSubresource) uintptr { + return comCall( + unsafe.Pointer(context), + d3d11DeviceContextMapMethod, + uintptr(unsafe.Pointer(resource)), + 0, + uintptr(d3d11MapRead), + 0, + uintptr(unsafe.Pointer(mapped)), + ) +} + +func d3d11DeviceContextUnmap(context *d3d11DeviceContext, resource *d3d11Texture2D) { + comCall( + unsafe.Pointer(context), + d3d11DeviceContextUnmapMethod, + uintptr(unsafe.Pointer(resource)), + 0, + ) +} + +func comQueryInterface(obj unsafe.Pointer, iid *windows.GUID, out unsafe.Pointer) uintptr { + return comCall(obj, 0, uintptr(unsafe.Pointer(iid)), uintptr(out)) +} + +func comRelease(obj any) { + switch v := obj.(type) { + case nil: + return + case *dxgiFactory1: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *dxgiAdapter1: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *dxgiOutput: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *dxgiOutput1: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *dxgiOutputDuplication: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *dxgiResource: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *d3d11Device: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *d3d11DeviceContext: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + case *d3d11Texture2D: + if v != nil { + comCall(unsafe.Pointer(v), 2) + } + } +} + +func comCall(obj unsafe.Pointer, methodIndex uintptr, args ...uintptr) uintptr { + vtbl := *(*uintptr)(obj) + method := *(*uintptr)(unsafe.Pointer(vtbl + methodIndex*unsafe.Sizeof(uintptr(0)))) + callArgs := make([]uintptr, 1+len(args)) + callArgs[0] = uintptr(obj) + copy(callArgs[1:], args) + r1, _, _ := syscall.SyscallN(method, callArgs...) + return r1 +} + +func procAddress(proc *windows.LazyProc) (uintptr, error) { + if err := proc.Find(); err != nil { + return 0, err + } + return proc.Addr(), nil +} + +func wrapDXGIInitError(operation string, hr uintptr) error { + if isDXGIBackendUnavailableHRESULT(hresultCode(hr)) { + return backendUnavailableError(cap.CaptureBackendDXGI, "%s failed: %s", operation, formatHRESULT(hr)) + } + return fmt.Errorf("%s failed: %s", operation, formatHRESULT(hr)) +} + +func isDXGIBackendUnavailableHRESULT(code uint32) bool { + switch code { + case dxgiErrorUnsupported, dxgiErrorNotCurrentlyAvailable, dxgiErrorSessionDisconnected, eAccessDenied: + return true + default: + return false + } +} + +func hresultFailed(hr uintptr) bool { + return int32(hr) < 0 +} + +func hresultCode(hr uintptr) uint32 { + return uint32(hr) +} + +func formatHRESULT(hr uintptr) string { + return fmt.Sprintf("HRESULT 0x%08x", hresultCode(hr)) +} diff --git a/screenshot/windows_gdi.go b/screenshot/windows_gdi.go new file mode 100644 index 0000000..e0906c9 --- /dev/null +++ b/screenshot/windows_gdi.go @@ -0,0 +1,97 @@ +//go:build windows + +package screenshot + +import ( + "errors" + "image" + "syscall" + "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" + "github.com/lxn/win" +) + +func captureGDI(req cap.Request) (*image.RGBA, error) { + rect := image.Rect(0, 0, req.Width, req.Height) + img, err := createImage(rect) + if err != nil { + return nil, err + } + + hwnd := getDesktopWindow() + hdc := win.GetDC(hwnd) + if hdc == 0 { + return nil, errors.New("GetDC failed") + } + defer win.ReleaseDC(hwnd, hdc) + + memoryDevice := win.CreateCompatibleDC(hdc) + if memoryDevice == 0 { + return nil, errors.New("CreateCompatibleDC failed") + } + defer win.DeleteDC(memoryDevice) + + bitmap := win.CreateCompatibleBitmap(hdc, int32(req.Width), int32(req.Height)) + if bitmap == 0 { + return nil, errors.New("CreateCompatibleBitmap failed") + } + defer win.DeleteObject(win.HGDIOBJ(bitmap)) + + var header win.BITMAPINFOHEADER + header.BiSize = uint32(unsafe.Sizeof(header)) + header.BiPlanes = 1 + header.BiBitCount = 32 + header.BiWidth = int32(req.Width) + header.BiHeight = int32(-req.Height) + header.BiCompression = win.BI_RGB + header.BiSizeImage = 0 + + // GetDIBits balks at using Go memory on some systems. The MSDN example uses + // GlobalAlloc, so we'll do that too. See: + // https://docs.microsoft.com/en-gb/windows/desktop/gdi/capturing-an-image + bitmapDataSize := uintptr(((int64(req.Width)*int64(header.BiBitCount) + 31) / 32) * 4 * int64(req.Height)) + hmem := win.GlobalAlloc(win.GMEM_MOVEABLE, bitmapDataSize) + defer win.GlobalFree(hmem) + memptr := win.GlobalLock(hmem) + defer win.GlobalUnlock(hmem) + + old := win.SelectObject(memoryDevice, win.HGDIOBJ(bitmap)) + if old == 0 { + return nil, errors.New("SelectObject failed") + } + defer win.SelectObject(memoryDevice, old) + + if !win.BitBlt(memoryDevice, 0, 0, int32(req.Width), int32(req.Height), hdc, int32(req.X), int32(req.Y), win.SRCCOPY) { + return nil, errors.New("BitBlt failed") + } + + if win.GetDIBits(hdc, bitmap, 0, uint32(req.Height), (*uint8)(memptr), (*win.BITMAPINFO)(unsafe.Pointer(&header)), win.DIB_RGB_COLORS) == 0 { + return nil, errors.New("GetDIBits failed") + } + + i := 0 + src := uintptr(memptr) + for y := 0; y < req.Height; y++ { + for x := 0; x < req.Width; x++ { + v0 := *(*uint8)(unsafe.Pointer(src)) + v1 := *(*uint8)(unsafe.Pointer(src + 1)) + v2 := *(*uint8)(unsafe.Pointer(src + 2)) + + // BGRA => RGBA, and set A to 255 + img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = v2, v1, v0, 255 + + i += 4 + src += 4 + } + } + + return img, nil +} + +func getDesktopWindow() win.HWND { + user32 := syscall.NewLazyDLL("user32.dll") + proc := user32.NewProc("GetDesktopWindow") + ret, _, _ := proc.Call() + return win.HWND(ret) +} From c28a807de046d85de9d21cb2fc9471c76e9765ae Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:43:54 +0800 Subject: [PATCH 30/37] Trigger CI for PR #3 From 4bf6329f541ab2c7afeffe0d95e702bf236cb649 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:00:22 +0800 Subject: [PATCH 31/37] Fix CI build regressions --- base/deadbeef_rand.h | 12 +++++++--- base/deadbeef_rand_c.h | 18 ++++++++++----- screenshot/darwin.go | 51 +++++++++++++++++++++++++++--------------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/base/deadbeef_rand.h b/base/deadbeef_rand.h index 02e3d4e..bf1d914 100644 --- a/base/deadbeef_rand.h +++ b/base/deadbeef_rand.h @@ -3,17 +3,21 @@ #include +#ifndef DEADBEEF_RAND_API +#define DEADBEEF_RAND_API extern +#endif + #define DEADBEEF_MAX UINT32_MAX /* Dead Beef Random Number Generator From: http://inglorion.net/software/deadbeef_rand */ /* Generates a random number between 0 and DEADBEEF_MAX. */ -uint32_t deadbeef_rand(void); +DEADBEEF_RAND_API uint32_t deadbeef_rand(void); /* Seeds with the given integer. */ -void deadbeef_srand(uint32_t x); +DEADBEEF_RAND_API void deadbeef_srand(uint32_t x); /* Generates seed from the current time. */ -uint32_t deadbeef_generate_seed(void); +DEADBEEF_RAND_API uint32_t deadbeef_generate_seed(void); /* Seeds with the above function. */ #define deadbeef_srand_time() deadbeef_srand(deadbeef_generate_seed()) @@ -25,4 +29,6 @@ uint32_t deadbeef_generate_seed(void); /* Returns random integer in the range [a, b).*/ #define DEADBEEF_RANDRANGE(a, b) (uint32_t)DEADBEEF_UNIFORM(a, b) +#undef DEADBEEF_RAND_API + #endif /* DEADBEEF_RAND_H */ diff --git a/base/deadbeef_rand_c.h b/base/deadbeef_rand_c.h index 1a0173f..e3b9ccf 100644 --- a/base/deadbeef_rand_c.h +++ b/base/deadbeef_rand_c.h @@ -1,23 +1,29 @@ +#ifndef DEADBEEF_RAND_C_H +#define DEADBEEF_RAND_C_H + +#define DEADBEEF_RAND_API static inline #include "deadbeef_rand.h" #include static uint32_t deadbeef_seed; static uint32_t deadbeef_beef = 0xdeadbeef; -uint32_t deadbeef_rand(void) { +static inline uint32_t deadbeef_rand(void) { deadbeef_seed = (deadbeef_seed << 7) ^ ((deadbeef_seed >> 25) + deadbeef_beef); deadbeef_beef = (deadbeef_beef << 7) ^ ((deadbeef_beef >> 25) + 0xdeadbeef); return deadbeef_seed; } -void deadbeef_srand(uint32_t x) { +static inline void deadbeef_srand(uint32_t x) { deadbeef_seed = x; deadbeef_beef = 0xdeadbeef; } /* Taken directly from the documentation: http://inglorion.net/software/cstuff/deadbeef_rand/ */ -uint32_t deadbeef_generate_seed(void) { - uint32_t t = (uint32_t)time(NULL); - uint32_t c = (uint32_t)clock(); - return (t << 24) ^ (c << 11) ^ t ^ (size_t) &c; +static inline uint32_t deadbeef_generate_seed(void) { + uint32_t t = (uint32_t)time(NULL); + uint32_t c = (uint32_t)clock(); + return (t << 24) ^ (c << 11) ^ t ^ (size_t)&c; } + +#endif /* DEADBEEF_RAND_C_H */ diff --git a/screenshot/darwin.go b/screenshot/darwin.go index 5a6391a..1e95465 100644 --- a/screenshot/darwin.go +++ b/screenshot/darwin.go @@ -40,19 +40,19 @@ static CaptureResult captureWithCGDisplay(CGDirectDisplayID id, CGRect diInterse return result; } -static CaptureResult captureWithScreenCaptureKit(CGDirectDisplayID id, - CGRect diIntersectDisplayLocal, - CGColorSpaceRef colorSpace, - const uint64_t* excludedWindowIDs, - size_t excludedWindowCount) { - CaptureResult result = {0}; #if defined(HAS_SCREENCAPTUREKIT) - if (@available(macOS 14.4, *)) { - } else { - result.status = CaptureStatusBackendUnavailable; - return result; - } - +static CaptureResult captureWithScreenCaptureKitAvailable(CGDirectDisplayID id, + CGRect diIntersectDisplayLocal, + CGColorSpaceRef colorSpace, + const uint64_t* excludedWindowIDs, + size_t excludedWindowCount) API_AVAILABLE(macos(14.4)); + +static CaptureResult captureWithScreenCaptureKitAvailable(CGDirectDisplayID id, + CGRect diIntersectDisplayLocal, + CGColorSpaceRef colorSpace, + const uint64_t* excludedWindowIDs, + size_t excludedWindowCount) { + CaptureResult result = {0}; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); __block CaptureStatus status = CaptureStatusCaptureFailed; __block CGImageRef capturedImage = nil; @@ -118,15 +118,28 @@ static CaptureResult captureWithScreenCaptureKit(CGDirectDisplayID id, result.image = capturedImage; result.status = status; return result; +} +#endif + +static CaptureResult captureWithScreenCaptureKit(CGDirectDisplayID id, + CGRect diIntersectDisplayLocal, + CGColorSpaceRef colorSpace, + const uint64_t* excludedWindowIDs, + size_t excludedWindowCount) { + CaptureResult result = {0}; +#if defined(HAS_SCREENCAPTUREKIT) + if (@available(macOS 14.4, *)) { + return captureWithScreenCaptureKitAvailable(id, diIntersectDisplayLocal, colorSpace, excludedWindowIDs, excludedWindowCount); + } #else (void)id; (void)diIntersectDisplayLocal; (void)colorSpace; (void)excludedWindowIDs; (void)excludedWindowCount; +#endif result.status = CaptureStatusBackendUnavailable; return result; -#endif } */ import "C" @@ -247,6 +260,7 @@ func Capture(req cap.Request) (*image.RGBA, error) { } func captureDarwinImage(id C.CGDirectDisplayID, rect C.CGRect, colorSpace C.CGColorSpaceRef, backend cap.CaptureBackend, excludedWindowIDs []C.uint64_t) (C.CGImageRef, error) { + var zero C.CGImageRef switch backend { case cap.CaptureBackendScreenCaptureKit: var ptr *C.uint64_t @@ -259,23 +273,24 @@ func captureDarwinImage(id C.CGDirectDisplayID, rect C.CGRect, colorSpace C.CGCo result := C.captureWithCGDisplay(id, rect, colorSpace) return darwinCaptureResult(result, backend) default: - return nil, backendUnavailableError(backend, "backend %q is not supported on macOS", backend) + return zero, backendUnavailableError(backend, "backend %q is not supported on macOS", backend) } } func darwinCaptureResult(result C.CaptureResult, backend cap.CaptureBackend) (C.CGImageRef, error) { + var zero C.CGImageRef switch result.status { case C.CaptureStatusOK: if unsafe.Pointer(result.image) == nil { - return nil, errors.New("cannot capture display") + return zero, errors.New("cannot capture display") } return result.image, nil case C.CaptureStatusBackendUnavailable: - return nil, backendUnavailableError(backend, "backend %q is unavailable on this macOS version", backend) + return zero, backendUnavailableError(backend, "backend %q is unavailable on this macOS version", backend) case C.CaptureStatusWindowExclusionUnsupported: - return nil, windowExclusionUnsupportedError(backend) + return zero, windowExclusionUnsupportedError(backend) default: - return nil, errors.New("cannot capture display") + return zero, errors.New("cannot capture display") } } From 190569c48603b857d87d234b37000bc55f541af7 Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:15:42 +0800 Subject: [PATCH 32/37] Restore CGDisplay as macOS default capture backend --- display/capture_defaults_darwin.go | 2 +- display/capture_defaults_darwin_test.go | 4 ++-- screenshot/darwin.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/display/capture_defaults_darwin.go b/display/capture_defaults_darwin.go index baaf7b8..bbc5447 100644 --- a/display/capture_defaults_darwin.go +++ b/display/capture_defaults_darwin.go @@ -7,6 +7,6 @@ import cap "github.com/PekingSpades/DeskAct/capture" // DefaultCaptureOptions returns the default capture options. func DefaultCaptureOptions() CaptureOptions { return CaptureOptions{ - Backend: cap.CaptureBackendScreenCaptureKit, + Backend: cap.CaptureBackendCGDisplay, } } diff --git a/display/capture_defaults_darwin_test.go b/display/capture_defaults_darwin_test.go index b43debf..66d1522 100644 --- a/display/capture_defaults_darwin_test.go +++ b/display/capture_defaults_darwin_test.go @@ -6,7 +6,7 @@ import "testing" func TestDefaultCaptureOptionsOnDarwin(t *testing.T) { options := DefaultCaptureOptions() - if options.Backend != CaptureBackendScreenCaptureKit { - t.Fatalf("expected default backend %q, got %q", CaptureBackendScreenCaptureKit, options.Backend) + if options.Backend != CaptureBackendCGDisplay { + t.Fatalf("expected default backend %q, got %q", CaptureBackendCGDisplay, options.Backend) } } diff --git a/screenshot/darwin.go b/screenshot/darwin.go index 1e95465..052454e 100644 --- a/screenshot/darwin.go +++ b/screenshot/darwin.go @@ -157,7 +157,7 @@ func Capture(req cap.Request) (*image.RGBA, error) { return nil, errors.New("width or height should be > 0") } - backend := normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendScreenCaptureKit) + backend := normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendCGDisplay) if hasExcludedWindowIDs(req.Options) && backend != cap.CaptureBackendScreenCaptureKit { return nil, windowExclusionUnsupportedError(backend) } From 37f2a067d5e13e83a5e720691a06afadce0b1a2d Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:39:56 +0800 Subject: [PATCH 33/37] Stabilize Windows DXGI capture threading --- screenshot/dxgi_helpers.go | 124 ++++++++++++++++++++++++-------- screenshot/dxgi_helpers_test.go | 18 +++++ screenshot/windows.go | 3 + screenshot/windows_dxgi.go | 42 +++++++++++ 4 files changed, 156 insertions(+), 31 deletions(-) diff --git a/screenshot/dxgi_helpers.go b/screenshot/dxgi_helpers.go index c4002a9..88ae3bf 100644 --- a/screenshot/dxgi_helpers.go +++ b/screenshot/dxgi_helpers.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "image" + "runtime" "sync" "time" @@ -47,8 +48,21 @@ type dxgiDuplicationManager struct { } type dxgiDuplicationEntry struct { - mu sync.Mutex - session dxgiCaptureSession + worker *dxgiDuplicationWorker +} + +type dxgiDuplicationWorker struct { + requests chan dxgiCaptureRequest +} + +type dxgiCaptureRequest struct { + req cap.Request + resp chan dxgiCaptureResponse +} + +type dxgiCaptureResponse struct { + img *image.RGBA + err error } func newDXGIDuplicationManager(factory dxgiSessionFactory) *dxgiDuplicationManager { @@ -60,30 +74,13 @@ func newDXGIDuplicationManager(factory dxgiSessionFactory) *dxgiDuplicationManag func (m *dxgiDuplicationManager) Capture(req cap.Request) (*image.RGBA, error) { entry := m.entry(req.DisplayID) - entry.mu.Lock() - defer entry.mu.Unlock() - - session, err := m.ensureSessionLocked(entry, req.DisplayID) - if err != nil { - return nil, err - } - - img, err := session.capture(req) - if err == nil { - return img, nil - } - if !errors.Is(err, errDXGIAccessLost) { - return nil, err - } - - session.close() - entry.session = nil - - session, err = m.ensureSessionLocked(entry, req.DisplayID) - if err != nil { - return nil, err + resp := make(chan dxgiCaptureResponse, 1) + entry.worker.requests <- dxgiCaptureRequest{ + req: req, + resp: resp, } - return session.capture(req) + result := <-resp + return result.img, result.err } func (m *dxgiDuplicationManager) entry(displayID int) *dxgiDuplicationEntry { @@ -92,25 +89,90 @@ func (m *dxgiDuplicationManager) entry(displayID int) *dxgiDuplicationEntry { entry := m.entries[displayID] if entry == nil { - entry = &dxgiDuplicationEntry{} + entry = &dxgiDuplicationEntry{ + worker: newDXGIDuplicationWorker(displayID, m.newSession), + } m.entries[displayID] = entry } return entry } -func (m *dxgiDuplicationManager) ensureSessionLocked(entry *dxgiDuplicationEntry, displayID int) (dxgiCaptureSession, error) { - if entry.session != nil { - return entry.session, nil +func newDXGIDuplicationWorker(displayID int, factory dxgiSessionFactory) *dxgiDuplicationWorker { + worker := &dxgiDuplicationWorker{ + requests: make(chan dxgiCaptureRequest), + } + go worker.run(displayID, factory) + return worker +} + +func (w *dxgiDuplicationWorker) run(displayID int, factory dxgiSessionFactory) { + // Keep DXGI/Desktop Duplication work on a fixed OS thread. The reference + // implementations are all single-threaded here, and this avoids Go runtime + // thread migration across COM/D3D11/Desktop APIs. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var session dxgiCaptureSession + defer func() { + if session != nil { + session.close() + } + }() + + for request := range w.requests { + img, err := func() (img *image.RGBA, err error) { + defer func() { + if recovered := recover(); recovered != nil { + if session != nil { + session.close() + session = nil + } + err = fmt.Errorf("DXGI capture panicked: %v", recovered) + } + }() + + session, err = ensureDXGISession(session, displayID, factory) + if err != nil { + return nil, err + } + + img, err = session.capture(request.req) + if err == nil { + return img, nil + } + if !errors.Is(err, errDXGIAccessLost) { + return nil, err + } + + session.close() + session = nil + + session, err = ensureDXGISession(session, displayID, factory) + if err != nil { + return nil, err + } + return session.capture(request.req) + }() + + request.resp <- dxgiCaptureResponse{ + img: img, + err: err, + } + } +} + +func ensureDXGISession(session dxgiCaptureSession, displayID int, factory dxgiSessionFactory) (dxgiCaptureSession, error) { + if session != nil { + return session, nil } - session, err := m.newSession(displayID) + session, err := factory(displayID) if err != nil { return nil, err } if session == nil { return nil, errors.New("DXGI session factory returned nil session") } - entry.session = session return session, nil } diff --git a/screenshot/dxgi_helpers_test.go b/screenshot/dxgi_helpers_test.go index 776629d..c52339f 100644 --- a/screenshot/dxgi_helpers_test.go +++ b/screenshot/dxgi_helpers_test.go @@ -264,6 +264,24 @@ func TestDXGIDuplicationManagerAllowsDifferentDisplays(t *testing.T) { } } +func TestDXGIDuplicationManagerRecoversFromSessionPanic(t *testing.T) { + manager := newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + return &fakeDXGISession{ + captureFn: func(req cap.Request) (*image.RGBA, error) { + panic("boom") + }, + }, nil + }) + + _, err := manager.Capture(cap.Request{DisplayID: 1}) + if err == nil { + t.Fatal("expected panic to be converted into an error") + } + if got := err.Error(); got != "DXGI capture panicked: boom" { + t.Fatalf("unexpected panic error: %q", got) + } +} + type fakeDXGISession struct { captureFn func(req cap.Request) (*image.RGBA, error) closeFn func() diff --git a/screenshot/windows.go b/screenshot/windows.go index d52485e..c4096fb 100644 --- a/screenshot/windows.go +++ b/screenshot/windows.go @@ -10,6 +10,9 @@ import ( ) var windowsDXGIManager = newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + if err := prepareDXGIThread(); err != nil { + return nil, err + } return newDXGIDuplicationSession(displayID) }) diff --git a/screenshot/windows_dxgi.go b/screenshot/windows_dxgi.go index 0a92f91..8e95b88 100644 --- a/screenshot/windows_dxgi.go +++ b/screenshot/windows_dxgi.go @@ -47,6 +47,10 @@ var ( procCreateDXGIFactory1 = modDXGI.NewProc("CreateDXGIFactory1") modD3D11 = windows.NewLazySystemDLL("d3d11.dll") procD3D11CreateDevice = modD3D11.NewProc("D3D11CreateDevice") + modUser32DXGI = windows.NewLazySystemDLL("user32.dll") + procOpenInputDesktop = modUser32DXGI.NewProc("OpenInputDesktop") + procSetThreadDesktop = modUser32DXGI.NewProc("SetThreadDesktop") + procCloseDesktop = modUser32DXGI.NewProc("CloseDesktop") iidIDXGIFactory1 = windows.GUID{Data1: 0x770aae78, Data2: 0xf26f, Data3: 0x4dba, Data4: [8]byte{0xa8, 0x29, 0x25, 0x3c, 0x83, 0xd1, 0xb3, 0x87}} iidIDXGIOutput1 = windows.GUID{Data1: 0x00cddea8, Data2: 0x939b, Data3: 0x4b83, Data4: [8]byte{0xa3, 0x40, 0xa6, 0x85, 0x22, 0x66, 0x66, 0xcc}} @@ -175,6 +179,44 @@ func newDXGIDuplicationSession(displayID int) (*dxgiDuplicationSession, error) { }, nil } +func prepareDXGIThread() error { + openInputDesktopAddr, err := procAddress(procOpenInputDesktop) + if err != nil { + return nil + } + setThreadDesktopAddr, err := procAddress(procSetThreadDesktop) + if err != nil { + return nil + } + closeDesktopAddr, err := procAddress(procCloseDesktop) + if err != nil { + return nil + } + + desktop, _, callErr := syscall.SyscallN( + openInputDesktopAddr, + 0, + 0, + uintptr(windows.GENERIC_ALL), + ) + if desktop == 0 { + if callErr != 0 { + return nil + } + return nil + } + defer syscall.SyscallN(closeDesktopAddr, desktop) + + result, _, callErr := syscall.SyscallN(setThreadDesktopAddr, desktop) + if result != 0 { + return nil + } + if callErr != 0 && callErr != windows.ERROR_BUSY { + return fmt.Errorf("SetThreadDesktop failed: %w", callErr) + } + return nil +} + func (s *dxgiDuplicationSession) close() { comRelease(s.staging) s.staging = nil From b84225e3d93bf2583ddb245dca5128edfe311c1d Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:56:04 +0800 Subject: [PATCH 34/37] Fix Windows DXGI D3D11 vtable calls --- screenshot/windows_dxgi.go | 8 +++-- screenshot/windows_dxgi_vtable_test.go | 44 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 screenshot/windows_dxgi_vtable_test.go diff --git a/screenshot/windows_dxgi.go b/screenshot/windows_dxgi.go index 8e95b88..17c4728 100644 --- a/screenshot/windows_dxgi.go +++ b/screenshot/windows_dxgi.go @@ -22,9 +22,11 @@ const ( dxgiOutputDuplicationAcquireNextFrame = 8 dxgiOutputDuplicationReleaseFrame = 14 d3d11DeviceCreateTexture2DMethod = 5 - d3d11DeviceContextMapMethod = 10 - d3d11DeviceContextUnmapMethod = 11 - d3d11DeviceContextCopyResourceMethod = 43 + // ID3D11DeviceContext inherits ID3D11DeviceChild, so the resource-copy + // methods are not near the start of the vtable. + d3d11DeviceContextMapMethod = 14 + d3d11DeviceContextUnmapMethod = 15 + d3d11DeviceContextCopyResourceMethod = 47 d3d11Texture2DGetDescMethod = 10 dxgiAcquireFrameTimeoutMillis uint = 250 d3dDriverTypeUnknown uint = 0 diff --git a/screenshot/windows_dxgi_vtable_test.go b/screenshot/windows_dxgi_vtable_test.go new file mode 100644 index 0000000..d3bf3e9 --- /dev/null +++ b/screenshot/windows_dxgi_vtable_test.go @@ -0,0 +1,44 @@ +//go:build windows + +package screenshot + +import "testing" + +func TestD3D11DeviceContextMethodIndices(t *testing.T) { + if d3d11DeviceContextMapMethod != 14 { + t.Fatalf("expected ID3D11DeviceContext::Map to use vtable index 14, got %d", d3d11DeviceContextMapMethod) + } + if d3d11DeviceContextUnmapMethod != 15 { + t.Fatalf("expected ID3D11DeviceContext::Unmap to use vtable index 15, got %d", d3d11DeviceContextUnmapMethod) + } + if d3d11DeviceContextCopyResourceMethod != 47 { + t.Fatalf("expected ID3D11DeviceContext::CopyResource to use vtable index 47, got %d", d3d11DeviceContextCopyResourceMethod) + } +} + +func TestDXGIVtableMethodIndices(t *testing.T) { + if dxgiFactory1EnumAdapters1Method != 12 { + t.Fatalf("expected IDXGIFactory1::EnumAdapters1 to use vtable index 12, got %d", dxgiFactory1EnumAdapters1Method) + } + if dxgiAdapterEnumOutputsMethod != 7 { + t.Fatalf("expected IDXGIAdapter::EnumOutputs to use vtable index 7, got %d", dxgiAdapterEnumOutputsMethod) + } + if dxgiOutputGetDescMethod != 7 { + t.Fatalf("expected IDXGIOutput::GetDesc to use vtable index 7, got %d", dxgiOutputGetDescMethod) + } + if dxgiOutput1DuplicateOutputMethod != 22 { + t.Fatalf("expected IDXGIOutput1::DuplicateOutput to use vtable index 22, got %d", dxgiOutput1DuplicateOutputMethod) + } + if dxgiOutputDuplicationAcquireNextFrame != 8 { + t.Fatalf("expected IDXGIOutputDuplication::AcquireNextFrame to use vtable index 8, got %d", dxgiOutputDuplicationAcquireNextFrame) + } + if dxgiOutputDuplicationReleaseFrame != 14 { + t.Fatalf("expected IDXGIOutputDuplication::ReleaseFrame to use vtable index 14, got %d", dxgiOutputDuplicationReleaseFrame) + } + if d3d11DeviceCreateTexture2DMethod != 5 { + t.Fatalf("expected ID3D11Device::CreateTexture2D to use vtable index 5, got %d", d3d11DeviceCreateTexture2DMethod) + } + if d3d11Texture2DGetDescMethod != 10 { + t.Fatalf("expected ID3D11Texture2D::GetDesc to use vtable index 10, got %d", d3d11Texture2DGetDescMethod) + } +} From 33ff7b9db14aa2049429e232280945095fbadc2f Mon Sep 17 00:00:00 2001 From: PekingSpades <180665176+PekingSpades@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:17:32 +0800 Subject: [PATCH 35/37] Add secure window capture example controls --- examples/capture/main.go | 60 ++++++++++++++++++++++++++++ examples/display/electron/index.html | 33 +++++++++++++++ examples/display/electron/main.js | 46 ++++++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/examples/capture/main.go b/examples/capture/main.go index 3182064..ef85929 100644 --- a/examples/capture/main.go +++ b/examples/capture/main.go @@ -8,6 +8,7 @@ import ( "image/png" "os" "runtime" + "strconv" "strings" deskact "github.com/PekingSpades/DeskAct" @@ -33,6 +34,10 @@ func main() { captureOptions.Backend = backendResult fmt.Printf("Capture backend selected: %v\n", backendSelected) fmt.Printf("Capture backend result: %s\n", captureBackendLabel(captureOptions.Backend)) + if runtime.GOOS == "darwin" { + captureOptions.ExcludedWindowIDs = promptExcludedWindowIDs(captureOptions.Backend) + fmt.Printf("Excluded window IDs: %s\n", formatExcludedWindowIDs(captureOptions.ExcludedWindowIDs)) + } displays := deskact.AllDisplays(displayOptions) count := len(displays) @@ -150,6 +155,7 @@ func main() { logBuilder.WriteString(fmt.Sprintf("DPI init result: %s\n", dpiResult)) logBuilder.WriteString(fmt.Sprintf("Capture backend selected: %v\n", backendSelected)) logBuilder.WriteString(fmt.Sprintf("Capture backend result: %s\n", captureBackendLabel(captureOptions.Backend))) + logBuilder.WriteString(fmt.Sprintf("Excluded window IDs: %s\n", formatExcludedWindowIDs(captureOptions.ExcludedWindowIDs))) logBuilder.WriteString(fmt.Sprintf("Total displays: %d\n", count)) for _, d := range displays { info := d.Info() @@ -276,6 +282,60 @@ func captureBackendLabel(backend deskact.CaptureBackend) string { } } +func promptExcludedWindowIDs(backend deskact.CaptureBackend) []uint64 { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Printf("\nOptional: enter macOS excluded window IDs [Enter for none].\n") + fmt.Printf("Current backend: %s. Window exclusion is supported by %s.\n", captureBackendLabel(backend), captureBackendLabel(deskact.CaptureBackendScreenCaptureKit)) + fmt.Print("Excluded window IDs: ") + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return nil + } + + fields := strings.FieldsFunc(input, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) + if len(fields) == 0 { + return nil + } + + ids := make([]uint64, 0, len(fields)) + seen := make(map[uint64]struct{}, len(fields)) + valid := true + for _, field := range fields { + id, err := strconv.ParseUint(field, 10, 64) + if err != nil { + fmt.Printf("Invalid window ID %q. Enter decimal uint64 IDs separated by commas or spaces.\n", field) + valid = false + break + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if valid { + return ids + } + } +} + +func formatExcludedWindowIDs(ids []uint64) string { + if len(ids) == 0 { + return "none" + } + + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, strconv.FormatUint(id, 10)) + } + return strings.Join(parts, ", ") +} + func savePNG(img image.Image, path string) error { file, err := os.Create(path) if err != nil { diff --git a/examples/display/electron/index.html b/examples/display/electron/index.html index a2d1a23..4b28ea5 100644 --- a/examples/display/electron/index.html +++ b/examples/display/electron/index.html @@ -14,6 +14,20 @@ .meta { font-size: 12px; color: #94a3b8; margin-bottom: 20px; } .meta span { margin-right: 16px; } .grid { display: flex; flex-direction: column; gap: 16px; } + .window-card { + background: linear-gradient(135deg, #14532d, #0f3d24); + border: 1px solid #22c55e; + border-radius: 10px; + padding: 16px; + margin-bottom: 18px; + } + .window-title { + display: flex; align-items: center; gap: 8px; + margin-bottom: 12px; font-size: 15px; font-weight: 600; color: #ecfdf5; + } + .window-note { + margin-top: 12px; color: #bbf7d0; font-size: 12px; line-height: 1.5; + } .card { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 16px; position: relative; @@ -38,6 +52,7 @@

      Electron Display Info

      +