diff --git a/.github/workflows/build-electron-display.yml b/.github/workflows/build-electron-display.yml new file mode 100644 index 0000000..917cc52 --- /dev/null +++ b/.github/workflows/build-electron-display.yml @@ -0,0 +1,148 @@ +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: | + # 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 + output_dir="$d" + break + fi + done + 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 "$output_dir" && zip -ry "../../${archive_name}" .) + else + powershell -Command "Compress-Archive -Path '${output_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 + compression-level: 0 + 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/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml new file mode 100644 index 0000000..f9301fe --- /dev/null +++ b/.github/workflows/build-examples.yml @@ -0,0 +1,166 @@ +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 \ + libxrandr-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 -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 + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: examples-${{ matrix.goos }}-${{ matrix.goarch }} + 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 }}* + 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}/" + shopt -s nullglob + 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 }}* + ) + 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 + 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} (HTTP ${status})" + 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 new file mode 100644 index 0000000..55469cb --- /dev/null +++ b/.github/workflows/build-tester.yml @@ -0,0 +1,149 @@ +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 \ + libxrandr-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 -a -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: 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 }} + 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}/" + 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 + 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} (HTTP ${status})" + 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/.gitignore b/.gitignore index aaadf73..a523cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,23 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ +.deskact-tester/ +.deskact-tester/* + +.refer +.refer/* + +.apps +.apps/* + +display_*.png +window_*.png + +# Electron example +node_modules/ +dist/ +package-lock.json +.claude/settings.json +.claude/settings.local.json 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..b89c84c --- /dev/null +++ b/apps/apps_darwin.go @@ -0,0 +1,311 @@ +//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) { + @autoreleasepool { + if (!path) { + return 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); + } +} + +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 ( + "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..17ad225 --- /dev/null +++ b/apps/apps_windows.go @@ -0,0 +1,1406 @@ +//go:build windows + +package apps + +import ( + "errors" + "image" + "os" + "path/filepath" + "regexp" + "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", + } + 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 { + 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 + } + } + + 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 != "" { + forceIndex := hasIndex || filepath.Ext(iconPath) == "" + icon, iconErr = iconFromFile(iconPath, iconIndex, forceIndex) + } 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 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) +} + +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/base/deadbeef_rand.h b/base/deadbeef_rand.h new file mode 100644 index 0000000..bf1d914 --- /dev/null +++ b/base/deadbeef_rand.h @@ -0,0 +1,34 @@ +#ifndef DEADBEEF_RAND_H +#define DEADBEEF_RAND_H + +#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. */ +DEADBEEF_RAND_API uint32_t deadbeef_rand(void); + +/* Seeds with the given integer. */ +DEADBEEF_RAND_API void deadbeef_srand(uint32_t x); + +/* Generates seed from the current time. */ +DEADBEEF_RAND_API 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) + +#undef DEADBEEF_RAND_API + +#endif /* DEADBEEF_RAND_H */ diff --git a/base/deadbeef_rand_c.h b/base/deadbeef_rand_c.h new file mode 100644 index 0000000..e3b9ccf --- /dev/null +++ b/base/deadbeef_rand_c.h @@ -0,0 +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; + +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; +} + +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/ */ +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/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/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/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..ba2ac41 --- /dev/null +++ b/cmd/deskact-tester/main.go @@ -0,0 +1,2553 @@ +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(deskact.MouseButtonLeft, false, settings) + } else { + err = deskact.MultiClick(deskact.MouseButtonLeft, 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) + } + 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) + if toggleErr := deskact.Toggle(deskact.MouseButtonLeft, false, false, settings); toggleErr != nil && err == nil { + err = toggleErr + } + time.Sleep(120 * time.Millisecond) + 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) + } + + 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 { + if err := display.Move(target.X, target.Y, settings); err != nil { + return err + } + 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) + if err := display.Move(pt.X, pt.Y, settings); err != nil { + return err + } + time.Sleep(120 * time.Millisecond) + if err := deskact.Click(deskact.MouseButtonLeft, false, settings); err != nil { + return err + } + time.Sleep(120 * time.Millisecond) + return deskact.Click(deskact.MouseButtonLeft, 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) + if err := display.Move(pt.X, pt.Y, settings); err != nil { + return err + } + time.Sleep(120 * time.Millisecond) + return deskact.Click(deskact.MouseButtonLeft, 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/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..bbc5447 --- /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.CaptureBackendCGDisplay, + } +} diff --git a/display/capture_defaults_darwin_test.go b/display/capture_defaults_darwin_test.go new file mode 100644 index 0000000..66d1522 --- /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 != CaptureBackendCGDisplay { + t.Fatalf("expected default backend %q, got %q", CaptureBackendCGDisplay, 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 new file mode 100644 index 0000000..ef38326 --- /dev/null +++ b/display/defaults.go @@ -0,0 +1,10 @@ +package display + +const ( + DefaultDPIAware = false +) + +// DisplayOptions defines platform-specific display options. +type DisplayOptions struct { + DPIAware bool +} diff --git a/display/defaults_other.go b/display/defaults_other.go new file mode 100644 index 0000000..b4e34cf --- /dev/null +++ b/display/defaults_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package display + +// DefaultDisplayOptions returns the default display options. +func DefaultDisplayOptions() DisplayOptions { + return DisplayOptions{DPIAware: DefaultDPIAware} +} diff --git a/display/defaults_windows.go b/display/defaults_windows.go new file mode 100644 index 0000000..ef2d7c8 --- /dev/null +++ b/display/defaults_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package display + +// DefaultDisplayOptions returns the default display options. +func DefaultDisplayOptions() DisplayOptions { + return DisplayOptions{DPIAware: IsDPIAware()} +} diff --git a/display/display.go b/display/display.go new file mode 100644 index 0000000..58ccb1e --- /dev/null +++ b/display/display.go @@ -0,0 +1,120 @@ +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"). + Platform() string +} + +// Display represents a physical display/monitor. +type Display struct { + 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 + Size Size // Physical pixel size + ScaleFactor float64 // Scale factor +} + +// ID returns the platform-specific display identifier. +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 +} + +// 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, + ElectronID: d.electronId, + Index: d.index, + IsMain: d.isMain, + Origin: d.origin, + Size: d.size, + ScaleFactor: d.scale, + } +} + +// 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 +// - CaptureRect(x, y, w, h int, options CaptureOptions) (*image.RGBA, error) +// - MouseLocation() (x, y int, ok bool) +// - ContainsMouse() bool diff --git a/display/display_c.h b/display/display_c.h new file mode 100644 index 0000000..2251cdc --- /dev/null +++ b/display/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/display/display_c_macos.h b/display/display_c_macos.h new file mode 100644 index 0000000..8350456 --- /dev/null +++ b/display/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/display/display_c_windows.h b/display/display_c_windows.h new file mode 100644 index 0000000..d1c0f50 --- /dev/null +++ b/display/display_c_windows.h @@ -0,0 +1,294 @@ +// 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 +#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 + int32_t vw, vh; // Virtual (logical) size + double scale; // Scale factor (physical/logical) + char electronIdHashInput[256]; // Raw string for SuperFastHash (electron ID) + int32_t electronIdHashInputLen; // Length of the hash input string +} 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 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. 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; + } + 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 - 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); + *outputLen = (int32_t)len; + free(paths); + free(modes); + return; + } + } + + free(paths); + free(modes); + } + +fallback: + { + // Fallback: convert GDI device name to UTF-8 + int utf8Len = WideCharToMultiByte(CP_UTF8, 0, monInfo->szDevice, -1, + output, 256, NULL, NULL); + if (utf8Len > 1) { + *outputLen = (int32_t)(utf8Len - 1); // exclude null terminator + } else { + *outputLen = 0; + } + } +} + +// 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 and get device name for electron ID) + MONITORINFOEXW mi = {0}; + mi.cbSize = sizeof(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; + getElectronIdHashInput(&mi, info->electronIdHashInput, &info->electronIdHashInputLen); + + // 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 + 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 + 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 = logicalW; + info->h = logicalH; + 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/display/display_c_x11.h b/display/display_c_x11.h new file mode 100644 index 0000000..ff49fec --- /dev/null +++ b/display/display_c_x11.h @@ -0,0 +1,300 @@ +// 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 +#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) + // 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; + +// 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)) { + return; + } + + Window root = DefaultRootWindow(dpy); + XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, root); + if (!res) { + return; + } + + int8_t* matched = (int8_t*)calloc(count, sizeof(int8_t)); + if (!matched) { + XRRFreeScreenResources(res); + 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 = ((uint16_t)edid[8] << 8) | (uint16_t)edid[9]; + + for (int d = 0; d < 4; d++) { + int offset = 54 + d * 18; + if (offset + 18 > (int)nitems) break; + if (edid[offset] == 0 && edid[offset+1] == 0 && + edid[offset+2] == 0 && edid[offset+3] == 0xFC) { + 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); + } + + uint32_t output_id = (uint32_t)res->outputs[o]; + + // 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].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; + } + } + + XRRFreeCrtcInfo(crtcInfo); + XRRFreeOutputInfo(outInfo); + } + + free(matched); + XRRFreeScreenResources(res); +} + +// 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); + populateEdidData(dpy, displays, resultCount); + 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/display/display_darwin.go b/display/display_darwin.go new file mode 100644 index 0000000..1ec094e --- /dev/null +++ b/display/display_darwin.go @@ -0,0 +1,207 @@ +//go:build darwin +// +build darwin + +package display + +/* +#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 "display_c.h" +*/ +import "C" + +import ( + "image" + + cap "github.com/PekingSpades/DeskAct/capture" + "github.com/PekingSpades/DeskAct/mouse" + "github.com/PekingSpades/DeskAct/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), + 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}}, + 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), + 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}}, + 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), + 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}}, + 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) error { + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + 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) error { + virtAbsX, virtAbsY := d.ToAbsolute(physX, physY) + return mouse.MoveSmooth(virtAbsX, virtAbsY, 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(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. +func (d *Display) MouseLocation() (physX, physY int, ok bool) { + 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 := mouse.Location() + return d.Contains(virtAbsX, virtAbsY) +} diff --git a/display/display_linux.go b/display/display_linux.go new file mode 100644 index 0000000..1b93732 --- /dev/null +++ b/display/display_linux.go @@ -0,0 +1,180 @@ +//go:build linux +// +build linux + +package display + +/* +#cgo linux CFLAGS: -I/usr/src +#cgo linux LDFLAGS: -L/usr/src -lm -lX11 -lXtst -lXinerama -lXrandr + +#include "display_c.h" +*/ +import "C" + +import ( + "image" + "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" + "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: 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}}, + 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), + 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}}, + 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), + 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}}, + 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) error { + absX, absY := d.ToAbsolute(x, y) + 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) error { + absX, absY := d.ToAbsolute(x, y) + return mouse.MoveSmooth(absX, absY, 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(cap.Request{ + DisplayID: d.id, + X: absX, + Y: absY, + Width: w, + Height: h, + Options: options, + }) +} + +// MouseLocation gets the mouse location relative to this display. +func (d *Display) MouseLocation() (x, y int, ok bool) { + 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 := mouse.Location() + return d.Contains(absX, absY) +} diff --git a/display/display_windows.go b/display/display_windows.go new file mode 100644 index 0000000..08a33bd --- /dev/null +++ b/display/display_windows.go @@ -0,0 +1,302 @@ +//go:build windows +// +build windows + +package display + +/* +#cgo windows LDFLAGS: -lgdi32 -luser32 + +#include "display_c.h" +*/ +import "C" + +import ( + "errors" + "image" + "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" + "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. + 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 := 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. + 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), + electronId: windowsElectronId(&info), + 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 := 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. + 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), + electronId: windowsElectronId(&cDisplays[i]), + 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 := 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. + 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), + electronId: windowsElectronId(&info), + 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) error { + absX, absY := d.ToAbsolute(x, y) + 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) error { + absX, absY := d.ToAbsolute(x, y) + return mouse.MoveSmooth(absX, absY, 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(cap.Request{ + DisplayID: d.id, + X: absX, + Y: absY, + Width: w, + Height: h, + Options: options, + }) +} + +// MouseLocation gets the mouse location relative to this display. +func (d *Display) MouseLocation() (x, y int, ok bool) { + 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 := mouse.Location() + return d.Contains(absX, absY) +} diff --git a/display/dpi_windows.go b/display/dpi_windows.go new file mode 100644 index 0000000..0242005 --- /dev/null +++ b/display/dpi_windows.go @@ -0,0 +1,90 @@ +//go:build windows +// +build windows + +package display + +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/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/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/types.go b/display/types.go new file mode 100644 index 0000000..ba34b40 --- /dev/null +++ b/display/types.go @@ -0,0 +1,19 @@ +package display + +// 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 +} diff --git a/display_exports.go b/display_exports.go new file mode 100644 index 0000000..ba54a82 --- /dev/null +++ b/display_exports.go @@ -0,0 +1,52 @@ +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 CaptureBackend = display.CaptureBackend +type CaptureOptions = display.CaptureOptions +type Point = display.Point +type Size = display.Size +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() +} + +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_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 +} 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/apps/main.go b/examples/apps/main.go new file mode 100644 index 0000000..c26338c --- /dev/null +++ b/examples/apps/main.go @@ -0,0 +1,1059 @@ +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)) + } + 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) +} + +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...) +} diff --git a/examples/capture/main.go b/examples/capture/main.go new file mode 100644 index 0000000..f9b8d96 --- /dev/null +++ b/examples/capture/main.go @@ -0,0 +1,493 @@ +package main + +import ( + "bufio" + "fmt" + "image" + "image/color" + "image/png" + "os" + "runtime" + "strconv" + "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("========================================") + 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() + 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)) + 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) + 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, captureBackendLabel(captureOptions.Backend)) + } + + 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("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("Excluded window IDs: %s\n", formatExcludedWindowIDs(captureOptions.ExcludedWindowIDs))) + 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 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 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 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 { + return err + } + defer file.Close() + + return png.Encode(file, img) +} + +func drawDisplayInfo(img *image.RGBA, displayX, displayY, displayW, displayH int, info deskact.DisplayInfo, backendLabel string) { + lines := []string{ + fmt.Sprintf("Display #%d", info.Index), + fmt.Sprintf("Backend: %s", backendLabel), + 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/electron/index.html b/examples/display/electron/index.html new file mode 100644 index 0000000..4b28ea5 --- /dev/null +++ b/examples/display/electron/index.html @@ -0,0 +1,130 @@ + + + + +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..d508b91 --- /dev/null +++ b/examples/display/electron/main.js @@ -0,0 +1,143 @@ +const { app, BrowserWindow, screen, ipcMain } = require("electron"); +const path = require("path"); + +app.disableHardwareAcceleration(); + +function parseOSWindowID(mediaSourceId) { + const match = /^window:([^:]+):/.exec(mediaSourceId || ""); + return match ? match[1] : ""; +} + +function getMediaSourceId(win) { + if (typeof win.getMediaSourceId !== "function") { + return ""; + } + return win.getMediaSourceId(); +} + +function formatNativeWindowHandle(buffer) { + if (!Buffer.isBuffer(buffer)) { + return ""; + } + return "0x" + Buffer.from(buffer).toString("hex"); +} + +function getNativeWindowHandleHex(win) { + if (typeof win.getNativeWindowHandle !== "function") { + return ""; + } + return formatNativeWindowHandle(win.getNativeWindowHandle()); +} + +function isContentProtectionEnabled(win, fallbackValue) { + if (typeof win.isContentProtected !== "function") { + return fallbackValue; + } + return win.isContentProtected(); +} + +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("----------------------------------------"); + } +} + +function getWindowData(win) { + const mediaSourceId = getMediaSourceId(win); + + return { + title: win.getTitle(), + electronWindowID: win.id, + mediaSourceId, + osWindowID: parseOSWindowID(mediaSourceId), + nativeWindowHandleHex: getNativeWindowHandleHex(win), + contentProtectionEnabled: isContentProtectionEnabled(win, true), + }; +} + +function printWindowToConsole(win) { + const windowData = getWindowData(win); + + console.log("\nSecure window under test"); + console.log("----------------------------------------"); + console.log(` title: ${windowData.title}`); + console.log(` electronWindowID: ${windowData.electronWindowID}`); + console.log(` mediaSourceId: ${windowData.mediaSourceId}`); + console.log(` osWindowID: ${windowData.osWindowID}`); + console.log(` nativeWindowHandle: ${windowData.nativeWindowHandleHex}`); + console.log(` contentProtection: ${windowData.contentProtectionEnabled}`); + console.log("----------------------------------------"); +} + +app.whenReady().then(() => { + const displays = getDisplayData(); + printToConsole(displays); + + const win = new BrowserWindow({ + width: 720, + height: 560, + title: "DeskAct - Secure Electron Display Info", + webPreferences: { + contextIsolation: false, + nodeIntegration: true, + }, + }); + + win.setContentProtection(true); + win.loadFile(path.join(__dirname, "index.html")); + win.webContents.once("did-finish-load", () => { + printWindowToConsole(win); + }); + + ipcMain.handle("get-display-data", () => ({ + displays, + window: getWindowData(win), + 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..f1c6811 --- /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": "^36.5.0", + "electron-builder": "^26.0.0" + } +} diff --git a/examples/display/main.go b/examples/display/main.go new file mode 100644 index 0000000..47d74f8 --- /dev/null +++ b/examples/display/main.go @@ -0,0 +1,146 @@ +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("========================================") + + 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() + + 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(" 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.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 { + 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() + 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("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() + 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:") + 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:") + } +} + +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/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()) + } +} diff --git a/examples/mouse/main.go b/examples/mouse/main.go new file mode 100644 index 0000000..6398a44 --- /dev/null +++ b/examples/mouse/main.go @@ -0,0 +1,663 @@ +package main + +import ( + "bufio" + "fmt" + "math" + "os" + "runtime" + "strconv" + "strings" + + deskact "github.com/PekingSpades/DeskAct" +) + +const ( + defaultScrollAmount = 3 + maxDisplayASCIIWidth = 30 + asciiHeightScale = 0.5 +) + +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).") + + 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() + 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 "left_drag": + runDrag(reader, delay, settings, deskact.MouseButtonLeft) + 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(" 11) left click drag") + 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 + case "11", "drag", "leftdrag", "leftclickdrag": + return "left_drag", true + default: + return "", false + } +} + +func readLine(reader *bufio.Reader) string { + line, _ := reader.ReadString('\n') + 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) + 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 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 + } + 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) + + 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) { + display, info := selectDisplay(reader) + if display == nil { + return + } + x, y, ok := promptPhysicalPoint(reader, info, "Target") + if !ok { + return + } + waitDelay(delay) + if err := display.Move(x, y, settings); err != nil { + fmt.Printf("Move error: %v\n", err) + return + } + 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) { + 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) +} + +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 +} 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/go.mod b/go.mod new file mode 100644 index 0000000..bdc33b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +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 + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d6cc94 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +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= +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/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..4426cfe --- /dev/null +++ b/key/keypress.h @@ -0,0 +1,54 @@ +#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 + +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); +#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..025a4b2 --- /dev/null +++ b/key/keypress_c_macos.h @@ -0,0 +1,272 @@ +// 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) ? MM_KEY_OK : MM_KEY_ERR_EVENT; +} + +/* + * 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. */ + int err = postMediaKeyEvent(code, true); + if (err != MM_KEY_OK) { return err; } + microsleep(5.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 MM_KEY_ERR_EVENT; + } + 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 MM_KEY_ERR_EVENT; + } + if (flags != 0) { + CGEventSetFlags(keyUp, (CGEventFlags)flags); + } + CGEventPost(kCGHIDEventTap, keyUp); + CFRelease(keyUp); + + CFRelease(source); + return MM_KEY_OK; +} + +/* + * 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); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } + CGEventRef keyEvent = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, down); + + if (keyEvent == NULL) { + CFRelease(source); + return MM_KEY_ERR_EVENT; + } + + CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); + if (flags != 0) { + CGEventSetFlags(keyEvent, (CGEventFlags)flags); + } + + CGEventPost(kCGHIDEventTap, keyEvent); + CFRelease(keyEvent); + CFRelease(source); + return MM_KEY_OK; +} + +/* + * 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); + if (source == NULL) { + return MM_KEY_ERR_EVENT; + } + + CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)code, true); + if (keyDown == NULL) { + CFRelease(source); + return MM_KEY_ERR_EVENT; + } + 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 MM_KEY_ERR_EVENT; + } + if (flags != 0) { + CGEventSetFlags(keyUp, (CGEventFlags)flags); + } + CGEventPostToPid(pid, keyUp); + CFRelease(keyUp); + + CFRelease(source); + return MM_KEY_OK; +} + +/* + * keyTogglePid - Key toggle to a specific process + */ +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 MM_KEY_ERR_EVENT; + } + + CGEventSetType(keyEvent, down ? kCGEventKeyDown : kCGEventKeyUp); + if (flags != 0) { + CGEventSetFlags(keyEvent, (CGEventFlags)flags); + } + + CGEventPostToPid(pid, keyEvent); + CFRelease(keyEvent); + CFRelease(source); + return MM_KEY_OK; +} + +/* + * 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(const UniChar *chars, size_t len, 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, (UniCharCount)len, chars); + SendTo(pid, keyEvent); + CFRelease(source); +} + +void unicodeType(const unsigned value, uintptr pid, int8_t isPid) { + 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(chars, len, 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..c01de3f --- /dev/null +++ b/key/keypress_c_windows.h @@ -0,0 +1,298 @@ +// 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 */ +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; + return postMessageChecked(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 ? MM_KEY_OK : 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 ? MM_KEY_OK : 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) */ + 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; +} + +/* + * keyTogglePid - Key toggle to a specific process + */ +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) { 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 { + 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 MM_KEY_OK; +} + +/* + * 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); + 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)); + + 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..79800d5 --- /dev/null +++ b/key/keypress_c_x11.h @@ -0,0 +1,192 @@ +// 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(); + if (display == NULL) { + return MM_KEY_ERR_DISPLAY; + } + + /* 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 MM_KEY_OK; +} + +/* + * 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(); + if (display == NULL) { + return MM_KEY_ERR_DISPLAY; + } + 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 MM_KEY_OK; +} + +/* + * 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); + if (dpy == NULL) { + return MM_KEY_ERR_DISPLAY; + } + 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 MM_KEY_OK; +} 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_catalog.go b/keyboard/key_catalog.go new file mode 100644 index 0000000..9a85312 --- /dev/null +++ b/keyboard/key_catalog.go @@ -0,0 +1,48 @@ +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, 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, 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, + 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 +} + +// 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 +} diff --git a/keyboard/keyboard.go b/keyboard/keyboard.go new file mode 100644 index 0000000..3c3e92d --- /dev/null +++ b/keyboard/keyboard.go @@ -0,0 +1,466 @@ +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 + } + + if v, ok := keyNameMap[k]; 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..51a7544 --- /dev/null +++ b/keyboard/keys.go @@ -0,0 +1,180 @@ +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" + 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 + Alt = "alt" + Lalt = "lalt" // left alt + Ralt = "ralt" // right alt + Ctrl = "ctrl" + Lctrl = "lctrl" // left ctrl + Rctrl = "rctrl" // right ctrl + Shift = "shift" + Lshift = "lshift" // left shift + Rshift = "rshift" // right shift + Capslock = "capslock" + Space = "space" + Print = "print" + 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/keyboard/special.go b/keyboard/special.go new file mode 100644 index 0000000..e91beee --- /dev/null +++ b/keyboard/special.go @@ -0,0 +1,77 @@ +package keyboard + +// 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/keyboard_exports.go b/keyboard_exports.go new file mode 100644 index 0000000..d35c6ea --- /dev/null +++ b/keyboard_exports.go @@ -0,0 +1,245 @@ +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 + 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 + Shift = kbd.Shift + Lshift = kbd.Lshift + Rshift = kbd.Rshift + Capslock = kbd.Capslock + Space = kbd.Space + Print = kbd.Print + 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 SupportedKeyNames() []string { + return kbd.SupportedKeyNames() +} + +func ModifierNames() []Modifier { + return kbd.ModifierNames() +} + +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/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..a32d363 --- /dev/null +++ b/keyboardstate/state_darwin.go @@ -0,0 +1,97 @@ +//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 + +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("IOHIDSystem")); + 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 bool(capsOk) { + state.mask &^= bitCapsLock + if bool(caps) { + state.mask |= bitCapsLock + } + } + if bool(numOk) { + state.supported |= bitNumLock + if bool(num) { + 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..46abc83 --- /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 +#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() +} 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..d97cc1d --- /dev/null +++ b/mouse/mouse.go @@ -0,0 +1,206 @@ +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 +} + +// 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(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 (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(fromX, fromY, toX, toY, button, settings); err != nil { + _ = Toggle(button, false, false, settings) + return err + } + return Toggle(button, false, false, settings) +} + +// 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 new file mode 100644 index 0000000..1918916 --- /dev/null +++ b/mouse/mouse.h @@ -0,0 +1,41 @@ +#pragma once +#ifndef MOUSE_H +#define MOUSE_H + +#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 + + #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) + #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) + #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 */ diff --git a/mouse/mouse_c.h b/mouse/mouse_c.h new file mode 100644 index 0000000..fd33648 --- /dev/null +++ b/mouse/mouse_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_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..761c751 --- /dev/null +++ b/mouse/mouse_c_macos.h @@ -0,0 +1,278 @@ +// 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_c.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 == MM_BUTTON_LEFT) { + return kCGEventLeftMouseDown; + } + if (button == MM_BUTTON_RIGHT) { + return kCGEventRightMouseDown; + } + return kCGEventOtherMouseDown; +} + +CGEventType MMMouseUpToCGEventType(MMMouseButton button) { + if (button == MM_BUTTON_LEFT) { return kCGEventLeftMouseUp; } + if (button == MM_BUTTON_RIGHT) { return kCGEventRightMouseUp; } + return kCGEventOtherMouseUp; +} + +CGEventType MMMouseDragToCGEventType(MMMouseButton button) { + if (button == MM_BUTTON_LEFT) { return kCGEventLeftMouseDragged; } + if (button == MM_BUTTON_RIGHT) { 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); +} + +/* 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) { + 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); + CGEventRef move = CGEventCreateMouseEvent(source, kCGEventMouseMoved, + CGPointFromMMPointInt32(point), kCGMouseButtonLeft); + + calculateDeltas(&move, point); + + CGEventPost(kCGHIDEventTap, move); + CFRelease(move); + CFRelease(source); + + waitForCursorSync(point, 50.0); +} + +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); + + waitForCursorSync(point, 50.0); +} + +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); + if (source == NULL) { + return (int)kCGErrorCannotComplete; + } + + 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(source); + + return 0; +} + +/* Function used to scroll the screen in the required direction. */ +void scrollMouseXY(int x, int y, MMScrollUnit unit) { + CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); + CGScrollEventUnit cgUnit = unit == MM_SCROLL_UNIT_LINE ? kCGScrollEventUnitLine : kCGScrollEventUnitPixel; + CGEventRef event = CGEventCreateScrollWheelEvent(source, cgUnit, 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 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; + // 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; +} + +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; + + 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 new file mode 100644 index 0000000..006d286 --- /dev/null +++ b/mouse/mouse_c_windows.h @@ -0,0 +1,172 @@ +// 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_c.h" +#include "../base/microsleep.h" + +#include /* For floor() */ + +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; + } + + 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. */ +void moveMouse(MMPointInt32 point){ + SetPhysicalCursorPos(point.x, point.y); +} + +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; + int err = fillMouseInput(down, button, &mouseInput); + if (err != 0) { + return err; + } + 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, 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)); + } + + 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. */ +#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 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; + // 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..63660b3 --- /dev/null +++ b/mouse/mouse_c_x11.h @@ -0,0 +1,149 @@ +// 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_c.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); +} + +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, MMScrollUnit unit) { + (void)unit; + 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 startPoint, MMPointInt32 endPoint, double lowSpeed, double highSpeed){ + MMPointInt32 pos = startPoint; + // 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/platform_darwin.go b/mouse/platform_darwin.go new file mode 100644 index 0000000..d14b8e7 --- /dev/null +++ b/mouse/platform_darwin.go @@ -0,0 +1,91 @@ +//go:build darwin +// +build darwin + +package mouse + +/* +#include "mouse.h" + +void dragMouse(MMPointInt32 point, const MMMouseButton button); +bool smoothlyDragMouse(MMPointInt32 startPoint, MMPointInt32 endPoint, const MMMouseButton button, double lowSpeed, double highSpeed); +*/ +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 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(toX) + cy := C.int32_t(toY) + C.dragMouse(C.MMPointInt32Make(cx, cy), cbtn) + MilliSleep(settings.Sleep) + return nil +} + +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(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(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) + } + return nil +} + +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..556a8dd --- /dev/null +++ b/mouse/platform_linux.go @@ -0,0 +1,89 @@ +//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 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(toX, toY, settings) +} + +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(toX, toY, settings) +} + +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..9cbce80 --- /dev/null +++ b/mouse/platform_windows.go @@ -0,0 +1,75 @@ +//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 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(toX, toY, settings) +} + +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(toX, toY, settings) +} + +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..07bcc7f --- /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(fromX, fromY, toX, toY int, button MouseButton, settings MouseSettings) error { + return m.Drag(fromX, fromY, toX, toY, 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 { + 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/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 new file mode 100644 index 0000000..052454e --- /dev/null +++ b/screenshot/darwin.go @@ -0,0 +1,345 @@ +//go:build cgo && darwin + +package screenshot + +/* +#cgo CFLAGS: -x objective-c +#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 + +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; +} + +#if defined(HAS_SCREENCAPTUREKIT) +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; + + [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; + } + } + 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; + } + } + } + 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); + 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; +} +*/ +import "C" + +import ( + "errors" + "image" + "unsafe" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +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") + } + + backend := normalizeRequestedBackend(req.Options.Backend, cap.CaptureBackendCGDisplay) + 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 + } + + // 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(req.X), C.CGFloat(req.Y+req.Height)) + cgBottomLeft := getCoreGraphicsCoordinateFromWindowsCoordinate(winBottomLeft, cgMainDisplayBounds) + cgCaptureBounds := C.CGRectMake(cgBottomLeft.x, cgBottomLeft.y, C.CGFloat(req.Width), C.CGFloat(req.Height)) + + ids := activeDisplayList() + + 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") + } + + colorSpace := createColorspace() + if colorSpace == 0 { + return nil, errors.New("cannot create colorspace") + } + 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) + if C.CGRectIsNull(cgIntersect) { + continue + } + if cgIntersect.size.width <= 0 || cgIntersect.size.height <= 0 { + continue + } + + // 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) + } + 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, + ) + + imageRef, err := captureDarwinImage(id, diIntersectDisplayLocal, colorSpace, backend, excludedWindowIDs) + if err != nil { + return nil, err + } + 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, imageRef) + } + + i := 0 + for iy := 0; iy < req.Height; iy++ { + j := i + 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 + } + i += img.Stride + } + + return img, nil +} + +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 + 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 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 zero, errors.New("cannot capture display") + } + return result.image, nil + case C.CaptureStatusBackendUnavailable: + return zero, backendUnavailableError(backend, "backend %q is unavailable on this macOS version", backend) + case C.CaptureStatusWindowExclusionUnsupported: + return zero, windowExclusionUnsupportedError(backend) + default: + return zero, errors.New("cannot capture display") + } +} + +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/screenshot/dxgi_helpers.go b/screenshot/dxgi_helpers.go new file mode 100644 index 0000000..88ae3bf --- /dev/null +++ b/screenshot/dxgi_helpers.go @@ -0,0 +1,218 @@ +package screenshot + +import ( + "errors" + "fmt" + "image" + "runtime" + "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 { + 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 { + return &dxgiDuplicationManager{ + entries: make(map[int]*dxgiDuplicationEntry), + newSession: factory, + } +} + +func (m *dxgiDuplicationManager) Capture(req cap.Request) (*image.RGBA, error) { + entry := m.entry(req.DisplayID) + resp := make(chan dxgiCaptureResponse, 1) + entry.worker.requests <- dxgiCaptureRequest{ + req: req, + resp: resp, + } + result := <-resp + return result.img, result.err +} + +func (m *dxgiDuplicationManager) entry(displayID int) *dxgiDuplicationEntry { + m.mu.Lock() + defer m.mu.Unlock() + + entry := m.entries[displayID] + if entry == nil { + entry = &dxgiDuplicationEntry{ + worker: newDXGIDuplicationWorker(displayID, m.newSession), + } + m.entries[displayID] = entry + } + return entry +} + +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 := factory(displayID) + if err != nil { + return nil, err + } + if session == nil { + return nil, errors.New("DXGI session factory returned nil 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..c52339f --- /dev/null +++ b/screenshot/dxgi_helpers_test.go @@ -0,0 +1,301 @@ +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) + } +} + +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() +} + +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 new file mode 100644 index 0000000..8039a13 --- /dev/null +++ b/screenshot/nix_dbus_available.go @@ -0,0 +1,28 @@ +//go:build !s390x && !ppc64le && !darwin && !windows && (linux || openbsd || netbsd) + +package screenshot + +import ( + cap "github.com/PekingSpades/DeskAct/capture" + "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(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(req.X, req.Y, req.Width, req.Height, req.Options.WaylandToken) + } else { + return captureXinerama(req.X, req.Y, req.Width, req.Height) + } +} diff --git a/screenshot/nix_wayland.go b/screenshot/nix_wayland.go new file mode 100644 index 0000000..8573e33 --- /dev/null +++ b/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/screenshot/nix_xwindow.go b/screenshot/nix_xwindow.go new file mode 100644 index 0000000..d52e422 --- /dev/null +++ b/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/screenshot/screenshot.go b/screenshot/screenshot.go new file mode 100644 index 0000000..43d6cf5 --- /dev/null +++ b/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/screenshot/unsupported.go b/screenshot/unsupported.go new file mode 100644 index 0000000..abb4621 --- /dev/null +++ b/screenshot/unsupported.go @@ -0,0 +1,21 @@ +//go:build s390x || ppc64le || (!(cgo && darwin) && !windows && !linux && !freebsd && !openbsd && !netbsd) + +package screenshot + +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(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 new file mode 100644 index 0000000..c4096fb --- /dev/null +++ b/screenshot/windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package screenshot + +import ( + "errors" + "image" + + cap "github.com/PekingSpades/DeskAct/capture" +) + +var windowsDXGIManager = newDXGIDuplicationManager(func(displayID int) (dxgiCaptureSession, error) { + if err := prepareDXGIThread(); err != nil { + return nil, err + } + return newDXGIDuplicationSession(displayID) +}) + +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") + } + + 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) + } +} diff --git a/screenshot/windows_dxgi.go b/screenshot/windows_dxgi.go new file mode 100644 index 0000000..17c4728 --- /dev/null +++ b/screenshot/windows_dxgi.go @@ -0,0 +1,678 @@ +//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 + // 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 + 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") + 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}} + 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 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 + 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_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) + } +} 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) +} 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/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..a216068 --- /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: int32(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) +}