Compare commits
7 Commits
main
...
codegen-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383e70cad2 | ||
|
|
dc28b29d24 | ||
|
|
8d92d80536 | ||
|
|
0c4e103d01 | ||
|
|
2b0da715f9 | ||
|
|
e521e4b7d9 | ||
|
|
eb538b8a98 |
147
.github/workflows/cross-platform-build.yml
vendored
Normal file
147
.github/workflows/cross-platform-build.yml
vendored
Normal file
@ -0,0 +1,147 @@
|
||||
name: Cross-Platform Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, 'codegen-bot/**' ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Build for macOS
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
- name: Test macOS build
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test
|
||||
|
||||
- name: Create macOS binary
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
mkdir -p ../artifacts/macos
|
||||
cp .build/release/peekaboo ../artifacts/macos/
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: peekaboo-macos
|
||||
path: artifacts/macos/peekaboo
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
with:
|
||||
swift-version: '6.0'
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev \
|
||||
libxcomposite-dev \
|
||||
libxrandr-dev \
|
||||
libxfixes-dev \
|
||||
imagemagick \
|
||||
wmctrl \
|
||||
grim \
|
||||
sway
|
||||
|
||||
- name: Build for Linux
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
- name: Test Linux build
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test
|
||||
|
||||
- name: Create Linux binary
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
mkdir -p ../artifacts/linux
|
||||
cp .build/release/peekaboo ../artifacts/linux/
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: peekaboo-linux
|
||||
path: artifacts/linux/peekaboo
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Swift
|
||||
uses: compnerd/gha-setup-swift@main
|
||||
with:
|
||||
branch: swift-6.0-release
|
||||
tag: 6.0-RELEASE
|
||||
|
||||
- name: Build for Windows
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
- name: Test Windows build
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test
|
||||
|
||||
- name: Create Windows binary
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
mkdir -p ../artifacts/windows
|
||||
cp .build/release/peekaboo.exe ../artifacts/windows/
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: peekaboo-windows
|
||||
path: artifacts/windows/peekaboo.exe
|
||||
|
||||
test-architecture:
|
||||
runs-on: macos-15
|
||||
needs: [build-macos, build-linux, build-windows]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Test platform factory
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test --filter PlatformFactoryTests
|
||||
|
||||
- name: Verify cross-platform compilation
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
# Test that all platform-specific code compiles
|
||||
swift build -c release
|
||||
echo "✅ Cross-platform architecture verified"
|
||||
105
.github/workflows/cross-platform-ci.yml
vendored
Normal file
105
.github/workflows/cross-platform-ci.yml
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
name: Cross-Platform CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, 'codegen-bot/**' ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test-macos:
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Run macOS tests
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test --parallel
|
||||
|
||||
- name: Test macOS-specific functionality
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift run peekaboo --help
|
||||
echo "✅ macOS CLI working"
|
||||
|
||||
test-linux:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
with:
|
||||
swift-version: '6.0'
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev \
|
||||
libxcomposite-dev \
|
||||
libxrandr-dev \
|
||||
libxfixes-dev \
|
||||
imagemagick \
|
||||
wmctrl
|
||||
|
||||
- name: Run Linux tests
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test --parallel
|
||||
|
||||
- name: Test Linux-specific functionality
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift run peekaboo --help
|
||||
echo "✅ Linux CLI working"
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Swift
|
||||
uses: compnerd/gha-setup-swift@main
|
||||
with:
|
||||
branch: swift-6.0-release
|
||||
tag: 6.0-RELEASE
|
||||
|
||||
- name: Run Windows tests
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift test --parallel
|
||||
|
||||
- name: Test Windows-specific functionality
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
swift run peekaboo --help
|
||||
echo "✅ Windows CLI working"
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [test-macos, test-linux, test-windows]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
with:
|
||||
swift-version: '6.0'
|
||||
|
||||
- name: Test cross-platform compatibility
|
||||
run: |
|
||||
cd peekaboo-cli
|
||||
# Verify that the platform factory works correctly
|
||||
swift test --filter PlatformFactoryTests
|
||||
echo "✅ Cross-platform integration verified"
|
||||
308
CROSS_PLATFORM_SETUP.md
Normal file
308
CROSS_PLATFORM_SETUP.md
Normal file
@ -0,0 +1,308 @@
|
||||
# Cross-Platform Setup Guide
|
||||
|
||||
Peekaboo now supports **macOS**, **Windows**, and **Linux**! This guide will help you set up and use Peekaboo on your platform.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### macOS
|
||||
```bash
|
||||
# Clone and build
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# Run
|
||||
.build/release/peekaboo --help
|
||||
```
|
||||
|
||||
### Linux
|
||||
```bash
|
||||
# Install dependencies (Ubuntu/Debian)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y swift-lang libx11-dev libxcomposite-dev libxrandr-dev libxfixes-dev imagemagick wmctrl
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# Run
|
||||
.build/release/peekaboo --help
|
||||
```
|
||||
|
||||
### Windows
|
||||
```powershell
|
||||
# Install Swift for Windows (see Swift.org for latest installer)
|
||||
# Clone and build
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# Run
|
||||
.build/release/peekaboo.exe --help
|
||||
```
|
||||
|
||||
## 📋 Platform Requirements
|
||||
|
||||
### macOS
|
||||
- **macOS 14.0+** (Sonoma or later)
|
||||
- **Xcode 15.0+** or Swift 6.0+
|
||||
- **Permissions**: Screen Recording and Accessibility (granted through System Preferences)
|
||||
|
||||
### Linux
|
||||
- **Swift 6.0+**
|
||||
- **X11 or Wayland** display server
|
||||
- **Dependencies**:
|
||||
- `libx11-dev`, `libxcomposite-dev`, `libxrandr-dev`, `libxfixes-dev` (for X11)
|
||||
- `imagemagick` or `scrot` (for screen capture)
|
||||
- `wmctrl` or `xwininfo` (for window management)
|
||||
- `grim` and `swaymsg` (for Wayland/Sway)
|
||||
|
||||
### Windows
|
||||
- **Windows 10+** (version 1903 or later)
|
||||
- **Swift 6.0 for Windows**
|
||||
- **Visual Studio Build Tools** (for compilation)
|
||||
|
||||
## 🔧 Installation Instructions
|
||||
|
||||
### macOS Installation
|
||||
|
||||
1. **Install Xcode or Swift**:
|
||||
```bash
|
||||
# Via Xcode (recommended)
|
||||
# Download from Mac App Store
|
||||
|
||||
# Or via Swift toolchain
|
||||
# Download from swift.org
|
||||
```
|
||||
|
||||
2. **Grant Permissions**:
|
||||
- Go to **System Preferences > Security & Privacy > Privacy**
|
||||
- Add Terminal (or your terminal app) to **Screen Recording** and **Accessibility**
|
||||
|
||||
3. **Build and Install**:
|
||||
```bash
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# Optional: Install globally
|
||||
sudo cp .build/release/peekaboo /usr/local/bin/
|
||||
```
|
||||
|
||||
### Linux Installation
|
||||
|
||||
#### Ubuntu/Debian
|
||||
```bash
|
||||
# Install Swift
|
||||
wget https://download.swift.org/swift-6.0-release/ubuntu2204/swift-6.0-RELEASE/swift-6.0-RELEASE-ubuntu22.04.tar.gz
|
||||
tar xzf swift-6.0-RELEASE-ubuntu22.04.tar.gz
|
||||
sudo mv swift-6.0-RELEASE-ubuntu22.04 /opt/swift
|
||||
echo 'export PATH=/opt/swift/usr/bin:$PATH' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev \
|
||||
libxcomposite-dev \
|
||||
libxrandr-dev \
|
||||
libxfixes-dev \
|
||||
imagemagick \
|
||||
wmctrl \
|
||||
grim \
|
||||
sway
|
||||
|
||||
# Build Peekaboo
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# Optional: Install globally
|
||||
sudo cp .build/release/peekaboo /usr/local/bin/
|
||||
```
|
||||
|
||||
#### Fedora/RHEL
|
||||
```bash
|
||||
# Install Swift (download from swift.org)
|
||||
# Install dependencies
|
||||
sudo dnf install -y \
|
||||
libX11-devel \
|
||||
libXcomposite-devel \
|
||||
libXrandr-devel \
|
||||
libXfixes-devel \
|
||||
ImageMagick \
|
||||
wmctrl
|
||||
|
||||
# Build as above
|
||||
```
|
||||
|
||||
#### Arch Linux
|
||||
```bash
|
||||
# Install from AUR or build Swift from source
|
||||
yay -S swift-bin
|
||||
|
||||
# Install dependencies
|
||||
sudo pacman -S \
|
||||
libx11 \
|
||||
libxcomposite \
|
||||
libxrandr \
|
||||
libxfixes \
|
||||
imagemagick \
|
||||
wmctrl
|
||||
|
||||
# Build as above
|
||||
```
|
||||
|
||||
### Windows Installation
|
||||
|
||||
1. **Install Swift for Windows**:
|
||||
- Download the latest Swift installer from [swift.org](https://swift.org/download/)
|
||||
- Follow the installation instructions
|
||||
- Ensure Visual Studio Build Tools are installed
|
||||
|
||||
2. **Install Git** (if not already installed):
|
||||
- Download from [git-scm.com](https://git-scm.com/)
|
||||
|
||||
3. **Build Peekaboo**:
|
||||
```powershell
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
|
||||
# The binary will be at .build/release/peekaboo.exe
|
||||
```
|
||||
|
||||
## 🎯 Usage Examples
|
||||
|
||||
### Basic Screen Capture
|
||||
```bash
|
||||
# Capture entire screen
|
||||
peekaboo image --mode screen
|
||||
|
||||
# Capture specific screen (multi-monitor)
|
||||
peekaboo image --mode screen --screen-index 1
|
||||
|
||||
# Capture with custom path
|
||||
peekaboo image --mode screen --path ~/Screenshots/
|
||||
```
|
||||
|
||||
### Window Capture
|
||||
```bash
|
||||
# List all applications
|
||||
peekaboo list apps
|
||||
|
||||
# List windows for specific app
|
||||
peekaboo list windows --app "Safari"
|
||||
|
||||
# Capture specific window
|
||||
peekaboo image --mode window --app "Safari" --window-index 0
|
||||
```
|
||||
|
||||
### Cross-Platform Compatibility
|
||||
```bash
|
||||
# These commands work identically on all platforms:
|
||||
peekaboo image --mode screen --format png
|
||||
peekaboo image --mode window --app "Firefox"
|
||||
peekaboo list apps --format json
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### macOS Issues
|
||||
|
||||
**Permission Denied**:
|
||||
- Ensure Screen Recording permission is granted
|
||||
- Restart terminal after granting permissions
|
||||
- Check System Preferences > Security & Privacy > Privacy
|
||||
|
||||
**ScreenCaptureKit Not Available**:
|
||||
- Update to macOS 12.3+ for best performance
|
||||
- Fallback to CGImage will be used automatically
|
||||
|
||||
### Linux Issues
|
||||
|
||||
**Command Not Found**:
|
||||
```bash
|
||||
# Install missing tools
|
||||
sudo apt-get install imagemagick wmctrl # Ubuntu/Debian
|
||||
sudo dnf install ImageMagick wmctrl # Fedora
|
||||
```
|
||||
|
||||
**X11 Display Issues**:
|
||||
```bash
|
||||
# Ensure DISPLAY is set
|
||||
echo $DISPLAY
|
||||
export DISPLAY=:0 # if not set
|
||||
```
|
||||
|
||||
**Wayland Limitations**:
|
||||
- Some features may be limited under Wayland
|
||||
- Install `grim` and `swaymsg` for Sway
|
||||
- GNOME Wayland may require additional setup
|
||||
|
||||
### Windows Issues
|
||||
|
||||
**Swift Not Found**:
|
||||
- Ensure Swift is in your PATH
|
||||
- Restart Command Prompt/PowerShell after installation
|
||||
|
||||
**Build Errors**:
|
||||
- Ensure Visual Studio Build Tools are installed
|
||||
- Try running from "Developer Command Prompt"
|
||||
|
||||
**Permission Issues**:
|
||||
- Run as Administrator if capturing elevated applications
|
||||
- Check Windows Defender settings
|
||||
|
||||
## 🔍 Platform-Specific Features
|
||||
|
||||
### macOS
|
||||
- ✅ ScreenCaptureKit (hardware accelerated)
|
||||
- ✅ CGImage fallback
|
||||
- ✅ Retina display support
|
||||
- ✅ Full window management
|
||||
- ✅ Application enumeration
|
||||
|
||||
### Linux
|
||||
- ✅ X11 support (XGetImage, wmctrl)
|
||||
- ✅ Wayland support (grim, swaymsg)
|
||||
- ✅ Multi-desktop environment support
|
||||
- ⚠️ Limited Wayland window management
|
||||
|
||||
### Windows
|
||||
- ✅ DXGI Desktop Duplication (planned)
|
||||
- ✅ GDI+ screen capture
|
||||
- ✅ Win32 window enumeration
|
||||
- ✅ Process management
|
||||
- ⚠️ UAC elevation may be required
|
||||
|
||||
## 🚧 Known Limitations
|
||||
|
||||
### General
|
||||
- Window capture on Wayland is limited by compositor support
|
||||
- Some desktop environments have additional security restrictions
|
||||
|
||||
### Platform-Specific
|
||||
- **macOS**: Requires explicit permission grants
|
||||
- **Linux**: Tool availability varies by distribution
|
||||
- **Windows**: May require elevation for some applications
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Want to improve cross-platform support? Check out:
|
||||
- `peekaboo-cli/Sources/peekaboo/Platforms/` - Platform implementations
|
||||
- `peekaboo-cli/Sources/peekaboo/Protocols/` - Cross-platform interfaces
|
||||
- `.github/workflows/` - CI/CD for all platforms
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Swift.org Downloads](https://swift.org/download/) - Swift toolchains for all platforms
|
||||
- [ImageMagick](https://imagemagick.org/) - Cross-platform image manipulation
|
||||
- [Wayland Documentation](https://wayland.freedesktop.org/) - Linux Wayland support
|
||||
- [Win32 API Reference](https://docs.microsoft.com/en-us/windows/win32/) - Windows development
|
||||
|
||||
---
|
||||
|
||||
**Happy screenshotting across all platforms! 🎉**
|
||||
|
||||
907
README.md
907
README.md
@ -1,726 +1,343 @@
|
||||
# Peekaboo MCP: Lightning-fast macOS Screenshots for AI Agents
|
||||
# 🌍 Peekaboo - Cross-Platform Screen Capture Utility
|
||||
|
||||

|
||||
> Now you see it, now it's saved. A cross-platform screen capture utility that works seamlessly on **macOS**, **Windows**, and **Linux**.
|
||||
|
||||
[](https://www.npmjs.com/package/@steipete/peekaboo-mcp)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://swift.org)
|
||||
[](https://github.com/steipete/Peekaboo)
|
||||
[](LICENSE)
|
||||
|
||||
Peekaboo is a macOS-only MCP server that enables AI agents to capture screenshots of applications, windows, or the entire system, with optional visual question answering through local or remote AI models.
|
||||
## ✨ Features
|
||||
|
||||
## What is Peekaboo?
|
||||
### 🎯 **Universal Screen Capture**
|
||||
- **Screen Capture**: Capture entire screens or specific displays
|
||||
- **Window Capture**: Capture specific application windows
|
||||
- **Multi-Window Capture**: Capture all windows from an application
|
||||
- **Frontmost Window**: Capture the currently active window
|
||||
|
||||
Peekaboo bridges the gap between AI assistants and visual content on your screen. Without visual capabilities, AI agents are fundamentally limited when debugging UI issues or understanding what's happening on screen. Peekaboo solves this by giving AI agents the ability to:
|
||||
### 🌍 **Cross-Platform Support**
|
||||
- **macOS**: ScreenCaptureKit with CGImage fallback, full feature support
|
||||
- **Windows**: DXGI Desktop Duplication API with GDI+ fallback
|
||||
- **Linux**: X11 and Wayland support via external tools
|
||||
|
||||
- **Capture screenshots** of your entire screen, specific applications, or individual windows
|
||||
- **Analyze visual content** using AI vision models (both local and cloud-based)
|
||||
- **List running applications** and their windows for targeted captures
|
||||
- **Work non-intrusively** without changing window focus or interrupting your workflow
|
||||
### 🔧 **Smart Platform Detection**
|
||||
- Automatic OS and architecture detection
|
||||
- Capability-based feature detection
|
||||
- Graceful fallbacks for unsupported features
|
||||
- Platform-specific optimizations
|
||||
|
||||
## Key Features
|
||||
### 📋 **Application & Window Management**
|
||||
- List running applications across all platforms
|
||||
- Enumerate windows for specific applications
|
||||
- Cross-platform window information and bounds
|
||||
- Platform-specific permission handling
|
||||
|
||||
- **🚀 Fast & Non-intrusive**: Uses Apple's ScreenCaptureKit for instant captures without focus changes
|
||||
- **🎯 Smart Window Targeting**: Fuzzy matching finds the right window even with partial names
|
||||
- **🤖 AI-Powered Analysis**: Ask questions about screenshots using GPT-4o, Claude, or local models
|
||||
- **🔒 Privacy-First**: Run entirely locally with Ollama, or use cloud providers when needed
|
||||
- **📦 Easy Installation**: One-click install via Cursor or simple npm/npx commands
|
||||
- **🛠️ Developer-Friendly**: Clean JSON API, TypeScript support, comprehensive logging
|
||||
## 🚀 Quick Start
|
||||
|
||||
Read more about the design philosophy and implementation details in the [blog post](https://steipete.com/posts/peekaboo-mcp-screenshots-so-fast-theyre-paranormal/).
|
||||
### Installation
|
||||
|
||||
## Installation
|
||||
#### macOS
|
||||
```bash
|
||||
# Using Homebrew (coming soon)
|
||||
brew install steipete/tap/peekaboo
|
||||
|
||||
### Requirements
|
||||
|
||||
- **macOS 14.0+** (Sonoma or later)
|
||||
- **Node.js 20.0+**
|
||||
- **Screen Recording Permission** (you'll be prompted on first use)
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### For Cursor IDE
|
||||
|
||||
<div align="center">
|
||||
<a href="cursor://anysphere.cursor-deeplink/mcp/install?name=peekaboo&config=ewogICJjb21tYW5kIjogIm5weCIsCiAgImFyZ3MiOiBbCiAgICAiLXkiLAogICAgIkBzdGVpcGV0ZS9wZWVrYWJvby1tY3AiCiAgXSwKICAiZW52IjogewogICAgIlBFRUtBQk9PX0FJX1BST1ZJREVSUyI6ICJvbGxhbWEvbGxhdmE6bGF0ZXN0IgogIH0KfQ==">
|
||||
<img src="https://cursor.com/deeplink/mcp-install-dark.png" alt="Install Peekaboo in Cursor IDE" height="40" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Or manually add to your Cursor settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"peekaboo": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@steipete/peekaboo-mcp"
|
||||
],
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/llava:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Or build from source
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
#### For Claude Desktop
|
||||
|
||||
Edit your Claude Desktop configuration file:
|
||||
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
Add the Peekaboo configuration and restart Claude Desktop.
|
||||
|
||||
### Configuration
|
||||
|
||||
Peekaboo can be configured using environment variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/llava:latest,openai/gpt-4o",
|
||||
"PEEKABOO_LOG_LEVEL": "debug",
|
||||
"PEEKABOO_LOG_FILE": "~/Library/Logs/peekaboo-mcp-debug.log",
|
||||
"PEEKABOO_DEFAULT_SAVE_PATH": "~/Pictures/PeekabooCaptures",
|
||||
"PEEKABOO_CONSOLE_LOGGING": "true",
|
||||
"PEEKABOO_CLI_TIMEOUT": "30000",
|
||||
"PEEKABOO_CLI_PATH": "/opt/custom/peekaboo"
|
||||
}
|
||||
#### Windows
|
||||
```bash
|
||||
# Prerequisites: Swift 6.0, Visual Studio Build Tools
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
#### Available Environment Variables
|
||||
#### Linux
|
||||
```bash
|
||||
# Prerequisites: Swift 6.0, X11/Wayland development libraries
|
||||
sudo apt-get install libx11-dev libxcomposite-dev libxrandr-dev libxfixes-dev
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PEEKABOO_AI_PROVIDERS` | JSON string defining AI providers for image analysis (see [AI Analysis](#ai-analysis)). | `""` (disabled) |
|
||||
| `PEEKABOO_LOG_LEVEL` | Logging level (trace, debug, info, warn, error, fatal). | `info` |
|
||||
| `PEEKABOO_LOG_FILE` | Path to the server's log file. If the specified directory is not writable, falls back to the system temp directory. | `~/Library/Logs/peekaboo-mcp.log` |
|
||||
| `PEEKABOO_DEFAULT_SAVE_PATH` | Default directory for saving captured images when no path is specified. | System temp directory |
|
||||
| `PEEKABOO_OLLAMA_BASE_URL` | Base URL for the Ollama API server. Only needed if Ollama is running on a non-default address. | `http://localhost:11434` |
|
||||
| `PEEKABOO_CONSOLE_LOGGING` | Boolean (`"true"`/`"false"`) for development console logs. | `"false"` |
|
||||
| `PEEKABOO_CLI_TIMEOUT` | Timeout in milliseconds for Swift CLI operations. Prevents hanging processes. | `30000` (30 seconds) |
|
||||
| `PEEKABOO_CLI_PATH` | Optional override for the Swift `peekaboo` CLI executable path. | (uses bundled CLI) |
|
||||
|
||||
#### AI Provider Configuration
|
||||
|
||||
The `PEEKABOO_AI_PROVIDERS` environment variable is your gateway to unlocking Peekaboo\'s analytical abilities for both the dedicated `analyze` tool and the `image` tool (when a `question` is supplied with an image capture). It should be a JSON string defining the AI providers and their default models. For example:
|
||||
|
||||
`PEEKABOO_AI_PROVIDERS="ollama/llava:latest,openai/gpt-4o,anthropic/claude-3-haiku-20240307"`
|
||||
|
||||
Each entry follows the format `provider_name/model_identifier`.
|
||||
|
||||
- **`provider_name`**: Currently supported values are `ollama` (for local Ollama instances) and `openai`. Support for `anthropic` is planned.
|
||||
- **`model_identifier`**: The specific model to use for that provider (e.g., `llava:latest`, `gpt-4o`).
|
||||
|
||||
The `analyze` tool and the `image` tool (when a `question` is provided) will use these configurations. If the `provider_config` argument in these tools is set to `\"auto\"` (the default for `analyze`, and an option for `image`), Peekaboo will try providers from `PEEKABOO_AI_PROVIDERS` in the order they are listed, checking for necessary API keys (like `OPENAI_API_KEY`) or service availability (like Ollama running at `http://localhost:11434` or the URL specified in `PEEKABOO_OLLAMA_BASE_URL`).
|
||||
|
||||
You can override the model or pick a specific provider listed in `PEEKABOO_AI_PROVIDERS` using the `provider_config` argument in the `analyze` or `image` tools. (The system will still verify its operational readiness, e.g., API key presence or service availability.)
|
||||
|
||||
### Setting Up Local AI with Ollama
|
||||
|
||||
Ollama provides powerful local AI models that can analyze your screenshots without sending data to the cloud.
|
||||
|
||||
#### Installing Ollama
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Install via Homebrew
|
||||
brew install ollama
|
||||
# Capture primary screen
|
||||
peekaboo image
|
||||
|
||||
# Or download from https://ollama.ai
|
||||
# Capture specific application window
|
||||
peekaboo image --app "Safari" --mode window
|
||||
|
||||
# Start the Ollama service
|
||||
ollama serve
|
||||
# Capture all windows from an application
|
||||
peekaboo image --app "Safari" --mode multi
|
||||
|
||||
# List running applications
|
||||
peekaboo list apps
|
||||
|
||||
# List windows for a specific app
|
||||
peekaboo list windows --app "Safari"
|
||||
|
||||
# Check platform capabilities
|
||||
peekaboo list server-status
|
||||
```
|
||||
|
||||
#### Downloading Vision Models
|
||||
## 📖 Detailed Usage
|
||||
|
||||
**For powerful machines**, LLaVA (Large Language and Vision Assistant) is the recommended model:
|
||||
### Screen Capture
|
||||
|
||||
```bash
|
||||
# Download the latest LLaVA model (recommended for best quality)
|
||||
ollama pull llava:latest
|
||||
# Capture primary screen
|
||||
peekaboo image --mode screen
|
||||
|
||||
# Alternative LLaVA versions
|
||||
ollama pull llava:7b-v1.6
|
||||
ollama pull llava:13b-v1.6 # Larger, more capable
|
||||
ollama pull llava:34b-v1.6 # Largest, most powerful (requires significant RAM)
|
||||
# Capture specific screen by index
|
||||
peekaboo image --mode screen --screen-index 1
|
||||
|
||||
# Capture to specific path
|
||||
peekaboo image --mode screen --path ~/Screenshots/
|
||||
|
||||
# Capture in JPEG format
|
||||
peekaboo image --mode screen --format jpg
|
||||
```
|
||||
|
||||
**For less beefy machines**, Qwen2-VL provides excellent performance with lower resource requirements:
|
||||
### Window Capture
|
||||
|
||||
```bash
|
||||
# Download Qwen2-VL 7B model (great balance of quality and performance)
|
||||
ollama pull qwen2-vl:7b
|
||||
# Capture frontmost window of an app
|
||||
peekaboo image --app "Safari" --mode window
|
||||
|
||||
# Capture specific window by title
|
||||
peekaboo image --app "Safari" --window-title "GitHub"
|
||||
|
||||
# Capture specific window by index
|
||||
peekaboo image --app "Safari" --window-index 0
|
||||
|
||||
# Capture all windows from an app
|
||||
peekaboo image --app "Safari" --mode multi
|
||||
```
|
||||
|
||||
**Model Size Guide:**
|
||||
- `qwen2-vl:7b` - ~4GB download, ~6GB RAM required (excellent for lighter machines)
|
||||
- `llava:7b` - ~4.5GB download, ~8GB RAM required
|
||||
- `llava:13b` - ~8GB download, ~16GB RAM required
|
||||
- `llava:34b` - ~20GB download, ~40GB RAM required
|
||||
|
||||
#### Configuring Peekaboo with Ollama
|
||||
|
||||
Add Ollama to your Claude Desktop configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"peekaboo": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@steipete/peekaboo-mcp@beta"
|
||||
],
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/llava:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For less powerful machines (using Qwen2-VL):**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"peekaboo": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@steipete/peekaboo-mcp@beta"
|
||||
],
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/qwen2-vl:7b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple AI Providers (Ollama + OpenAI):**
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/llava:latest,openai/gpt-4o",
|
||||
"OPENAI_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### macOS Permissions
|
||||
|
||||
Peekaboo requires specific macOS permissions to function:
|
||||
|
||||
#### 1. Screen Recording Permission
|
||||
1. Open **System Preferences** → **Security & Privacy** → **Privacy**
|
||||
2. Select **Screen Recording** from the left sidebar
|
||||
3. Click the **lock icon** and enter your password
|
||||
4. Click **+** and add your terminal application or MCP client
|
||||
5. Restart the application
|
||||
|
||||
**Applications that need permission:**
|
||||
- Terminal.app: `/Applications/Utilities/Terminal.app`
|
||||
- Claude Desktop: `/Applications/Claude.app`
|
||||
- VS Code: `/Applications/Visual Studio Code.app`
|
||||
- Cursor: `/Applications/Cursor.app`
|
||||
|
||||
#### 2. Accessibility Permission (Optional)
|
||||
|
||||
To whisper commands to windows and make them dance:
|
||||
1. Open **System Preferences** → **Security & Privacy** → **Privacy**
|
||||
2. Select **Accessibility** from the left sidebar
|
||||
3. Add your terminal/MCP client application
|
||||
|
||||
### Testing & Debugging
|
||||
|
||||
#### Using MCP Inspector
|
||||
|
||||
The easiest way to test Peekaboo is with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector):
|
||||
### Application & Window Listing
|
||||
|
||||
```bash
|
||||
# Test with local Ollama
|
||||
PEEKABOO_AI_PROVIDERS="ollama/llava:latest" npx @modelcontextprotocol/inspector npx -y @steipete/peekaboo-mcp
|
||||
# List all running applications
|
||||
peekaboo list apps
|
||||
|
||||
# Test with OpenAI
|
||||
OPENAI_API_KEY="your-key" PEEKABOO_AI_PROVIDERS="openai/gpt-4o" npx @modelcontextprotocol/inspector npx -y @steipete/peekaboo-mcp
|
||||
# List with JSON output
|
||||
peekaboo list apps --json-output
|
||||
|
||||
# List windows for specific app
|
||||
peekaboo list windows --app "Safari"
|
||||
|
||||
# Include window bounds and details
|
||||
peekaboo list windows --app "Safari" --details bounds --details ids
|
||||
```
|
||||
|
||||
This launches an interactive web interface where you can test all of Peekaboo's tools and see their responses in real-time.
|
||||
|
||||
#### Direct CLI Testing
|
||||
### Platform Status
|
||||
|
||||
```bash
|
||||
# Commune with the Swift spirit directly
|
||||
./peekaboo --help
|
||||
# Check platform capabilities and permissions
|
||||
peekaboo list server-status
|
||||
|
||||
# Check the spectral server's pulse
|
||||
./peekaboo list server_status --json-output
|
||||
|
||||
# Capture a soul (requires permission wards)
|
||||
./peekaboo image --mode screen --format png
|
||||
|
||||
# Open the portal for testing
|
||||
peekaboo-mcp
|
||||
# JSON output for scripting
|
||||
peekaboo list server-status --json-output
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"swift_cli_available": true,
|
||||
"permissions": {
|
||||
"screen_recording": true
|
||||
},
|
||||
"system_info": {
|
||||
"macos_version": "14.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Protocol-Based Design
|
||||
|
||||
Peekaboo uses a protocol-based architecture for maximum flexibility and testability:
|
||||
|
||||
```swift
|
||||
// Core protocols
|
||||
protocol ScreenCaptureProtocol: Sendable { ... }
|
||||
protocol WindowManagerProtocol: Sendable { ... }
|
||||
protocol ApplicationFinderProtocol: Sendable { ... }
|
||||
protocol PermissionsProtocol: Sendable { ... }
|
||||
|
||||
// Platform factory
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
### Platform-Specific Implementations
|
||||
|
||||
Peekaboo provides three main tools for AI agents:
|
||||
#### macOS
|
||||
- **Screen Capture**: ScreenCaptureKit with CGImage fallback
|
||||
- **Window Management**: AppKit and Core Graphics APIs
|
||||
- **Application Finding**: NSWorkspace integration
|
||||
- **Permissions**: System-level permission dialogs
|
||||
|
||||
### 1. `image` - Capture Screenshots
|
||||
#### Windows
|
||||
- **Screen Capture**: DXGI Desktop Duplication API with GDI+ fallback
|
||||
- **Window Management**: Win32 API window enumeration
|
||||
- **Application Finding**: Process and module management
|
||||
- **Permissions**: UAC-aware permission handling
|
||||
|
||||
Captures macOS screen content with automatic shadow/frame removal.
|
||||
#### Linux
|
||||
- **Screen Capture**: X11 (XGetImage) and Wayland (grim) support
|
||||
- **Window Management**: wmctrl and swaymsg integration
|
||||
- **Application Finding**: /proc filesystem based process management
|
||||
- **Permissions**: Display server specific permission handling
|
||||
|
||||
**Important:** Screen captures **cannot use `format: "data"`** due to the large size of screen images causing JavaScript stack overflow errors. Screen captures always save to files, either to a specified path or a temporary directory. When `format: "data"` is requested for screen captures, the tool automatically falls back to PNG format and saves to a file with a warning message explaining the fallback.
|
||||
## 🔐 Permissions
|
||||
|
||||
**Examples:**
|
||||
```javascript
|
||||
// Capture entire screen (must save to file)
|
||||
await use_mcp_tool("peekaboo", "image", {
|
||||
app_target: "screen:0",
|
||||
path: "~/Desktop/screenshot.png"
|
||||
});
|
||||
### macOS
|
||||
- **Screen Recording**: Required for screen and window capture
|
||||
- **Accessibility**: Required for window enumeration and focus control
|
||||
|
||||
// Capture specific app window with analysis (can use format: "data")
|
||||
await use_mcp_tool("peekaboo", "image", {
|
||||
app_target: "Safari",
|
||||
question: "What website is currently open?",
|
||||
format: "data"
|
||||
});
|
||||
Grant permissions in **System Settings > Privacy & Security**
|
||||
|
||||
// Capture window by title
|
||||
await use_mcp_tool("peekaboo", "image", {
|
||||
app_target: "Notes:WINDOW_TITLE:Meeting Notes",
|
||||
path: "~/Desktop/notes.png"
|
||||
});
|
||||
### Windows
|
||||
- **UAC**: May require administrator privileges for some operations
|
||||
- **Windows Security**: Screen capture permissions handled automatically
|
||||
|
||||
// Capture frontmost window of currently active application
|
||||
await use_mcp_tool("peekaboo", "image", {
|
||||
app_target: "frontmost",
|
||||
format: "png"
|
||||
});
|
||||
```
|
||||
### Linux
|
||||
- **X11**: Requires access to X11 display server
|
||||
- **Wayland**: May require additional portal permissions
|
||||
- **File System**: Read access to /proc for process information
|
||||
|
||||
#### Browser Helper Filtering
|
||||
## 🛠️ Development
|
||||
|
||||
Peekaboo automatically filters out browser helper processes when searching for common browsers (Chrome, Safari, Firefox, Edge, Brave, Arc, Opera). This prevents confusing errors when helper processes like "Google Chrome Helper (Renderer)" are matched instead of the main browser application.
|
||||
|
||||
**Examples:**
|
||||
```javascript
|
||||
// ✅ Finds main Chrome browser, not helpers
|
||||
await use_mcp_tool("peekaboo", "image", {
|
||||
app_target: "Chrome"
|
||||
});
|
||||
|
||||
// ❌ Old behavior: Could match "Google Chrome Helper (Renderer)"
|
||||
// Result: "no capturable windows were found"
|
||||
// ✅ New behavior: Finds "Google Chrome" or shows "Chrome browser is not running"
|
||||
```
|
||||
|
||||
**Browser-Specific Error Messages:**
|
||||
- Instead of generic "Application not found"
|
||||
- Shows clear messages like "Chrome browser is not running or not found"
|
||||
- Only applies to browser identifiers - other apps work normally
|
||||
|
||||
### 2. `list` - System Information
|
||||
|
||||
Lists running applications, windows, or server status.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// List all running applications
|
||||
await use_mcp_tool("peekaboo", "list", {
|
||||
item_type: "running_applications"
|
||||
});
|
||||
|
||||
// List windows of specific app
|
||||
await use_mcp_tool("peekaboo", "list", {
|
||||
item_type: "application_windows",
|
||||
app: "Preview"
|
||||
});
|
||||
|
||||
// Check server status
|
||||
await use_mcp_tool("peekaboo", "list", {
|
||||
item_type: "server_status"
|
||||
});
|
||||
```
|
||||
|
||||
### 3. `analyze` - AI Vision Analysis
|
||||
|
||||
Analyzes existing images using configured AI models.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// Analyze with auto-selected provider
|
||||
await use_mcp_tool("peekaboo", "analyze", {
|
||||
image_path: "~/Desktop/screenshot.png",
|
||||
question: "What applications are visible?"
|
||||
});
|
||||
|
||||
// Force specific provider
|
||||
await use_mcp_tool("peekaboo", "analyze", {
|
||||
image_path: "~/Desktop/diagram.jpg",
|
||||
question: "Explain this diagram",
|
||||
provider_config: {
|
||||
type: "ollama",
|
||||
model: "llava:13b"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Peekaboo includes comprehensive test suites for both TypeScript and Swift components:
|
||||
|
||||
### TypeScript Tests
|
||||
|
||||
- **Unit Tests**: Test individual functions and modules in isolation
|
||||
- **Integration Tests**: Test tool handlers with mocked Swift CLI
|
||||
- **Platform-Specific Tests**: Some integration tests require macOS and Swift binary
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Run all tests (requires macOS and Swift binary for integration tests)
|
||||
npm test
|
||||
|
||||
# Run only unit tests (works on any platform)
|
||||
npm run test:unit
|
||||
|
||||
# Run TypeScript-only tests (skips Swift-dependent tests, works on Linux)
|
||||
npm run test:typescript
|
||||
|
||||
# Watch mode for TypeScript-only tests
|
||||
npm run test:typescript:watch
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
git clone https://github.com/steipete/Peekaboo.git
|
||||
cd Peekaboo/peekaboo-cli
|
||||
swift build
|
||||
```
|
||||
|
||||
### Swift Tests
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run Swift CLI tests (macOS only)
|
||||
npm run test:swift
|
||||
|
||||
# Run full integration tests (TypeScript + Swift)
|
||||
npm run test:integration
|
||||
swift test
|
||||
```
|
||||
|
||||
### Platform Support
|
||||
### Platform-Specific Development
|
||||
|
||||
- **macOS**: All tests run (unit, integration, Swift)
|
||||
- **Linux/CI**: Only TypeScript tests run (Swift-dependent tests are automatically skipped)
|
||||
- **Environment Variables**:
|
||||
- `SKIP_SWIFT_TESTS=true`: Force skip Swift-dependent tests
|
||||
- `CI=true`: Automatically skips Swift-dependent tests
|
||||
#### macOS Development
|
||||
```bash
|
||||
# Requires Xcode 15.0+ or Swift 6.0+
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
#### Windows Development
|
||||
```bash
|
||||
# Requires Swift 6.0 and Visual Studio Build Tools
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
#### Linux Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt-get install libx11-dev libxcomposite-dev libxrandr-dev libxfixes-dev
|
||||
|
||||
| Haunting | Exorcism |
|
||||
|-------|----------|
|
||||
| `Permission denied` errors during image capture | Grant **Screen Recording** permission in System Settings → Privacy & Security. Ensure the correct application (Terminal, Claude, VS Code, etc.) is added and checked. Restart the app after changing permissions. |
|
||||
| Window capture issues (wrong window, focus problems) | Grant **Accessibility** permission if using `capture_focus: "foreground"` or for more reliable window targeting. |
|
||||
| `Swift CLI unavailable` or `PEEKABOO_CLI_PATH` issues | Ensure the `peekaboo` binary is at the root of the NPM package, or if `PEEKABOO_CLI_PATH` is set, verify it points to a valid executable. You can test the Swift CLI directly: `path/to/peekaboo --version`. If missing or broken, rebuild: `cd peekaboo-cli && swift build -c release` (then place binary appropriately or update `PEEKABOO_CLI_PATH`). |
|
||||
| `AI analysis failed` | Check your `PEEKABOO_AI_PROVIDERS` environment variable for correct format and valid provider/model pairs. Ensure API keys (e.g., `OPENAI_API_KEY`) are set if using cloud providers. Verify local services like Ollama are running (`PEEKABOO_OLLAMA_BASE_URL`). Check the server logs (`PEEKABOO_LOG_FILE` or console if `PEEKABOO_CONSOLE_LOGGING="true"`) for detailed error messages from the AI provider. |
|
||||
| `Command not found: peekaboo-mcp` | If installed globally, ensure your system's PATH includes the global npm binaries directory. If running from a local clone, use `node dist/index.js` or a configured npm script. For `npx`, ensure the package name `@steipete/peekaboo-mcp` is correct. |
|
||||
| General weirdness or unexpected behavior | Check the Peekaboo MCP server logs! The default location is `/tmp/peekaboo-mcp.log` (or what you set in `PEEKABOO_LOG_FILE`). Set `PEEKABOO_LOG_LEVEL=debug` for maximum detail. |
|
||||
# Build
|
||||
swift build -c release
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
### Adding New Platforms
|
||||
|
||||
1. Create platform-specific implementations in `Sources/peekaboo/Platforms/YourPlatform/`
|
||||
2. Implement the required protocols
|
||||
3. Update `PlatformFactory.swift` to include your platform
|
||||
4. Add platform-specific CI workflows
|
||||
5. Update documentation
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Automated Testing
|
||||
|
||||
The project includes comprehensive CI workflows for all supported platforms:
|
||||
|
||||
- **macOS**: Latest macOS with Xcode
|
||||
- **Windows**: Windows Server with Swift toolchain
|
||||
- **Linux**: Ubuntu with Swift and required libraries
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
PEEKABOO_LOG_LEVEL=debug PEEKABOO_CONSOLE_LOGGING=true npx @steipete/peekaboo-mcp
|
||||
# Test platform detection
|
||||
peekaboo list server-status
|
||||
|
||||
# Check permissions
|
||||
./peekaboo list server_status --json-output
|
||||
# Test screen capture
|
||||
peekaboo image --mode screen
|
||||
|
||||
# Test application listing
|
||||
peekaboo list apps
|
||||
|
||||
# Test window capture
|
||||
peekaboo image --app "YourApp" --mode window
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
## 📚 API Reference
|
||||
|
||||
- 📚 [Documentation](./docs/)
|
||||
- 🐛 [Report Issues](https://github.com/steipete/peekaboo/issues)
|
||||
- 💬 [Discussions](https://github.com/steipete/peekaboo/discussions)
|
||||
- 📖 [Blog Post](https://steipete.com/posts/peekaboo-mcp-screenshots-so-fast-theyre-paranormal/)
|
||||
### Command Line Interface
|
||||
|
||||
## Building from Source
|
||||
#### Global Options
|
||||
- `--json-output`: Output results in JSON format
|
||||
|
||||
#### Image Command
|
||||
- `--app <identifier>`: Target application identifier
|
||||
- `--mode <mode>`: Capture mode (screen, window, multi, frontmost)
|
||||
- `--path <path>`: Output directory path
|
||||
- `--format <format>`: Image format (png, jpg)
|
||||
- `--window-title <title>`: Specific window title to capture
|
||||
- `--window-index <index>`: Window index to capture
|
||||
- `--screen-index <index>`: Screen index to capture
|
||||
|
||||
#### List Command
|
||||
- `apps`: List running applications
|
||||
- `windows --app <identifier>`: List windows for an application
|
||||
- `server-status`: Show platform capabilities and status
|
||||
|
||||
### Platform Capabilities
|
||||
|
||||
| Feature | macOS | Windows | Linux |
|
||||
|---------|-------|---------|-------|
|
||||
| Screen Capture | ✅ | ✅ | ✅ |
|
||||
| Window Capture | ✅ | ✅ | ✅ |
|
||||
| Application Listing | ✅ | ✅ | ✅ |
|
||||
| Window Management | ✅ | ✅ | ✅ |
|
||||
| Permission Handling | ✅ | ✅ | ✅ |
|
||||
| Focus Control | ✅ | ⚠️ | ⚠️ |
|
||||
|
||||
✅ Full Support | ⚠️ Limited Support | ❌ Not Supported
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/steipete/peekaboo.git
|
||||
cd peekaboo
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Build Swift CLI
|
||||
cd peekaboo-cli
|
||||
swift build -c release
|
||||
cp .build/release/peekaboo ../peekaboo
|
||||
cd ..
|
||||
|
||||
# Optional: Install globally
|
||||
npm link
|
||||
```
|
||||
|
||||
### Local Development Configuration
|
||||
|
||||
For development, you can run Peekaboo locally:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"peekaboo_local": {
|
||||
"command": "peekaboo-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"PEEKABOO_LOG_LEVEL": "debug",
|
||||
"PEEKABOO_CONSOLE_LOGGING": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, running directly with `node`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"peekaboo_local_node": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/Users/steipete/Projects/Peekaboo/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"PEEKABOO_LOG_LEVEL": "debug",
|
||||
"PEEKABOO_CONSOLE_LOGGING": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Remember to use absolute paths and unique server names to avoid conflicts with the npm version.
|
||||
|
||||
### Using the AppleScript Version
|
||||
|
||||
For simple screenshot capture without MCP integration:
|
||||
|
||||
```bash
|
||||
osascript peekaboo.scpt
|
||||
```
|
||||
|
||||
Note: This legacy version doesn't include AI analysis or MCP features.
|
||||
|
||||
### Manual Configuration for Other MCP Clients
|
||||
|
||||
For MCP clients other than Claude Desktop:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/peekaboo/dist/index.js"],
|
||||
"env": {
|
||||
"PEEKABOO_AI_PROVIDERS": "ollama/llava,openai/gpt-4o"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Documentation
|
||||
|
||||
### `image` - Screenshot Capture
|
||||
|
||||
Captures macOS screen content and optionally analyzes it. Window shadows/frames are automatically excluded.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
* `app_target` (string, optional): Specifies the capture target. If omitted or empty, captures all screens.
|
||||
* Examples:
|
||||
* `"screen:INDEX"`: Captures the screen at the specified zero-based index (e.g., `"screen:0"`). (Note: Index selection from multiple screens is planned for full support in the Swift CLI).
|
||||
* `"frontmost"`: Aims to capture all windows of the current foreground application. (Note: This is a complex scenario; current implementation may default to screen capture if the exact foreground app cannot be reliably determined by the Node.js layer alone).
|
||||
* `"AppName"`: Captures all windows of the application named `AppName` (e.g., `"Safari"`, `"com.apple.Safari"`). Fuzzy matching is used.
|
||||
* `"AppName:WINDOW_TITLE:Title"`: Captures the window of `AppName` that has the specified `Title` (e.g., `"Notes:WINDOW_TITLE:My Important Note"`).
|
||||
* `"AppName:WINDOW_INDEX:Index"`: Captures the window of `AppName` at the specified zero-based `Index` (e.g., `"Preview:WINDOW_INDEX:0"` for the frontmost window of Preview).
|
||||
* `path` (string, optional): Base absolute path for saving the captured image(s). If `format` is `"data"` and `path` is also provided, the image is saved to this path (as a PNG) AND Base64 data is returned. If a `question` is provided and `path` is omitted, a temporary path is used for capture, and the file is deleted after analysis.
|
||||
* `question` (string, optional): If provided, the captured image will be analyzed. The server automatically selects an AI provider from those configured in the `PEEKABOO_AI_PROVIDERS` environment variable.
|
||||
* `format` (string, optional, default: `"png"`): Specifies the output image format or data return type.
|
||||
* `"png"` or `"jpg"`: Saves the image to the specified `path` in the chosen format. For application captures: if `path` is not provided, behaves like `"data"`. For screen captures: always saves to file.
|
||||
* `"data"`: Returns Base64 encoded PNG data of the image directly in the MCP response. If `path` is also specified, a PNG file is also saved to that `path`. **Note: Screen captures cannot use this format and will automatically fall back to PNG file format.**
|
||||
* Invalid values (empty strings, null, or unrecognized formats) automatically fall back to `"png"`.
|
||||
* `capture_focus` (string, optional, default: `"background"`): Controls window focus behavior during capture.
|
||||
* `"background"`: Captures without altering the current window focus (default).
|
||||
* `"foreground"`: Attempts to bring the target application/window to the foreground before capture. This might be necessary for certain applications or to ensure a specific window is captured if multiple are open.
|
||||
|
||||
**Behavior with `question` (AI Analysis):**
|
||||
|
||||
* If a `question` is provided, the tool will capture the image (saving it to `path` if specified, or a temporary path otherwise).
|
||||
* This image is then sent to an AI model for analysis. The AI provider and model are chosen automatically by the server based on your `PEEKABOO_AI_PROVIDERS` environment variable (trying them in order until one succeeds).
|
||||
* The analysis result is returned as `analysis_text` in the response. Image data (Base64) is NOT returned in the `content` array when a question is asked.
|
||||
* If a temporary path was used for the image, it's deleted after the analysis attempt.
|
||||
|
||||
**Output Structure (Simplified):**
|
||||
|
||||
* `content`: Can contain `ImageContentItem` (if `format: "data"` or `path` was omitted, and no `question`) and/or `TextContentItem` (for summaries, analysis text, warnings).
|
||||
* `saved_files`: Array of objects, each detailing a file saved to `path` (if `path` was provided).
|
||||
* `analysis_text`: Text from AI (if `question` was asked).
|
||||
* `model_used`: AI model identifier (if `question` was asked).
|
||||
|
||||
For detailed parameter documentation, see [docs/spec.md](./docs/spec.md).
|
||||
|
||||
## Technical Features
|
||||
|
||||
### Screenshot Capabilities
|
||||
- **Multi-display support**: Captures each display separately
|
||||
- **Smart app targeting**: Fuzzy matching for application names
|
||||
- **Multiple formats**: PNG, JPEG, WebP, HEIF support
|
||||
- **Automatic naming**: Timestamp-based file naming
|
||||
- **Permission checking**: Automatic verification of required permissions
|
||||
|
||||
### Window Management
|
||||
- **Application listing**: Complete list of running applications
|
||||
- **Window enumeration**: List all windows for specific apps
|
||||
- **Flexible matching**: Find apps by partial name, bundle ID, or PID
|
||||
- **Status monitoring**: Active/inactive status, window counts
|
||||
|
||||
### AI Integration
|
||||
- **Provider agnostic**: Supports Ollama and OpenAI (Anthropic coming soon)
|
||||
- **Natural language**: Ask questions about captured images
|
||||
- **Configurable**: Environment-based provider selection
|
||||
- **Fallback support**: Automatic failover between providers
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Peekaboo/
|
||||
├── src/ # Node.js MCP Server (TypeScript)
|
||||
│ ├── index.ts # Main MCP server entry point
|
||||
│ ├── tools/ # Individual tool implementations
|
||||
│ │ ├── image.ts # Screen capture tool
|
||||
│ │ ├── analyze.ts # AI analysis tool
|
||||
│ │ └── list.ts # Application/window listing
|
||||
│ ├── utils/ # Utility modules
|
||||
│ │ ├── peekaboo-cli.ts # Swift CLI integration
|
||||
│ │ ├── ai-providers.ts # AI provider management
|
||||
│ │ └── server-status.ts # Server status utilities
|
||||
│ └── types/ # Shared type definitions
|
||||
├── peekaboo-cli/ # Native Swift CLI
|
||||
│ └── Sources/peekaboo/ # Swift source files
|
||||
│ ├── main.swift # CLI entry point
|
||||
│ ├── ImageCommand.swift # Image capture implementation
|
||||
│ ├── ListCommand.swift # Application listing
|
||||
│ ├── Models.swift # Data structures
|
||||
│ ├── ApplicationFinder.swift # App discovery logic
|
||||
│ ├── WindowManager.swift # Window management
|
||||
│ ├── PermissionsChecker.swift # macOS permissions
|
||||
│ └── JSONOutput.swift # JSON response formatting
|
||||
├── package.json # Node.js dependencies
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### JSON Output Format
|
||||
The Swift CLI outputs structured JSON when called with `--json-output`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"applications": [
|
||||
{
|
||||
"app_name": "Safari",
|
||||
"bundle_id": "com.apple.Safari",
|
||||
"pid": 1234,
|
||||
"is_active": true,
|
||||
"window_count": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"debug_logs": ["Found 50 applications"]
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Integration
|
||||
The Node.js server provides:
|
||||
- Schema validation via Zod
|
||||
- Proper MCP error codes
|
||||
- Structured logging via Pino
|
||||
- Full TypeScript type safety
|
||||
|
||||
### Security
|
||||
Peekaboo respects macOS security:
|
||||
- Checks permissions before operations
|
||||
- Graceful handling of missing permissions
|
||||
- Clear guidance for permission setup
|
||||
|
||||
## Development
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Test Swift CLI directly
|
||||
./peekaboo list apps --json-output | head -20
|
||||
|
||||
# Test MCP server
|
||||
echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | node dist/index.js
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Build Swift CLI
|
||||
cd peekaboo-cli && swift build
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- **FileHandle warning**: Non-critical Swift warning about TextOutputStream conformance
|
||||
- **AI Provider Config**: Requires `PEEKABOO_AI_PROVIDERS` environment variable for analysis features
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Ensure all tests pass on all platforms
|
||||
6. Submit a pull request
|
||||
|
||||
## Author
|
||||
## 📄 License
|
||||
|
||||
Created by [Peter Steinberger](https://steipete.com) - [@steipete](https://github.com/steipete)
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **ScreenCaptureKit**: Apple's modern screen capture framework
|
||||
- **Swift Argument Parser**: Command-line argument parsing
|
||||
- **Cross-Platform Swift**: The Swift community's cross-platform efforts
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [GitHub Repository](https://github.com/steipete/Peekaboo)
|
||||
- [Issue Tracker](https://github.com/steipete/Peekaboo/issues)
|
||||
- [Releases](https://github.com/steipete/Peekaboo/releases)
|
||||
- [Swift.org](https://swift.org)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by [Peter Steinberger](https://github.com/steipete) and the open source community.**
|
||||
|
||||
Read more about Peekaboo's design and implementation in the [blog post](https://steipete.com/posts/peekaboo-mcp-screenshots-so-fast-theyre-paranormal/).
|
||||
|
||||
@ -4,7 +4,11 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "peekaboo",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
.macOS(.v14),
|
||||
.iOS(.v13), // For potential future iOS support
|
||||
.watchOS(.v6), // For potential future watchOS support
|
||||
.tvOS(.v13) // For potential future tvOS support
|
||||
// Note: Windows and Linux support is handled through conditional compilation
|
||||
],
|
||||
products: [
|
||||
.executable(
|
||||
@ -13,25 +17,57 @@ let package = Package(
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
|
||||
// Platform-specific dependencies will be conditionally included
|
||||
.package(url: "https://github.com/apple/swift-system", from: "1.0.0"), // For cross-platform system APIs
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "peekaboo",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "SystemPackage", package: "swift-system"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency"),
|
||||
.unsafeFlags(["-parse-as-library"])
|
||||
.unsafeFlags(["-parse-as-library"]),
|
||||
// Enable platform-specific compilation
|
||||
.define("CROSS_PLATFORM_SUPPORT"),
|
||||
// Platform-specific defines
|
||||
.define("MACOS_SUPPORT", .when(platforms: [.macOS])),
|
||||
.define("WINDOWS_SUPPORT", .when(platforms: [.windows])),
|
||||
.define("LINUX_SUPPORT", .when(platforms: [.linux])),
|
||||
],
|
||||
linkerSettings: [
|
||||
// macOS-specific frameworks
|
||||
.linkedFramework("AppKit", .when(platforms: [.macOS])),
|
||||
.linkedFramework("CoreGraphics", .when(platforms: [.macOS])),
|
||||
.linkedFramework("ScreenCaptureKit", .when(platforms: [.macOS])),
|
||||
.linkedFramework("ApplicationServices", .when(platforms: [.macOS])),
|
||||
// Windows-specific libraries
|
||||
.linkedLibrary("user32", .when(platforms: [.windows])),
|
||||
.linkedLibrary("gdi32", .when(platforms: [.windows])),
|
||||
.linkedLibrary("dwmapi", .when(platforms: [.windows])),
|
||||
.linkedLibrary("dxgi", .when(platforms: [.windows])),
|
||||
.linkedLibrary("d3d11", .when(platforms: [.windows])),
|
||||
// Linux-specific libraries
|
||||
.linkedLibrary("X11", .when(platforms: [.linux])),
|
||||
.linkedLibrary("Xcomposite", .when(platforms: [.linux])),
|
||||
.linkedLibrary("Xrandr", .when(platforms: [.linux])),
|
||||
.linkedLibrary("Xfixes", .when(platforms: [.linux])),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "peekabooTests",
|
||||
dependencies: ["peekaboo"],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency")
|
||||
.enableExperimentalFeature("StrictConcurrency"),
|
||||
.define("CROSS_PLATFORM_SUPPORT"),
|
||||
.define("MACOS_SUPPORT", .when(platforms: [.macOS])),
|
||||
.define("WINDOWS_SUPPORT", .when(platforms: [.windows])),
|
||||
.define("LINUX_SUPPORT", .when(platforms: [.linux])),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// Legacy compatibility wrapper - use PlatformFactory.createApplicationFinder() for new code
|
||||
struct AppMatch: Sendable {
|
||||
#if os(macOS)
|
||||
let app: NSRunningApplication
|
||||
#endif
|
||||
let score: Double
|
||||
let matchType: String
|
||||
}
|
||||
|
||||
// Legacy ApplicationFinder class for backward compatibility
|
||||
// New code should use PlatformFactory.createApplicationFinder()
|
||||
final class ApplicationFinder: Sendable {
|
||||
#if os(macOS)
|
||||
static func findApplication(identifier: String) throws(ApplicationError) -> NSRunningApplication {
|
||||
// Logger.shared.debug("Searching for application: \(identifier)")
|
||||
|
||||
// In CI environment, throw not found to avoid accessing NSWorkspace
|
||||
if ProcessInfo.processInfo.environment["CI"] == "true" {
|
||||
throw ApplicationError.notFound(identifier)
|
||||
@ -20,7 +27,6 @@ final class ApplicationFinder: Sendable {
|
||||
|
||||
// Check for exact bundle ID match first
|
||||
if let exactMatch = runningApps.first(where: { $0.bundleIdentifier == identifier }) {
|
||||
// Logger.shared.debug("Found exact bundle ID match: \(exactMatch.localizedName ?? "Unknown")")
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
@ -28,346 +34,146 @@ final class ApplicationFinder: Sendable {
|
||||
let allMatches = findAllMatches(for: identifier, in: runningApps)
|
||||
|
||||
// Filter out browser helpers for common browser searches
|
||||
let matches = filterBrowserHelpers(matches: allMatches, identifier: identifier)
|
||||
let filteredMatches = filterBrowserHelpers(allMatches, searchTerm: identifier)
|
||||
|
||||
// Get unique matches
|
||||
let uniqueMatches = removeDuplicateMatches(from: matches)
|
||||
if filteredMatches.isEmpty {
|
||||
throw ApplicationError.notFound(identifier)
|
||||
} else if filteredMatches.count == 1 {
|
||||
return filteredMatches[0].app
|
||||
} else {
|
||||
// Multiple matches found
|
||||
let apps = filteredMatches.map { $0.app }
|
||||
throw ApplicationError.ambiguous(identifier, apps)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle results
|
||||
return try processMatchResults(uniqueMatches, identifier: identifier, runningApps: runningApps)
|
||||
static func getAllRunningApplications() -> [ApplicationInfo] {
|
||||
// In CI environment, return empty array
|
||||
if ProcessInfo.processInfo.environment["CI"] == "true" {
|
||||
return []
|
||||
}
|
||||
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
return runningApps.compactMap { app in
|
||||
guard let bundleId = app.bundleIdentifier,
|
||||
let appName = app.localizedName else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ApplicationInfo(
|
||||
app_name: appName,
|
||||
bundle_id: bundleId,
|
||||
pid: app.processIdentifier,
|
||||
is_active: app.isActive,
|
||||
window_count: 0 // Would need separate call to get window count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func findAllMatches(for identifier: String, in apps: [NSRunningApplication]) -> [AppMatch] {
|
||||
var matches: [AppMatch] = []
|
||||
let lowerIdentifier = identifier.lowercased()
|
||||
let lowercaseIdentifier = identifier.lowercased()
|
||||
|
||||
for app in apps {
|
||||
// Check exact name match
|
||||
if let appName = app.localizedName {
|
||||
if appName.lowercased() == lowerIdentifier {
|
||||
matches.append(AppMatch(app: app, score: 1.0, matchType: "exact_name"))
|
||||
continue
|
||||
}
|
||||
guard let appName = app.localizedName else { continue }
|
||||
let lowercaseAppName = appName.lowercased()
|
||||
|
||||
// Check partial name matches
|
||||
matches.append(contentsOf: findNameMatches(app: app, appName: appName, identifier: lowerIdentifier))
|
||||
// Exact name match (highest priority)
|
||||
if lowercaseAppName == lowercaseIdentifier {
|
||||
matches.append(AppMatch(app: app, score: 100.0, matchType: "exact_name"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check bundle ID matches
|
||||
if let bundleId = app.bundleIdentifier, bundleId.lowercased().contains(lowerIdentifier) {
|
||||
let score = Double(lowerIdentifier.count) / Double(bundleId.count) * 0.6
|
||||
matches.append(AppMatch(app: app, score: score, matchType: "bundle_contains"))
|
||||
// Bundle ID contains identifier
|
||||
if let bundleId = app.bundleIdentifier,
|
||||
bundleId.lowercased().contains(lowercaseIdentifier) {
|
||||
matches.append(AppMatch(app: app, score: 90.0, matchType: "bundle_id"))
|
||||
continue
|
||||
}
|
||||
|
||||
// App name contains identifier
|
||||
if lowercaseAppName.contains(lowercaseIdentifier) {
|
||||
matches.append(AppMatch(app: app, score: 80.0, matchType: "name_contains"))
|
||||
continue
|
||||
}
|
||||
|
||||
// App name starts with identifier
|
||||
if lowercaseAppName.hasPrefix(lowercaseIdentifier) {
|
||||
matches.append(AppMatch(app: app, score: 85.0, matchType: "name_prefix"))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sorted { $0.score > $1.score }
|
||||
}
|
||||
|
||||
private static func findNameMatches(app: NSRunningApplication, appName: String, identifier: String) -> [AppMatch] {
|
||||
var matches: [AppMatch] = []
|
||||
let lowerAppName = appName.lowercased()
|
||||
private static func filterBrowserHelpers(_ matches: [AppMatch], searchTerm: String) -> [AppMatch] {
|
||||
let browserHelperPatterns = [
|
||||
"helper", "renderer", "gpu", "utility", "crashpad"
|
||||
]
|
||||
|
||||
if lowerAppName.hasPrefix(identifier) {
|
||||
let score = Double(identifier.count) / Double(lowerAppName.count)
|
||||
matches.append(AppMatch(app: app, score: score, matchType: "prefix"))
|
||||
} else if lowerAppName.contains(identifier) {
|
||||
let score = Double(identifier.count) / Double(lowerAppName.count) * 0.8
|
||||
matches.append(AppMatch(app: app, score: score, matchType: "contains"))
|
||||
} else {
|
||||
// Try fuzzy matching if no direct match
|
||||
matches.append(contentsOf: findFuzzyMatches(app: app, appName: appName, identifier: identifier))
|
||||
}
|
||||
// If searching for a browser specifically, don't filter helpers
|
||||
let browserNames = ["safari", "chrome", "firefox", "edge", "brave", "opera"]
|
||||
let isSearchingForBrowser = browserNames.contains { searchTerm.lowercased().contains($0) }
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private static func findFuzzyMatches(app: NSRunningApplication, appName: String, identifier: String) -> [AppMatch] {
|
||||
var matches: [AppMatch] = []
|
||||
let lowerAppName = appName.lowercased()
|
||||
|
||||
// Try fuzzy matching against the full app name
|
||||
let fullNameSimilarity = calculateStringSimilarity(lowerAppName, identifier)
|
||||
if fullNameSimilarity >= 0.7 {
|
||||
let score = fullNameSimilarity * 0.9
|
||||
matches.append(AppMatch(app: app, score: score, matchType: "fuzzy"))
|
||||
return matches // Return early if we found a good match
|
||||
}
|
||||
|
||||
// For multi-word app names, also try fuzzy matching against individual words
|
||||
let words = lowerAppName.split(separator: " ").map(String.init)
|
||||
for (index, word) in words.enumerated() {
|
||||
let wordSimilarity = calculateStringSimilarity(word, identifier)
|
||||
if wordSimilarity >= 0.65 {
|
||||
// Score based on word similarity but reduced for partial matches
|
||||
// Give higher score to matches on the first word (main app name)
|
||||
let positionMultiplier = index == 0 ? 0.85 : 0.75
|
||||
// Reduce score for helper/service processes
|
||||
var systemPenalty = 1.0
|
||||
if lowerAppName.contains("helper") { systemPenalty *= 0.8 }
|
||||
if lowerAppName.contains("service") || lowerAppName.contains("theme") { systemPenalty *= 0.7 }
|
||||
let score = wordSimilarity * positionMultiplier * systemPenalty
|
||||
matches.append(AppMatch(app: app, score: score, matchType: "fuzzy_word"))
|
||||
break // Only match first suitable word
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private static func calculateStringSimilarity(_ str1: String, _ str2: String) -> Double {
|
||||
// Only consider strings with reasonable length differences
|
||||
let lengthDiff = abs(str1.count - str2.count)
|
||||
guard lengthDiff <= 3 else { return 0.0 }
|
||||
|
||||
let distance = levenshteinDistance(str1, str2)
|
||||
let maxLength = max(str1.count, str2.count)
|
||||
|
||||
// Calculate similarity (1.0 = identical, 0.0 = completely different)
|
||||
return 1.0 - (Double(distance) / Double(maxLength))
|
||||
}
|
||||
|
||||
private static func levenshteinDistance(_ str1: String, _ str2: String) -> Int {
|
||||
let chars1 = Array(str1)
|
||||
let chars2 = Array(str2)
|
||||
|
||||
let length1 = chars1.count
|
||||
let length2 = chars2.count
|
||||
|
||||
if length1 == 0 { return length2 }
|
||||
if length2 == 0 { return length1 }
|
||||
|
||||
var matrix = Array(repeating: Array(repeating: 0, count: length2 + 1), count: length1 + 1)
|
||||
|
||||
for idx1 in 0...length1 {
|
||||
matrix[idx1][0] = idx1
|
||||
}
|
||||
for idx2 in 0...length2 {
|
||||
matrix[0][idx2] = idx2
|
||||
}
|
||||
|
||||
for idx1 in 1...length1 {
|
||||
for idx2 in 1...length2 {
|
||||
let cost = chars1[idx1 - 1] == chars2[idx2 - 1] ? 0 : 1
|
||||
matrix[idx1][idx2] = min(
|
||||
matrix[idx1 - 1][idx2] + 1, // deletion
|
||||
matrix[idx1][idx2 - 1] + 1, // insertion
|
||||
matrix[idx1 - 1][idx2 - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[length1][length2]
|
||||
}
|
||||
|
||||
private static func removeDuplicateMatches(from matches: [AppMatch]) -> [AppMatch] {
|
||||
var uniqueMatches: [AppMatch] = []
|
||||
var seenPIDs: Set<pid_t> = []
|
||||
|
||||
for match in matches where !seenPIDs.contains(match.app.processIdentifier) {
|
||||
uniqueMatches.append(match)
|
||||
seenPIDs.insert(match.app.processIdentifier)
|
||||
}
|
||||
|
||||
return uniqueMatches
|
||||
}
|
||||
|
||||
private static func processMatchResults(
|
||||
_ matches: [AppMatch],
|
||||
identifier: String,
|
||||
runningApps: [NSRunningApplication]
|
||||
) throws(ApplicationError) -> NSRunningApplication {
|
||||
guard !matches.isEmpty else {
|
||||
// Provide browser-specific error messages
|
||||
let browserIdentifiers = ["chrome", "safari", "firefox", "edge", "brave", "arc", "opera"]
|
||||
let lowerIdentifier = identifier.lowercased()
|
||||
|
||||
if browserIdentifiers.contains(lowerIdentifier) {
|
||||
// Logger.shared.error("\(identifier.capitalized) browser is not running or not found")
|
||||
} else {
|
||||
// Logger.shared.error("No applications found matching: \(identifier)")
|
||||
}
|
||||
|
||||
// Find similar app names using fuzzy matching
|
||||
let suggestions = findSimilarApplications(identifier: identifier, from: runningApps)
|
||||
if !suggestions.isEmpty {
|
||||
// Logger.shared.debug("Did you mean: \(suggestions.joined(separator: ", "))?")
|
||||
}
|
||||
|
||||
throw ApplicationError.notFound(identifier)
|
||||
}
|
||||
|
||||
// Check for ambiguous matches
|
||||
let topScore = matches[0].score
|
||||
// Use a smaller threshold for fuzzy matches to avoid ambiguity
|
||||
let threshold = matches[0].matchType.contains("fuzzy") ? 0.05 : 0.1
|
||||
let topMatches = matches.filter { abs($0.score - topScore) < threshold }
|
||||
|
||||
if topMatches.count > 1 {
|
||||
handleAmbiguousMatches(topMatches, identifier: identifier)
|
||||
throw ApplicationError.ambiguous(identifier, topMatches.map(\.app))
|
||||
}
|
||||
|
||||
let bestMatch = matches[0]
|
||||
// Logger.shared.debug(
|
||||
// "Found application: \(bestMatch.app.localizedName ?? "Unknown") " +
|
||||
// "(score: \(bestMatch.score), type: \(bestMatch.matchType))"
|
||||
// )
|
||||
|
||||
return bestMatch.app
|
||||
}
|
||||
|
||||
private static func handleAmbiguousMatches(_ matches: [AppMatch], identifier: String) {
|
||||
let matchDescriptions = matches.map { match in
|
||||
"\(match.app.localizedName ?? "Unknown") (\(match.app.bundleIdentifier ?? "unknown.bundle"))"
|
||||
}
|
||||
|
||||
Logger.shared.error("Ambiguous application identifier: \(identifier)")
|
||||
Logger.shared.error("Matches found: \(matchDescriptions.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
private static func findSimilarApplications(identifier: String, from apps: [NSRunningApplication]) -> [String] {
|
||||
var suggestions: [(name: String, score: Double)] = []
|
||||
let lowerIdentifier = identifier.lowercased()
|
||||
|
||||
for app in apps {
|
||||
guard let appName = app.localizedName else { continue }
|
||||
let lowerAppName = appName.lowercased()
|
||||
|
||||
// Try full name similarity
|
||||
let fullNameSimilarity = calculateStringSimilarity(lowerAppName, lowerIdentifier)
|
||||
if fullNameSimilarity >= 0.6 && fullNameSimilarity < 1.0 {
|
||||
suggestions.append((name: appName, score: fullNameSimilarity))
|
||||
continue
|
||||
}
|
||||
|
||||
// For multi-word app names, also check individual words
|
||||
let words = lowerAppName.split(separator: " ").map(String.init)
|
||||
for word in words {
|
||||
let wordSimilarity = calculateStringSimilarity(word, lowerIdentifier)
|
||||
if wordSimilarity >= 0.6 && wordSimilarity < 1.0 {
|
||||
// Reduce score slightly for word matches vs full name matches
|
||||
suggestions.append((name: appName, score: wordSimilarity * 0.9))
|
||||
break // Only match first suitable word
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity and take top 3 suggestions
|
||||
return suggestions
|
||||
.sorted { $0.score > $1.score }
|
||||
.prefix(3)
|
||||
.map(\.name)
|
||||
}
|
||||
|
||||
static func getAllRunningApplications() -> [ApplicationInfo] {
|
||||
// Logger.shared.debug("Retrieving all running applications")
|
||||
|
||||
// In CI environment, return empty array to avoid accessing NSWorkspace
|
||||
if ProcessInfo.processInfo.environment["CI"] == "true" {
|
||||
return []
|
||||
}
|
||||
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
var result: [ApplicationInfo] = []
|
||||
|
||||
for app in runningApps {
|
||||
// Skip background-only apps without a name
|
||||
guard let appName = app.localizedName, !appName.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count windows for this app
|
||||
let windowCount = countWindowsForApp(pid: app.processIdentifier)
|
||||
|
||||
// Only include applications that have one or more windows.
|
||||
guard windowCount > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let appInfo = ApplicationInfo(
|
||||
app_name: appName,
|
||||
bundle_id: app.bundleIdentifier ?? "",
|
||||
pid: app.processIdentifier,
|
||||
is_active: app.isActive,
|
||||
window_count: windowCount
|
||||
)
|
||||
|
||||
result.append(appInfo)
|
||||
}
|
||||
|
||||
// Sort by name for consistent output
|
||||
result.sort { $0.app_name.lowercased() < $1.app_name.lowercased() }
|
||||
|
||||
// Logger.shared.debug("Found \(result.count) running applications")
|
||||
return result
|
||||
}
|
||||
|
||||
private static func countWindowsForApp(pid: pid_t) -> Int {
|
||||
let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements)
|
||||
|
||||
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
||||
return 0
|
||||
}
|
||||
|
||||
var count = 0
|
||||
for windowInfo in windowList {
|
||||
if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
|
||||
windowPID == pid {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
private static func filterBrowserHelpers(matches: [AppMatch], identifier: String) -> [AppMatch] {
|
||||
// Define common browser identifiers that should filter out helpers
|
||||
let browserIdentifiers = ["chrome", "safari", "firefox", "edge", "brave", "arc", "opera"]
|
||||
let lowerIdentifier = identifier.lowercased()
|
||||
|
||||
// Check if the search is for a common browser
|
||||
guard browserIdentifiers.contains(lowerIdentifier) else {
|
||||
return matches // No filtering for non-browser searches
|
||||
}
|
||||
|
||||
// Logger.shared.debug("Filtering browser helpers for '\(identifier)' search")
|
||||
|
||||
// Filter out helper processes for browser searches
|
||||
let filteredMatches = matches.filter { match in
|
||||
guard let appName = match.app.localizedName?.lowercased() else { return true }
|
||||
|
||||
// Exclude obvious helper processes
|
||||
let isHelper = appName.contains("helper") ||
|
||||
appName.contains("renderer") ||
|
||||
appName.contains("utility") ||
|
||||
appName.contains("plugin") ||
|
||||
appName.contains("service") ||
|
||||
appName.contains("crashpad") ||
|
||||
appName.contains("gpu") ||
|
||||
appName.contains("background")
|
||||
|
||||
if isHelper {
|
||||
// Logger.shared.debug("Filtering out helper process: \(appName)")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// If we filtered out all matches, return the original matches to avoid "not found" errors
|
||||
// But log a warning about this case
|
||||
if filteredMatches.isEmpty && !matches.isEmpty {
|
||||
// Logger.shared.debug("All matches were filtered as helpers, returning original matches to avoid 'not
|
||||
// found' error")
|
||||
if isSearchingForBrowser {
|
||||
return matches
|
||||
}
|
||||
|
||||
// Logger.shared.debug("After browser helper filtering: \(filteredMatches.count) matches remaining")
|
||||
return filteredMatches
|
||||
// Filter out browser helpers
|
||||
return matches.filter { match in
|
||||
guard let appName = match.app.localizedName else { return true }
|
||||
let lowercaseAppName = appName.lowercased()
|
||||
|
||||
return !browserHelperPatterns.contains { pattern in
|
||||
lowercaseAppName.contains(pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Non-macOS platforms - use platform factory
|
||||
static func findApplication(identifier: String) async throws -> ApplicationInfo {
|
||||
let finder = PlatformFactory.createApplicationFinder()
|
||||
let apps = try await finder.findApplications(matching: identifier)
|
||||
guard let app = apps.first else {
|
||||
throw ApplicationError.notFound(identifier)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
static func getAllRunningApplications() async -> [ApplicationInfo] {
|
||||
let finder = PlatformFactory.createApplicationFinder()
|
||||
do {
|
||||
return try await finder.getRunningApplications()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum ApplicationError: Error, Sendable {
|
||||
// Application-related errors
|
||||
enum ApplicationError: Error, LocalizedError, Sendable {
|
||||
case notFound(String)
|
||||
#if os(macOS)
|
||||
case ambiguous(String, [NSRunningApplication])
|
||||
#else
|
||||
case ambiguous(String, [ApplicationInfo])
|
||||
#endif
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .notFound(identifier):
|
||||
return "Application '\(identifier)' not found or is not running."
|
||||
case let .ambiguous(identifier, matches):
|
||||
let appNames = matches.map {
|
||||
#if os(macOS)
|
||||
$0.localizedName ?? "Unknown"
|
||||
#else
|
||||
$0.name
|
||||
#endif
|
||||
}.joined(separator: ", ")
|
||||
return "Multiple applications match '\(identifier)': \(appNames)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import AppKit
|
||||
import ArgumentParser
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import ScreenCaptureKit
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
// Define the wrapper struct
|
||||
struct FileHandleTextOutputStream: TextOutputStream {
|
||||
@ -54,13 +57,38 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
Logger.shared.setJsonOutputMode(jsonOutput)
|
||||
|
||||
// Check platform support
|
||||
guard PlatformFactory.isSupported else {
|
||||
let error = CaptureError.platformNotSupported(PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
guard capabilities.screenCapture else {
|
||||
let error = CaptureError.featureNotSupported("Screen capture", PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
do {
|
||||
try PermissionsChecker.requireScreenRecordingPermission()
|
||||
// Check permissions using platform-specific checker
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
let hasPermission = await permissionsChecker.hasScreenRecordingPermission()
|
||||
|
||||
if !hasPermission {
|
||||
let instructions = permissionsChecker.getPermissionInstructions()
|
||||
let error = CaptureError.screenRecordingPermissionDenied
|
||||
Logger.shared.error("Screen recording permission required. \(instructions)")
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let savedFiles = try await performCapture()
|
||||
outputResults(savedFiles)
|
||||
} catch {
|
||||
handleError(error)
|
||||
// Throw a special exit error that AsyncParsableCommand can handle
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
}
|
||||
@ -111,86 +139,69 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
return app != nil ? .window : .screen
|
||||
}
|
||||
|
||||
private func captureScreens() async throws(CaptureError) -> [SavedFile] {
|
||||
let displays = try getActiveDisplays()
|
||||
private func captureScreens() async throws -> [SavedFile] {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
let screens = try await screenCapture.getAvailableScreens()
|
||||
|
||||
guard !screens.isEmpty else {
|
||||
throw CaptureError.noDisplaysAvailable
|
||||
}
|
||||
|
||||
var savedFiles: [SavedFile] = []
|
||||
|
||||
if let screenIndex {
|
||||
savedFiles = try await captureSpecificScreen(displays: displays, screenIndex: screenIndex)
|
||||
savedFiles = try await captureSpecificScreen(screens: screens, screenIndex: screenIndex)
|
||||
} else {
|
||||
savedFiles = try await captureAllScreens(displays: displays)
|
||||
savedFiles = try await captureAllScreens(screens: screens)
|
||||
}
|
||||
|
||||
return savedFiles
|
||||
}
|
||||
|
||||
private func getActiveDisplays() throws(CaptureError) -> [CGDirectDisplayID] {
|
||||
var displayCount: UInt32 = 0
|
||||
let result = CGGetActiveDisplayList(0, nil, &displayCount)
|
||||
guard result == .success && displayCount > 0 else {
|
||||
throw CaptureError.noDisplaysAvailable
|
||||
}
|
||||
|
||||
var displays = [CGDirectDisplayID](repeating: 0, count: Int(displayCount))
|
||||
let listResult = CGGetActiveDisplayList(displayCount, &displays, nil)
|
||||
guard listResult == .success else {
|
||||
throw CaptureError.noDisplaysAvailable
|
||||
}
|
||||
|
||||
return displays
|
||||
}
|
||||
|
||||
private func captureSpecificScreen(
|
||||
displays: [CGDirectDisplayID],
|
||||
screens: [ScreenInfo],
|
||||
screenIndex: Int
|
||||
) async throws(CaptureError) -> [SavedFile] {
|
||||
if screenIndex >= 0 && screenIndex < displays.count {
|
||||
let displayID = displays[screenIndex]
|
||||
) async throws -> [SavedFile] {
|
||||
if screenIndex >= 0 && screenIndex < screens.count {
|
||||
let screen = screens[screenIndex]
|
||||
let labelSuffix = " (Index \(screenIndex))"
|
||||
return try await [captureSingleDisplay(displayID: displayID, index: screenIndex, labelSuffix: labelSuffix)]
|
||||
return try await [captureSingleScreen(screen: screen, labelSuffix: labelSuffix)]
|
||||
} else {
|
||||
Logger.shared.debug("Screen index \(screenIndex) is out of bounds. Capturing all screens instead.")
|
||||
// When falling back to all screens, use fallback-aware capture to prevent filename conflicts
|
||||
return try await captureAllScreensWithFallback(displays: displays)
|
||||
return try await captureAllScreensWithFallback(screens: screens)
|
||||
}
|
||||
}
|
||||
|
||||
private func captureAllScreens(displays: [CGDirectDisplayID]) async throws(CaptureError) -> [SavedFile] {
|
||||
private func captureAllScreens(screens: [ScreenInfo]) async throws -> [SavedFile] {
|
||||
var savedFiles: [SavedFile] = []
|
||||
for (index, displayID) in displays.enumerated() {
|
||||
let savedFile = try await captureSingleDisplay(displayID: displayID, index: index, labelSuffix: "")
|
||||
for screen in screens {
|
||||
let savedFile = try await captureSingleScreen(screen: screen, labelSuffix: "")
|
||||
savedFiles.append(savedFile)
|
||||
}
|
||||
return savedFiles
|
||||
}
|
||||
|
||||
private func captureAllScreensWithFallback(displays: [CGDirectDisplayID]) async throws(CaptureError)
|
||||
-> [SavedFile] {
|
||||
private func captureAllScreensWithFallback(screens: [ScreenInfo]) async throws -> [SavedFile] {
|
||||
var savedFiles: [SavedFile] = []
|
||||
for (index, displayID) in displays.enumerated() {
|
||||
let savedFile = try await captureSingleDisplayWithFallback(
|
||||
displayID: displayID,
|
||||
index: index,
|
||||
labelSuffix: ""
|
||||
)
|
||||
for screen in screens {
|
||||
let savedFile = try await captureSingleScreenWithFallback(screen: screen, labelSuffix: "")
|
||||
savedFiles.append(savedFile)
|
||||
}
|
||||
return savedFiles
|
||||
}
|
||||
|
||||
private func captureSingleDisplay(
|
||||
displayID: CGDirectDisplayID,
|
||||
index: Int,
|
||||
private func captureSingleScreen(
|
||||
screen: ScreenInfo,
|
||||
labelSuffix: String
|
||||
) async throws(CaptureError) -> SavedFile {
|
||||
let fileName = FileNameGenerator.generateFileName(displayIndex: index, format: format)
|
||||
) async throws -> SavedFile {
|
||||
let fileName = FileNameGenerator.generateFileName(displayIndex: screen.index, format: format)
|
||||
let filePath = OutputPathResolver.getOutputPath(basePath: path, fileName: fileName)
|
||||
|
||||
try await captureDisplay(displayID, to: filePath)
|
||||
try await captureScreen(screen, to: filePath)
|
||||
|
||||
return SavedFile(
|
||||
path: filePath,
|
||||
item_label: "Display \(index + 1)\(labelSuffix)",
|
||||
item_label: "Display \(screen.index + 1)\(labelSuffix)",
|
||||
window_title: nil,
|
||||
window_id: nil,
|
||||
window_index: nil,
|
||||
@ -198,19 +209,18 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
)
|
||||
}
|
||||
|
||||
private func captureSingleDisplayWithFallback(
|
||||
displayID: CGDirectDisplayID,
|
||||
index: Int,
|
||||
private func captureSingleScreenWithFallback(
|
||||
screen: ScreenInfo,
|
||||
labelSuffix: String
|
||||
) async throws(CaptureError) -> SavedFile {
|
||||
let fileName = FileNameGenerator.generateFileName(displayIndex: index, format: format)
|
||||
) async throws -> SavedFile {
|
||||
let fileName = FileNameGenerator.generateFileName(displayIndex: screen.index, format: format)
|
||||
let filePath = OutputPathResolver.getOutputPathWithFallback(basePath: path, fileName: fileName)
|
||||
|
||||
try await captureDisplay(displayID, to: filePath)
|
||||
try await captureScreen(screen, to: filePath)
|
||||
|
||||
return SavedFile(
|
||||
path: filePath,
|
||||
item_label: "Display \(index + 1)\(labelSuffix)",
|
||||
item_label: "Display \(screen.index + 1)\(labelSuffix)",
|
||||
window_title: nil,
|
||||
window_id: nil,
|
||||
window_index: nil,
|
||||
@ -219,42 +229,32 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private func captureApplicationWindow(_ appIdentifier: String) async throws -> [SavedFile] {
|
||||
let targetApp: NSRunningApplication
|
||||
do {
|
||||
targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
|
||||
} catch let ApplicationError.notFound(identifier) {
|
||||
throw CaptureError.appNotFound(identifier)
|
||||
} catch let ApplicationError.ambiguous(identifier, matches) {
|
||||
// For ambiguous matches, capture all windows from all matching applications
|
||||
Logger.shared.debug("Multiple applications match '\(identifier)', capturing all windows from all matches")
|
||||
return try await captureWindowsFromMultipleApps(matches, appIdentifier: identifier)
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
|
||||
// Find the application
|
||||
let apps = try await applicationFinder.findApplications(matching: appIdentifier)
|
||||
guard !apps.isEmpty else {
|
||||
throw CaptureError.appNotFound(appIdentifier)
|
||||
}
|
||||
|
||||
let targetApp = apps.first!
|
||||
|
||||
// Handle focus behavior (platform-specific)
|
||||
if captureFocus == .foreground || captureFocus == .auto {
|
||||
await handleApplicationFocus(targetApp)
|
||||
}
|
||||
|
||||
if captureFocus == .foreground || (captureFocus == .auto && !targetApp.isActive) {
|
||||
try PermissionsChecker.requireAccessibilityPermission()
|
||||
targetApp.activate()
|
||||
try await Task.sleep(nanoseconds: 200_000_000) // Brief delay for activation
|
||||
}
|
||||
|
||||
let windows = try WindowManager.getWindowsForApp(pid: targetApp.processIdentifier)
|
||||
let windows = try await windowManager.getWindows(for: targetApp.id)
|
||||
guard !windows.isEmpty else {
|
||||
throw CaptureError.noWindowsFound(targetApp.localizedName ?? appIdentifier)
|
||||
throw CaptureError.noWindowsFound(targetApp.name)
|
||||
}
|
||||
|
||||
let targetWindow: WindowData
|
||||
let targetWindow: PlatformWindowInfo
|
||||
if let windowTitle {
|
||||
guard let window = windows.first(where: { $0.title.contains(windowTitle) }) else {
|
||||
// Create detailed error message with available window titles for debugging
|
||||
let availableTitles = windows.map { "\"\($0.title)\"" }.joined(separator: ", ")
|
||||
let searchTerm = windowTitle
|
||||
let appName = targetApp.localizedName ?? "Unknown"
|
||||
|
||||
Logger.shared.debug(
|
||||
"Window not found. Searched for '\(searchTerm)' in \(appName). " +
|
||||
"Available windows: \(availableTitles)"
|
||||
)
|
||||
|
||||
throw CaptureError.windowTitleNotFound(searchTerm, appName, availableTitles)
|
||||
throw CaptureError.windowTitleNotFound(windowTitle, targetApp.name, availableTitles)
|
||||
}
|
||||
targetWindow = window
|
||||
} else if let windowIndex {
|
||||
@ -267,7 +267,7 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let fileName = FileNameGenerator.generateFileName(
|
||||
appName: targetApp.localizedName, windowTitle: targetWindow.title, format: format
|
||||
appName: targetApp.name, windowTitle: targetWindow.title, format: format
|
||||
)
|
||||
let filePath = OutputPathResolver.getOutputPath(basePath: path, fileName: fileName)
|
||||
|
||||
@ -275,10 +275,10 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
|
||||
let savedFile = SavedFile(
|
||||
path: filePath,
|
||||
item_label: targetApp.localizedName,
|
||||
item_label: targetApp.name,
|
||||
window_title: targetWindow.title,
|
||||
window_id: targetWindow.windowId,
|
||||
window_index: targetWindow.windowIndex,
|
||||
window_id: targetWindow.id,
|
||||
window_index: 0, // This would need to be calculated properly
|
||||
mime_type: format == .png ? "image/png" : "image/jpeg"
|
||||
)
|
||||
|
||||
@ -286,81 +286,32 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private func captureAllApplicationWindows(_ appIdentifier: String) async throws -> [SavedFile] {
|
||||
let targetApp: NSRunningApplication
|
||||
do {
|
||||
targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier)
|
||||
} catch let ApplicationError.notFound(identifier) {
|
||||
throw CaptureError.appNotFound(identifier)
|
||||
} catch let ApplicationError.ambiguous(identifier, matches) {
|
||||
// For ambiguous matches, capture all windows from all matching applications
|
||||
Logger.shared.debug("Multiple applications match '\(identifier)', capturing all windows from all matches")
|
||||
return try await captureWindowsFromMultipleApps(matches, appIdentifier: identifier)
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
|
||||
// Find the application
|
||||
let apps = try await applicationFinder.findApplications(matching: appIdentifier)
|
||||
guard !apps.isEmpty else {
|
||||
throw CaptureError.appNotFound(appIdentifier)
|
||||
}
|
||||
|
||||
if captureFocus == .foreground || (captureFocus == .auto && !targetApp.isActive) {
|
||||
try PermissionsChecker.requireAccessibilityPermission()
|
||||
targetApp.activate()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
let windows = try WindowManager.getWindowsForApp(pid: targetApp.processIdentifier)
|
||||
guard !windows.isEmpty else {
|
||||
throw CaptureError.noWindowsFound(targetApp.localizedName ?? appIdentifier)
|
||||
}
|
||||
|
||||
var savedFiles: [SavedFile] = []
|
||||
|
||||
for (index, window) in windows.enumerated() {
|
||||
let fileName = FileNameGenerator.generateFileName(
|
||||
appName: targetApp.localizedName, windowIndex: index, windowTitle: window.title, format: format
|
||||
)
|
||||
let filePath = OutputPathResolver.getOutputPath(basePath: path, fileName: fileName)
|
||||
|
||||
try await captureWindow(window, to: filePath)
|
||||
|
||||
let savedFile = SavedFile(
|
||||
path: filePath,
|
||||
item_label: targetApp.localizedName,
|
||||
window_title: window.title,
|
||||
window_id: window.windowId,
|
||||
window_index: index,
|
||||
mime_type: format == .png ? "image/png" : "image/jpeg"
|
||||
)
|
||||
savedFiles.append(savedFile)
|
||||
}
|
||||
|
||||
return savedFiles
|
||||
}
|
||||
|
||||
private func captureWindowsFromMultipleApps(
|
||||
_ apps: [NSRunningApplication], appIdentifier: String
|
||||
) async throws -> [SavedFile] {
|
||||
|
||||
var allSavedFiles: [SavedFile] = []
|
||||
var totalWindowIndex = 0
|
||||
|
||||
|
||||
for targetApp in apps {
|
||||
// Log which app we're processing
|
||||
Logger.shared.debug("Capturing windows for app: \(targetApp.localizedName ?? "Unknown")")
|
||||
|
||||
// Handle focus behavior for each app (if needed)
|
||||
if captureFocus == .foreground || (captureFocus == .auto && !targetApp.isActive) {
|
||||
try PermissionsChecker.requireAccessibilityPermission()
|
||||
targetApp.activate()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
// Handle focus behavior (platform-specific)
|
||||
if captureFocus == .foreground || captureFocus == .auto {
|
||||
await handleApplicationFocus(targetApp)
|
||||
}
|
||||
|
||||
let windows = try WindowManager.getWindowsForApp(pid: targetApp.processIdentifier)
|
||||
let windows = try await windowManager.getWindows(for: targetApp.id)
|
||||
if windows.isEmpty {
|
||||
Logger.shared.debug("No windows found for app: \(targetApp.localizedName ?? "Unknown")")
|
||||
Logger.shared.debug("No windows found for app: \(targetApp.name)")
|
||||
continue
|
||||
}
|
||||
|
||||
for window in windows {
|
||||
for (index, window) in windows.enumerated() {
|
||||
let fileName = FileNameGenerator.generateFileName(
|
||||
appName: targetApp.localizedName,
|
||||
windowIndex: totalWindowIndex,
|
||||
windowTitle: window.title,
|
||||
format: format
|
||||
appName: targetApp.name, windowIndex: index, windowTitle: window.title, format: format
|
||||
)
|
||||
let filePath = OutputPathResolver.getOutputPath(basePath: path, fileName: fileName)
|
||||
|
||||
@ -368,14 +319,13 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
|
||||
let savedFile = SavedFile(
|
||||
path: filePath,
|
||||
item_label: targetApp.localizedName,
|
||||
item_label: targetApp.name,
|
||||
window_title: window.title,
|
||||
window_id: window.windowId,
|
||||
window_index: totalWindowIndex,
|
||||
window_id: window.id,
|
||||
window_index: index,
|
||||
mime_type: format == .png ? "image/png" : "image/jpeg"
|
||||
)
|
||||
allSavedFiles.append(savedFile)
|
||||
totalWindowIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,32 +336,24 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
return allSavedFiles
|
||||
}
|
||||
|
||||
private func captureDisplay(_ displayID: CGDirectDisplayID, to path: String) async throws(CaptureError) {
|
||||
private func captureScreen(_ screen: ScreenInfo, to path: String) async throws {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
|
||||
do {
|
||||
try await ScreenCapture.captureDisplay(displayID, to: path, format: format)
|
||||
} catch let error as CaptureError {
|
||||
// Re-throw CaptureError as-is
|
||||
throw error
|
||||
let imageData = try await screenCapture.captureScreen(screenIndex: screen.index)
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
// Check if this is a permission error from ScreenCaptureKit
|
||||
if PermissionErrorDetector.isScreenRecordingPermissionError(error) {
|
||||
throw CaptureError.screenRecordingPermissionDenied
|
||||
}
|
||||
throw CaptureError.captureCreationFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func captureWindow(_ window: WindowData, to path: String) async throws(CaptureError) {
|
||||
private func captureWindow(_ window: PlatformWindowInfo, to path: String) async throws {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
|
||||
do {
|
||||
try await ScreenCapture.captureWindow(window, to: path, format: format)
|
||||
} catch let error as CaptureError {
|
||||
// Re-throw CaptureError as-is
|
||||
throw error
|
||||
let imageData = try await screenCapture.captureWindow(windowId: window.id, bounds: window.bounds)
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
// Check if this is a permission error from ScreenCaptureKit
|
||||
if PermissionErrorDetector.isScreenRecordingPermissionError(error) {
|
||||
throw CaptureError.screenRecordingPermissionDenied
|
||||
}
|
||||
throw CaptureError.windowCaptureFailed(error)
|
||||
}
|
||||
}
|
||||
@ -419,6 +361,17 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
private func captureFrontmostWindow() async throws -> [SavedFile] {
|
||||
Logger.shared.debug("Capturing frontmost window")
|
||||
|
||||
// This is platform-specific and would need different implementations
|
||||
#if os(macOS)
|
||||
return try await captureFrontmostWindowMacOS()
|
||||
#else
|
||||
// For other platforms, we'd need to implement frontmost window detection
|
||||
throw CaptureError.featureNotSupported("Frontmost window capture", PlatformFactory.currentPlatform)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func captureFrontmostWindowMacOS() async throws -> [SavedFile] {
|
||||
// Get the frontmost (active) application
|
||||
guard let frontmostApp = NSWorkspace.shared.frontmostApplication else {
|
||||
throw CaptureError.appNotFound("No frontmost application found")
|
||||
@ -426,8 +379,10 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
|
||||
Logger.shared.debug("Frontmost app: \(frontmostApp.localizedName ?? "Unknown")")
|
||||
|
||||
// Get windows for the frontmost app
|
||||
let windows = try WindowManager.getWindowsForApp(pid: frontmostApp.processIdentifier)
|
||||
// Use the cross-platform window manager
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
let windows = try await windowManager.getWindows(for: String(frontmostApp.processIdentifier))
|
||||
|
||||
guard !windows.isEmpty else {
|
||||
throw CaptureError.noWindowsFound(frontmostApp.localizedName ?? "frontmost application")
|
||||
}
|
||||
@ -451,9 +406,24 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
path: filePath,
|
||||
item_label: appName,
|
||||
window_title: frontmostWindow.title,
|
||||
window_id: UInt32(frontmostWindow.windowId),
|
||||
window_index: frontmostWindow.windowIndex,
|
||||
window_id: frontmostWindow.id,
|
||||
window_index: 0,
|
||||
mime_type: format == .png ? "image/png" : "image/jpeg"
|
||||
)]
|
||||
}
|
||||
#endif
|
||||
|
||||
private func handleApplicationFocus(_ app: ApplicationInfo) async {
|
||||
#if os(macOS)
|
||||
// On macOS, we can activate applications
|
||||
if let pid = app.processId {
|
||||
let runningApp = NSRunningApplication(processIdentifier: pid_t(pid))
|
||||
runningApp?.activate()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // Brief delay for activation
|
||||
}
|
||||
#else
|
||||
// On other platforms, focus handling would be different or not available
|
||||
Logger.shared.debug("Application focus handling not implemented for \(PlatformFactory.currentPlatform)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum ImageErrorHandler {
|
||||
static func handleError(_ error: Error, jsonOutput: Bool) {
|
||||
let captureError: CaptureError = if let err = error as? CaptureError {
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import AppKit
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct ListCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
@ -27,16 +30,53 @@ struct AppsSubcommand: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
Logger.shared.setJsonOutputMode(jsonOutput)
|
||||
|
||||
do {
|
||||
try PermissionsChecker.requireScreenRecordingPermission()
|
||||
// Check platform support
|
||||
guard PlatformFactory.isSupported else {
|
||||
let error = CaptureError.platformNotSupported(PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
guard capabilities.applicationFinding else {
|
||||
let error = CaptureError.featureNotSupported("Application listing", PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let applications = ApplicationFinder.getAllRunningApplications()
|
||||
let data = ApplicationListData(applications: applications)
|
||||
do {
|
||||
// Check permissions using platform-specific checker
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
let hasPermission = await permissionsChecker.hasScreenRecordingPermission()
|
||||
|
||||
if !hasPermission {
|
||||
let instructions = permissionsChecker.getPermissionInstructions()
|
||||
Logger.shared.error("Screen recording permission required. \(instructions)")
|
||||
let error = CaptureError.screenRecordingPermissionDenied
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
let applications = try await applicationFinder.getRunningApplications()
|
||||
|
||||
// Convert to the expected format
|
||||
let appInfos = applications.map { app in
|
||||
ApplicationInfo(
|
||||
app_name: app.name,
|
||||
bundle_id: app.bundleIdentifier ?? "",
|
||||
pid: Int32(app.processId ?? 0),
|
||||
is_active: app.isRunning,
|
||||
window_count: 0 // This would need to be calculated separately
|
||||
)
|
||||
}
|
||||
|
||||
let data = ApplicationListData(applications: appInfos)
|
||||
|
||||
if jsonOutput {
|
||||
outputSuccess(data: data)
|
||||
} else {
|
||||
printApplicationList(applications)
|
||||
printApplicationList(appInfos)
|
||||
}
|
||||
|
||||
} catch {
|
||||
@ -48,58 +88,25 @@ struct AppsSubcommand: AsyncParsableCommand {
|
||||
private func handleError(_ error: Error) {
|
||||
let captureError: CaptureError = if let err = error as? CaptureError {
|
||||
err
|
||||
} else if let appError = error as? ApplicationError {
|
||||
switch appError {
|
||||
case let .notFound(identifier):
|
||||
.appNotFound(identifier)
|
||||
case let .ambiguous(identifier, _):
|
||||
.invalidArgument("Ambiguous application identifier: '\(identifier)'")
|
||||
}
|
||||
} else {
|
||||
.unknownError(error.localizedDescription)
|
||||
CaptureError.unknownError(error.localizedDescription)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
let code: ErrorCode = switch captureError {
|
||||
case .screenRecordingPermissionDenied:
|
||||
.PERMISSION_ERROR_SCREEN_RECORDING
|
||||
case .accessibilityPermissionDenied:
|
||||
.PERMISSION_ERROR_ACCESSIBILITY
|
||||
default:
|
||||
.INTERNAL_SWIFT_ERROR
|
||||
}
|
||||
outputError(
|
||||
message: captureError.localizedDescription,
|
||||
code: code,
|
||||
details: "Failed to list applications"
|
||||
)
|
||||
} else {
|
||||
fputs("Error: \(captureError.localizedDescription)\n", stderr)
|
||||
}
|
||||
// Don't call exit() here - let the caller handle process termination
|
||||
ImageErrorHandler.handleError(captureError, jsonOutput: jsonOutput)
|
||||
}
|
||||
|
||||
func printApplicationList(_ applications: [ApplicationInfo]) {
|
||||
let output = formatApplicationList(applications)
|
||||
print(output)
|
||||
}
|
||||
private func printApplicationList(_ applications: [ApplicationInfo]) {
|
||||
print("Running Applications:")
|
||||
print("====================")
|
||||
|
||||
func formatApplicationList(_ applications: [ApplicationInfo]) -> String {
|
||||
var output = "Running Applications (\(applications.count)):\n\n"
|
||||
|
||||
for (index, app) in applications.enumerated() {
|
||||
output += "\(index + 1). \(app.app_name)\n"
|
||||
output += " Bundle ID: \(app.bundle_id)\n"
|
||||
output += " PID: \(app.pid)\n"
|
||||
output += " Status: \(app.is_active ? "Active" : "Background")\n"
|
||||
// Only show window count if it's not 1
|
||||
if app.window_count != 1 {
|
||||
output += " Windows: \(app.window_count)\n"
|
||||
}
|
||||
output += "\n"
|
||||
for app in applications {
|
||||
let activeStatus = app.is_active ? "●" : "○"
|
||||
let bundleInfo = app.bundle_id.isEmpty ? "" : " (\(app.bundle_id))"
|
||||
print("\(activeStatus) \(app.app_name)\(bundleInfo) [PID: \(app.pid)]")
|
||||
}
|
||||
|
||||
return output
|
||||
print("\nTotal: \(applications.count) applications")
|
||||
print("● = Active, ○ = Background")
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,40 +119,80 @@ struct WindowsSubcommand: AsyncParsableCommand {
|
||||
@Option(name: .long, help: "Target application identifier")
|
||||
var app: String
|
||||
|
||||
@Option(name: .long, help: "Include additional window details (comma-separated: off_screen,bounds,ids)")
|
||||
var includeDetails: String?
|
||||
|
||||
@Flag(name: .long, help: "Output results in JSON format")
|
||||
var jsonOutput = false
|
||||
|
||||
@Option(name: .long, help: "Window details to include")
|
||||
var details: [WindowDetailOption] = []
|
||||
|
||||
func run() async throws {
|
||||
Logger.shared.setJsonOutputMode(jsonOutput)
|
||||
|
||||
// Check platform support
|
||||
guard PlatformFactory.isSupported else {
|
||||
let error = CaptureError.platformNotSupported(PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
guard capabilities.windowManagement else {
|
||||
let error = CaptureError.featureNotSupported("Window management", PlatformFactory.currentPlatform)
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
do {
|
||||
try PermissionsChecker.requireScreenRecordingPermission()
|
||||
// Check permissions
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
let hasAccessibility = await permissionsChecker.hasAccessibilityPermission()
|
||||
|
||||
if !hasAccessibility {
|
||||
let instructions = permissionsChecker.getPermissionInstructions()
|
||||
Logger.shared.error("Accessibility permission required. \(instructions)")
|
||||
let error = CaptureError.accessibilityPermissionDenied
|
||||
handleError(error)
|
||||
throw ExitCode(Int32(1))
|
||||
}
|
||||
|
||||
// Find the target application
|
||||
let targetApp = try ApplicationFinder.findApplication(identifier: app)
|
||||
// Find the application
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
let apps = try await applicationFinder.findApplications(matching: app)
|
||||
|
||||
guard !apps.isEmpty else {
|
||||
throw CaptureError.appNotFound(app)
|
||||
}
|
||||
|
||||
let targetApp = apps.first!
|
||||
|
||||
// Get windows for the application
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
let windows = try await windowManager.getWindows(for: targetApp.id)
|
||||
|
||||
// Parse include details options
|
||||
let detailOptions = parseIncludeDetails()
|
||||
|
||||
// Get windows for the app
|
||||
let windows = try WindowManager.getWindowsInfoForApp(
|
||||
pid: targetApp.processIdentifier,
|
||||
includeOffScreen: detailOptions.contains(.off_screen),
|
||||
includeBounds: detailOptions.contains(.bounds),
|
||||
includeIDs: detailOptions.contains(.ids)
|
||||
)
|
||||
// Convert to the expected format
|
||||
let windowInfos = windows.enumerated().map { index, window in
|
||||
WindowInfo(
|
||||
window_title: window.title,
|
||||
window_id: UInt32(window.id) ?? nil,
|
||||
window_index: index,
|
||||
bounds: details.contains(.bounds) ? WindowBounds(
|
||||
x_coordinate: Int(window.bounds.minX),
|
||||
y_coordinate: Int(window.bounds.minY),
|
||||
width: Int(window.bounds.width),
|
||||
height: Int(window.bounds.height)
|
||||
) : nil,
|
||||
is_on_screen: details.contains(.off_screen) ? window.isVisible : nil
|
||||
)
|
||||
}
|
||||
|
||||
let targetAppInfo = TargetApplicationInfo(
|
||||
app_name: targetApp.localizedName ?? "Unknown",
|
||||
app_name: targetApp.name,
|
||||
bundle_id: targetApp.bundleIdentifier,
|
||||
pid: targetApp.processIdentifier
|
||||
pid: Int32(targetApp.processId ?? 0)
|
||||
)
|
||||
|
||||
let data = WindowListData(
|
||||
windows: windows,
|
||||
windows: windowInfos,
|
||||
target_application_info: targetAppInfo
|
||||
)
|
||||
|
||||
@ -164,97 +211,52 @@ struct WindowsSubcommand: AsyncParsableCommand {
|
||||
private func handleError(_ error: Error) {
|
||||
let captureError: CaptureError = if let err = error as? CaptureError {
|
||||
err
|
||||
} else if let appError = error as? ApplicationError {
|
||||
switch appError {
|
||||
case let .notFound(identifier):
|
||||
.appNotFound(identifier)
|
||||
case let .ambiguous(identifier, _):
|
||||
.invalidArgument("Ambiguous application identifier: '\(identifier)'")
|
||||
}
|
||||
} else {
|
||||
.unknownError(error.localizedDescription)
|
||||
CaptureError.unknownError(error.localizedDescription)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
let code: ErrorCode = switch captureError {
|
||||
case .screenRecordingPermissionDenied:
|
||||
.PERMISSION_ERROR_SCREEN_RECORDING
|
||||
case .accessibilityPermissionDenied:
|
||||
.PERMISSION_ERROR_ACCESSIBILITY
|
||||
case .appNotFound:
|
||||
.APP_NOT_FOUND
|
||||
default:
|
||||
.INTERNAL_SWIFT_ERROR
|
||||
}
|
||||
outputError(
|
||||
message: captureError.localizedDescription,
|
||||
code: code,
|
||||
details: "Failed to list windows"
|
||||
)
|
||||
} else {
|
||||
fputs("Error: \(captureError.localizedDescription)\n", stderr)
|
||||
}
|
||||
// Don't call exit() here - let the caller handle process termination
|
||||
}
|
||||
|
||||
private func parseIncludeDetails() -> Set<WindowDetailOption> {
|
||||
guard let detailsString = includeDetails else {
|
||||
return []
|
||||
}
|
||||
|
||||
let components = detailsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
var options: Set<WindowDetailOption> = []
|
||||
|
||||
for component in components {
|
||||
if let option = WindowDetailOption(rawValue: component) {
|
||||
options.insert(option)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
ImageErrorHandler.handleError(captureError, jsonOutput: jsonOutput)
|
||||
}
|
||||
|
||||
private func printWindowList(_ data: WindowListData) {
|
||||
let app = data.target_application_info
|
||||
let windows = data.windows
|
||||
let appInfo = data.target_application_info
|
||||
print("Windows for \(appInfo.app_name) [PID: \(appInfo.pid)]:")
|
||||
print("=" + String(repeating: "=", count: appInfo.app_name.count + 20))
|
||||
|
||||
print("Windows for \(app.app_name)")
|
||||
if let bundleId = app.bundle_id {
|
||||
print("Bundle ID: \(bundleId)")
|
||||
}
|
||||
print("PID: \(app.pid)")
|
||||
print("Total Windows: \(windows.count)")
|
||||
print()
|
||||
|
||||
if windows.isEmpty {
|
||||
if data.windows.isEmpty {
|
||||
print("No windows found.")
|
||||
return
|
||||
}
|
||||
|
||||
for (index, window) in windows.enumerated() {
|
||||
print("\(index + 1). \"\(window.window_title)\"")
|
||||
for (index, window) in data.windows.enumerated() {
|
||||
print("[\(index)] \(window.window_title)")
|
||||
|
||||
if let bounds = window.bounds {
|
||||
print(" Position: (\(bounds.x_coordinate), \(bounds.y_coordinate))")
|
||||
print(" Size: \(bounds.width) × \(bounds.height)")
|
||||
}
|
||||
|
||||
if let windowId = window.window_id {
|
||||
print(" Window ID: \(windowId)")
|
||||
print(" Window ID: \(windowId)")
|
||||
}
|
||||
|
||||
if let isOnScreen = window.is_on_screen {
|
||||
print(" On Screen: \(isOnScreen ? "Yes" : "No")")
|
||||
print(" On Screen: \(isOnScreen ? "Yes" : "No")")
|
||||
}
|
||||
|
||||
if let bounds = window.bounds {
|
||||
print(" Bounds: (\(bounds.x_coordinate), \(bounds.y_coordinate)) \(bounds.width)×\(bounds.height)")
|
||||
if index < data.windows.count - 1 {
|
||||
print()
|
||||
}
|
||||
|
||||
print()
|
||||
}
|
||||
|
||||
print("\nTotal: \(data.windows.count) windows")
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerStatusSubcommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "server_status",
|
||||
abstract: "Check server permissions status"
|
||||
commandName: "server-status",
|
||||
abstract: "Show platform and capability status"
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Output results in JSON format")
|
||||
@ -263,31 +265,50 @@ struct ServerStatusSubcommand: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
Logger.shared.setJsonOutputMode(jsonOutput)
|
||||
|
||||
let screenRecording = PermissionsChecker.checkScreenRecordingPermission()
|
||||
let accessibility = PermissionsChecker.checkAccessibilityPermission()
|
||||
|
||||
let permissions = ServerPermissions(
|
||||
screen_recording: screenRecording,
|
||||
accessibility: accessibility
|
||||
)
|
||||
|
||||
let data = ServerStatusData(permissions: permissions)
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
|
||||
let screenRecordingPermission = await permissionsChecker.hasScreenRecordingPermission()
|
||||
let accessibilityPermission = await permissionsChecker.hasAccessibilityPermission()
|
||||
|
||||
if jsonOutput {
|
||||
outputSuccess(data: data)
|
||||
let status = [
|
||||
"platform": PlatformFactory.currentPlatform,
|
||||
"supported": PlatformFactory.isSupported,
|
||||
"capabilities": [
|
||||
"screen_capture": capabilities.screenCapture,
|
||||
"window_management": capabilities.windowManagement,
|
||||
"application_finding": capabilities.applicationFinding,
|
||||
"permissions": capabilities.permissions
|
||||
],
|
||||
"permissions": [
|
||||
"screen_recording": screenRecordingPermission,
|
||||
"accessibility": accessibilityPermission
|
||||
]
|
||||
] as [String: Any]
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: status, options: .prettyPrinted)
|
||||
print(String(data: jsonData, encoding: .utf8) ?? "{}")
|
||||
} else {
|
||||
print("Server Permissions Status:")
|
||||
print(" Screen Recording: \(screenRecording ? "✅ Granted" : "❌ Not granted")")
|
||||
print(" Accessibility: \(accessibility ? "✅ Granted" : "❌ Not granted")")
|
||||
print("🌍 Platform: \(PlatformFactory.currentPlatform)")
|
||||
print("✅ Supported: \(PlatformFactory.isSupported ? "Yes" : "No")")
|
||||
print()
|
||||
print("📋 Capabilities:")
|
||||
print(" Screen Capture: \(capabilities.screenCapture ? "✅" : "❌")")
|
||||
print(" Window Management: \(capabilities.windowManagement ? "✅" : "❌")")
|
||||
print(" Application Finding: \(capabilities.applicationFinding ? "✅" : "❌")")
|
||||
print(" Permissions: \(capabilities.permissions ? "✅" : "❌")")
|
||||
print()
|
||||
print("🔐 Permissions:")
|
||||
print(" Screen Recording: \(screenRecordingPermission ? "✅ Granted" : "❌ Required")")
|
||||
print(" Accessibility: \(accessibilityPermission ? "✅ Granted" : "❌ Required")")
|
||||
|
||||
if !screenRecordingPermission || !accessibilityPermission {
|
||||
print()
|
||||
print("ℹ️ Permission Instructions:")
|
||||
print(permissionsChecker.getPermissionInstructions())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerPermissions: Codable {
|
||||
let screen_recording: Bool
|
||||
let accessibility: Bool
|
||||
}
|
||||
|
||||
struct ServerStatusData: Codable {
|
||||
let permissions: ServerPermissions
|
||||
}
|
||||
|
||||
@ -21,17 +21,29 @@ enum CaptureMode: String, CaseIterable, ExpressibleByArgument, Sendable {
|
||||
case window
|
||||
case multi
|
||||
case frontmost
|
||||
|
||||
init?(argument: String) {
|
||||
self.init(rawValue: argument)
|
||||
}
|
||||
}
|
||||
|
||||
enum ImageFormat: String, CaseIterable, ExpressibleByArgument, Sendable {
|
||||
case png
|
||||
case jpg
|
||||
|
||||
init?(argument: String) {
|
||||
self.init(rawValue: argument)
|
||||
}
|
||||
}
|
||||
|
||||
enum CaptureFocus: String, CaseIterable, ExpressibleByArgument, Sendable {
|
||||
case background
|
||||
case auto
|
||||
case foreground
|
||||
|
||||
init?(argument: String) {
|
||||
self.init(rawValue: argument)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Application & Window Models
|
||||
@ -123,6 +135,9 @@ enum CaptureError: Error, LocalizedError, Sendable {
|
||||
case invalidArgument(String)
|
||||
case unknownError(String)
|
||||
case noWindowsFound(String)
|
||||
// Cross-platform errors
|
||||
case platformNotSupported(String)
|
||||
case featureNotSupported(String, String) // feature, platform
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@ -188,6 +203,10 @@ enum CaptureError: Error, LocalizedError, Sendable {
|
||||
return "An unexpected error occurred: \(message)"
|
||||
case let .noWindowsFound(appName):
|
||||
return "The '\(appName)' process is running, but no capturable windows were found."
|
||||
case let .platformNotSupported(platform):
|
||||
return "Peekaboo is not supported on \(platform). Supported platforms: macOS, Windows, Linux."
|
||||
case let .featureNotSupported(feature, platform):
|
||||
return "\(feature) is not supported on \(platform)."
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,6 +226,8 @@ enum CaptureError: Error, LocalizedError, Sendable {
|
||||
case .invalidArgument: 20
|
||||
case .unknownError: 1
|
||||
case .noWindowsFound: 7
|
||||
case .platformNotSupported: 22
|
||||
case .featureNotSupported: 23
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
import CoreGraphics
|
||||
|
||||
#if os(macOS)
|
||||
import AVFoundation
|
||||
import ScreenCaptureKit
|
||||
#endif
|
||||
|
||||
// Legacy PermissionsChecker class for backward compatibility
|
||||
// New code should use PlatformFactory.createPermissionsChecker()
|
||||
final class PermissionsChecker: Sendable {
|
||||
#if os(macOS)
|
||||
static func checkScreenRecordingPermission() -> Bool {
|
||||
// Use a simpler approach - check CGWindowListCreateImage which doesn't require async
|
||||
// This is the traditional way to check screen recording permission
|
||||
@ -29,4 +35,79 @@ final class PermissionsChecker: Sendable {
|
||||
throw CaptureError.accessibilityPermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
static func requestScreenRecordingPermission() async -> Bool {
|
||||
// On macOS 14+, we can use ScreenCaptureKit to request permission
|
||||
if #available(macOS 14.0, *) {
|
||||
do {
|
||||
// This will prompt for permission if not already granted
|
||||
_ = try await SCShareableContent.current
|
||||
return checkScreenRecordingPermission()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// For older macOS versions, we can't programmatically request permission
|
||||
return checkScreenRecordingPermission()
|
||||
}
|
||||
}
|
||||
|
||||
static func requestAccessibilityPermission() -> Bool {
|
||||
// This will show the system prompt if permission is not granted
|
||||
let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
#else
|
||||
// Non-macOS platforms - use platform factory
|
||||
static func checkScreenRecordingPermission() async -> Bool {
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
return await permissionsChecker.hasScreenRecordingPermission()
|
||||
}
|
||||
|
||||
static func checkAccessibilityPermission() async -> Bool {
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
return await permissionsChecker.hasAccessibilityPermission()
|
||||
}
|
||||
|
||||
static func requireScreenRecordingPermission() async throws {
|
||||
if !(await checkScreenRecordingPermission()) {
|
||||
throw CaptureError.screenRecordingPermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
static func requireAccessibilityPermission() async throws {
|
||||
if !(await checkAccessibilityPermission()) {
|
||||
throw CaptureError.accessibilityPermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
static func requestScreenRecordingPermission() async -> Bool {
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
return await permissionsChecker.requestScreenRecordingPermission()
|
||||
}
|
||||
|
||||
static func requestAccessibilityPermission() async -> Bool {
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
return await permissionsChecker.requestAccessibilityPermission()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Permission error detection
|
||||
struct PermissionErrorDetector: Sendable {
|
||||
#if os(macOS)
|
||||
static func isScreenRecordingPermissionError(_ error: Error) -> Bool {
|
||||
let errorString = error.localizedDescription.lowercased()
|
||||
return errorString.contains("screen recording") ||
|
||||
errorString.contains("permission") ||
|
||||
errorString.contains("not authorized") ||
|
||||
errorString.contains("access denied")
|
||||
}
|
||||
#else
|
||||
static func isScreenRecordingPermissionError(_ error: Error) -> Bool {
|
||||
// Platform-specific permission error detection would go here
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
142
peekaboo-cli/Sources/peekaboo/PlatformFactory.swift
Normal file
142
peekaboo-cli/Sources/peekaboo/PlatformFactory.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// Factory for creating platform-specific implementations
|
||||
struct PlatformFactory: Sendable {
|
||||
|
||||
/// Creates a screen capture implementation for the current platform
|
||||
static func createScreenCapture() -> any ScreenCaptureProtocol {
|
||||
#if os(macOS)
|
||||
return macOSScreenCapture()
|
||||
#elseif os(Windows)
|
||||
return WindowsScreenCapture()
|
||||
#elseif os(Linux)
|
||||
return LinuxScreenCapture()
|
||||
#else
|
||||
fatalError("Unsupported platform for screen capture")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates a window manager implementation for the current platform
|
||||
static func createWindowManager() -> any WindowManagerProtocol {
|
||||
#if os(macOS)
|
||||
return macOSWindowManager()
|
||||
#elseif os(Windows)
|
||||
return WindowsWindowManager()
|
||||
#elseif os(Linux)
|
||||
return LinuxWindowManager()
|
||||
#else
|
||||
fatalError("Unsupported platform for window management")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates an application finder implementation for the current platform
|
||||
static func createApplicationFinder() -> any ApplicationFinderProtocol {
|
||||
#if os(macOS)
|
||||
return macOSApplicationFinder()
|
||||
#elseif os(Windows)
|
||||
return WindowsApplicationFinder()
|
||||
#elseif os(Linux)
|
||||
return LinuxApplicationFinder()
|
||||
#else
|
||||
fatalError("Unsupported platform for application finding")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates a permissions checker implementation for the current platform
|
||||
static func createPermissionsChecker() -> any PermissionsProtocol {
|
||||
#if os(macOS)
|
||||
return macOSPermissions()
|
||||
#elseif os(Windows)
|
||||
return WindowsPermissions()
|
||||
#elseif os(Linux)
|
||||
return LinuxPermissions()
|
||||
#else
|
||||
fatalError("Unsupported platform for permissions")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Gets the current platform name
|
||||
static var currentPlatform: String {
|
||||
#if os(macOS)
|
||||
return "macOS"
|
||||
#elseif os(Windows)
|
||||
return "Windows"
|
||||
#elseif os(Linux)
|
||||
return "Linux"
|
||||
#else
|
||||
return "Unknown"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Checks if the current platform is supported
|
||||
static var isSupported: Bool {
|
||||
#if os(macOS) || os(Windows) || os(Linux)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Gets platform capabilities
|
||||
static var capabilities: PlatformCapabilities {
|
||||
return PlatformCapabilities(
|
||||
screenCapture: {
|
||||
#if os(macOS)
|
||||
return macOSScreenCapture.isSupported()
|
||||
#elseif os(Windows)
|
||||
return WindowsScreenCapture.isSupported()
|
||||
#elseif os(Linux)
|
||||
return LinuxScreenCapture.isSupported()
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}(),
|
||||
windowManagement: {
|
||||
#if os(macOS)
|
||||
return macOSWindowManager.isSupported()
|
||||
#elseif os(Windows)
|
||||
return WindowsWindowManager.isSupported()
|
||||
#elseif os(Linux)
|
||||
return LinuxWindowManager.isSupported()
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}(),
|
||||
applicationFinding: {
|
||||
#if os(macOS)
|
||||
return macOSApplicationFinder.isSupported()
|
||||
#elseif os(Windows)
|
||||
return WindowsApplicationFinder.isSupported()
|
||||
#elseif os(Linux)
|
||||
return LinuxApplicationFinder.isSupported()
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}(),
|
||||
permissions: {
|
||||
#if os(macOS)
|
||||
return macOSPermissions.isSupported()
|
||||
#elseif os(Windows)
|
||||
return WindowsPermissions.isSupported()
|
||||
#elseif os(Linux)
|
||||
return LinuxPermissions.isSupported()
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform capabilities structure
|
||||
struct PlatformCapabilities: Sendable, Codable {
|
||||
let screenCapture: Bool
|
||||
let windowManagement: Bool
|
||||
let applicationFinding: Bool
|
||||
let permissions: Bool
|
||||
|
||||
var isFullySupported: Bool {
|
||||
return screenCapture && windowManagement && applicationFinding && permissions
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
#if os(Linux)
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Linux implementation of application finding using /proc filesystem
|
||||
struct LinuxApplicationFinder: ApplicationFinderProtocol {
|
||||
|
||||
func findApplications(matching query: String) async throws -> [ApplicationInfo] {
|
||||
let runningApps = try await getRunningApplications()
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
return runningApps.filter { app in
|
||||
app.name.lowercased().contains(lowercaseQuery) ||
|
||||
app.executablePath?.lowercased().contains(lowercaseQuery) == true ||
|
||||
app.id.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
func getRunningApplications() async throws -> [ApplicationInfo] {
|
||||
var applications: [ApplicationInfo] = []
|
||||
|
||||
// Read /proc directory to get all running processes
|
||||
let procURL = URL(fileURLWithPath: "/proc")
|
||||
|
||||
do {
|
||||
let procContents = try FileManager.default.contentsOfDirectory(at: procURL, includingPropertiesForKeys: nil)
|
||||
|
||||
for procDir in procContents {
|
||||
// Check if directory name is a number (process ID)
|
||||
guard let processId = Int(procDir.lastPathComponent) else { continue }
|
||||
|
||||
if let appInfo = try? getApplicationInfo(processId: processId) {
|
||||
applications.append(appInfo)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw LinuxApplicationFinderError.failedToReadProcDirectory
|
||||
}
|
||||
|
||||
return applications
|
||||
}
|
||||
|
||||
func getApplication(by identifier: String) async throws -> ApplicationInfo? {
|
||||
// Try to parse as process ID
|
||||
if let processId = Int(identifier) {
|
||||
return try? getApplicationInfo(processId: processId)
|
||||
}
|
||||
|
||||
// Search by name
|
||||
let runningApps = try await getRunningApplications()
|
||||
return runningApps.first { app in
|
||||
app.name.lowercased() == identifier.lowercased() ||
|
||||
app.executablePath?.lowercased().contains(identifier.lowercased()) == true
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return FileManager.default.fileExists(atPath: "/proc")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func getApplicationInfo(processId: Int) throws -> ApplicationInfo {
|
||||
let procPath = "/proc/\\(processId)"
|
||||
|
||||
// Check if process still exists
|
||||
guard FileManager.default.fileExists(atPath: procPath) else {
|
||||
throw LinuxApplicationFinderError.processNotFound(processId)
|
||||
}
|
||||
|
||||
// Get process name from /proc/PID/comm
|
||||
var processName = "Unknown"
|
||||
let commPath = "\\(procPath)/comm"
|
||||
if let comm = try? String(contentsOfFile: commPath, encoding: .utf8) {
|
||||
processName = comm.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// Get executable path from /proc/PID/exe
|
||||
var executablePath: String?
|
||||
let exePath = "\\(procPath)/exe"
|
||||
if let resolvedPath = try? FileManager.default.destinationOfSymbolicLink(atPath: exePath) {
|
||||
executablePath = resolvedPath
|
||||
|
||||
// If we couldn't get the name from comm, try to extract it from the executable path
|
||||
if processName == "Unknown" {
|
||||
processName = URL(fileURLWithPath: resolvedPath).lastPathComponent
|
||||
}
|
||||
}
|
||||
|
||||
// Get command line arguments from /proc/PID/cmdline
|
||||
let cmdlinePath = "\\(procPath)/cmdline"
|
||||
if let cmdlineData = try? Data(contentsOf: URL(fileURLWithPath: cmdlinePath)) {
|
||||
let cmdlineString = String(data: cmdlineData, encoding: .utf8) ?? ""
|
||||
let arguments = cmdlineString.components(separatedBy: "\\0").filter { !$0.isEmpty }
|
||||
|
||||
if let firstArg = arguments.first, executablePath == nil {
|
||||
executablePath = firstArg
|
||||
if processName == "Unknown" {
|
||||
processName = URL(fileURLWithPath: firstArg).lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ApplicationInfo(
|
||||
id: String(processId),
|
||||
name: processName,
|
||||
bundleIdentifier: nil, // Linux doesn't have bundle identifiers
|
||||
executablePath: executablePath,
|
||||
isRunning: true,
|
||||
processId: processId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum LinuxApplicationFinderError: Error, LocalizedError {
|
||||
case failedToReadProcDirectory
|
||||
case processNotFound(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToReadProcDirectory:
|
||||
return "Failed to read /proc directory"
|
||||
case .processNotFound(let processId):
|
||||
return "Process not found: \\(processId)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,279 @@
|
||||
#if os(Linux)
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Linux implementation of permissions checking
|
||||
struct LinuxPermissions: PermissionsProtocol {
|
||||
|
||||
private let isWayland: Bool
|
||||
|
||||
init() {
|
||||
// Detect if we're running under Wayland
|
||||
self.isWayland = ProcessInfo.processInfo.environment["WAYLAND_DISPLAY"] != nil ||
|
||||
ProcessInfo.processInfo.environment["XDG_SESSION_TYPE"] == "wayland"
|
||||
}
|
||||
|
||||
func hasScreenRecordingPermission() async -> Bool {
|
||||
if isWayland {
|
||||
return await hasWaylandScreenRecordingPermission()
|
||||
} else {
|
||||
return await hasX11ScreenRecordingPermission()
|
||||
}
|
||||
}
|
||||
|
||||
func requestScreenRecordingPermission() async -> Bool {
|
||||
// On Linux, permissions are typically handled by the desktop environment
|
||||
// We can't programmatically request permissions, but we can check if they're available
|
||||
return await hasScreenRecordingPermission()
|
||||
}
|
||||
|
||||
func hasAccessibilityPermission() async -> Bool {
|
||||
if isWayland {
|
||||
return await hasWaylandAccessibilityPermission()
|
||||
} else {
|
||||
return await hasX11AccessibilityPermission()
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccessibilityPermission() async -> Bool {
|
||||
// Similar to screen recording, we can't programmatically request permissions
|
||||
return await hasAccessibilityPermission()
|
||||
}
|
||||
|
||||
func getPermissionInstructions() -> String {
|
||||
if isWayland {
|
||||
return getWaylandInstructions()
|
||||
} else {
|
||||
return getX11Instructions()
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // Linux always supports permission checking
|
||||
}
|
||||
|
||||
// MARK: - X11 Permission Checking
|
||||
|
||||
private func hasX11ScreenRecordingPermission() async -> Bool {
|
||||
// Check if we can access the X11 display
|
||||
guard ProcessInfo.processInfo.environment["DISPLAY"] != nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Test if we can capture a screenshot using import or scrot
|
||||
if commandExists("import") {
|
||||
return testImageMagickCapture()
|
||||
} else if commandExists("scrot") {
|
||||
return testScrotCapture()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func hasX11AccessibilityPermission() async -> Bool {
|
||||
// Check if we can enumerate windows using wmctrl or xwininfo
|
||||
if commandExists("wmctrl") {
|
||||
return testWmctrlAccess()
|
||||
} else if commandExists("xwininfo") {
|
||||
return testXwininfoAccess()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func testImageMagickCapture() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["import", "-window", "root", "-resize", "1x1", "/dev/null"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func testScrotCapture() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["scrot", "--silent", "/dev/null"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func testWmctrlAccess() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["wmctrl", "-l"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func testXwininfoAccess() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["xwininfo", "-root", "-tree"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wayland Permission Checking
|
||||
|
||||
private func hasWaylandScreenRecordingPermission() async -> Bool {
|
||||
// Check if we have access to screen capture tools
|
||||
if commandExists("grim") {
|
||||
return testGrimCapture()
|
||||
} else if commandExists("gnome-screenshot") {
|
||||
return testGnomeScreenshotCapture()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func hasWaylandAccessibilityPermission() async -> Bool {
|
||||
// Check if we can access window information
|
||||
if commandExists("swaymsg") {
|
||||
return testSwaymsgAccess()
|
||||
}
|
||||
|
||||
// For other Wayland compositors, this might be more limited
|
||||
return false
|
||||
}
|
||||
|
||||
private func testGrimCapture() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["grim", "-g", "1,1 1x1", "/dev/null"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func testGnomeScreenshotCapture() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["gnome-screenshot", "--file=/dev/null"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func testSwaymsgAccess() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["swaymsg", "-t", "get_outputs"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instructions
|
||||
|
||||
private func getX11Instructions() -> String {
|
||||
return \"\"\"
|
||||
To use Peekaboo on X11 Linux, you need the following tools installed:
|
||||
|
||||
For screen capture:
|
||||
- ImageMagick (import command): sudo apt install imagemagick
|
||||
- OR scrot: sudo apt install scrot
|
||||
|
||||
For window management:
|
||||
- wmctrl: sudo apt install wmctrl
|
||||
- OR xwininfo (usually pre-installed with X11)
|
||||
|
||||
Make sure your DISPLAY environment variable is set correctly.
|
||||
|
||||
If you're using a display manager or desktop environment with additional
|
||||
security restrictions, you may need to adjust your settings.
|
||||
\"\"\"
|
||||
}
|
||||
|
||||
private func getWaylandInstructions() -> String {
|
||||
return \"\"\"
|
||||
To use Peekaboo on Wayland Linux, you need the following tools installed:
|
||||
|
||||
For screen capture:
|
||||
- grim: sudo apt install grim (for wlroots-based compositors like Sway)
|
||||
- OR gnome-screenshot: sudo apt install gnome-screenshot (for GNOME)
|
||||
|
||||
For window management:
|
||||
- swaymsg (for Sway): usually included with Sway
|
||||
|
||||
Note: Wayland has stricter security policies. Some desktop environments
|
||||
may require additional permissions or may not support all features.
|
||||
|
||||
For GNOME Wayland, you may need to use the built-in screenshot portal
|
||||
or grant additional permissions through your desktop environment settings.
|
||||
\"\"\"
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func commandExists(_ command: String) -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
||||
process.arguments = [command]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
#if os(Linux)
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Linux implementation of screen capture using X11 and Wayland
|
||||
struct LinuxScreenCapture: ScreenCaptureProtocol {
|
||||
|
||||
private let isWayland: Bool
|
||||
|
||||
init() {
|
||||
// Detect if we're running under Wayland
|
||||
self.isWayland = ProcessInfo.processInfo.environment["WAYLAND_DISPLAY"] != nil ||
|
||||
ProcessInfo.processInfo.environment["XDG_SESSION_TYPE"] == "wayland"
|
||||
}
|
||||
|
||||
func captureScreen(screenIndex: Int) async throws -> Data {
|
||||
let screens = try await getAvailableScreens()
|
||||
guard screenIndex < screens.count else {
|
||||
throw LinuxScreenCaptureError.invalidScreenIndex(screenIndex)
|
||||
}
|
||||
|
||||
let screen = screens[screenIndex]
|
||||
|
||||
if isWayland {
|
||||
return try await captureScreenWayland(screen: screen)
|
||||
} else {
|
||||
return try await captureScreenX11(screen: screen)
|
||||
}
|
||||
}
|
||||
|
||||
func captureWindow(windowId: String, bounds: CGRect?) async throws -> Data {
|
||||
if isWayland {
|
||||
return try await captureWindowWayland(windowId: windowId, bounds: bounds)
|
||||
} else {
|
||||
return try await captureWindowX11(windowId: windowId, bounds: bounds)
|
||||
}
|
||||
}
|
||||
|
||||
func getAvailableScreens() async throws -> [ScreenInfo] {
|
||||
if isWayland {
|
||||
return try await getScreensWayland()
|
||||
} else {
|
||||
return try await getScreensX11()
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
// Check if we have the necessary tools available
|
||||
return commandExists("xrandr") || commandExists("grim")
|
||||
}
|
||||
|
||||
// MARK: - X11 Implementation
|
||||
|
||||
private func captureScreenX11(screen: ScreenInfo) async throws -> Data {
|
||||
let command = [
|
||||
"import",
|
||||
"-window", "root",
|
||||
"-crop", "\\(Int(screen.bounds.width))x\\(Int(screen.bounds.height))+\\(Int(screen.bounds.minX))+\\(Int(screen.bounds.minY))",
|
||||
"png:-"
|
||||
]
|
||||
|
||||
return try await runCommand(command)
|
||||
}
|
||||
|
||||
private func captureWindowX11(windowId: String, bounds: CGRect?) async throws -> Data {
|
||||
var command = ["import", "-window", windowId, "png:-"]
|
||||
|
||||
if let bounds = bounds {
|
||||
command.insert("-crop", at: 2)
|
||||
command.insert("\\(Int(bounds.width))x\\(Int(bounds.height))+\\(Int(bounds.minX))+\\(Int(bounds.minY))", at: 3)
|
||||
}
|
||||
|
||||
return try await runCommand(command)
|
||||
}
|
||||
|
||||
private func getScreensX11() async throws -> [ScreenInfo] {
|
||||
let output = try await runCommandString(["xrandr", "--query"])
|
||||
return parseXrandrOutput(output)
|
||||
}
|
||||
|
||||
// MARK: - Wayland Implementation
|
||||
|
||||
private func captureScreenWayland(screen: ScreenInfo) async throws -> Data {
|
||||
let geometry = "\\(Int(screen.bounds.minX)),\\(Int(screen.bounds.minY)) \\(Int(screen.bounds.width))x\\(Int(screen.bounds.height))"
|
||||
let command = ["grim", "-g", geometry, "-"]
|
||||
|
||||
return try await runCommand(command)
|
||||
}
|
||||
|
||||
private func captureWindowWayland(windowId: String, bounds: CGRect?) async throws -> Data {
|
||||
// For Wayland, we need to use swaymsg to get window geometry
|
||||
if let bounds = bounds {
|
||||
let geometry = "\\(Int(bounds.minX)),\\(Int(bounds.minY)) \\(Int(bounds.width))x\\(Int(bounds.height))"
|
||||
return try await runCommand(["grim", "-g", geometry, "-"])
|
||||
} else {
|
||||
// Try to get window geometry from sway
|
||||
let windowInfo = try await runCommandString(["swaymsg", "-t", "get_tree"])
|
||||
// Parse window info and extract geometry
|
||||
// This is simplified - real implementation would parse JSON
|
||||
return try await runCommand(["grim", "-"])
|
||||
}
|
||||
}
|
||||
|
||||
private func getScreensWayland() async throws -> [ScreenInfo] {
|
||||
let output = try await runCommandString(["swaymsg", "-t", "get_outputs"])
|
||||
return parseSwayOutputs(output)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func runCommand(_ command: [String]) async throws -> Data {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = command
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw LinuxScreenCaptureError.commandFailed(command.joined(separator: " "))
|
||||
}
|
||||
|
||||
return pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}
|
||||
|
||||
private func runCommandString(_ command: [String]) async throws -> String {
|
||||
let data = try await runCommand(command)
|
||||
guard let output = String(data: data, encoding: .utf8) else {
|
||||
throw LinuxScreenCaptureError.invalidCommandOutput
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func parseXrandrOutput(_ output: String) -> [ScreenInfo] {
|
||||
var screens: [ScreenInfo] = []
|
||||
let lines = output.components(separatedBy: .newlines)
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
if line.contains(" connected") {
|
||||
let components = line.components(separatedBy: " ")
|
||||
if let geometryIndex = components.firstIndex(where: { $0.contains("x") && $0.contains("+") }) {
|
||||
let geometry = components[geometryIndex]
|
||||
if let bounds = parseGeometry(geometry) {
|
||||
let name = components[0]
|
||||
let isPrimary = line.contains("primary")
|
||||
|
||||
let screenInfo = ScreenInfo(
|
||||
index: index,
|
||||
bounds: bounds,
|
||||
name: name,
|
||||
isPrimary: isPrimary
|
||||
)
|
||||
screens.append(screenInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return screens
|
||||
}
|
||||
|
||||
private func parseSwayOutputs(_ output: String) -> [ScreenInfo] {
|
||||
// This would parse JSON output from swaymsg
|
||||
// Simplified implementation
|
||||
return [
|
||||
ScreenInfo(
|
||||
index: 0,
|
||||
bounds: CGRect(x: 0, y: 0, width: 1920, height: 1080),
|
||||
name: "Default",
|
||||
isPrimary: true
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func parseGeometry(_ geometry: String) -> CGRect? {
|
||||
// Parse format like "1920x1080+0+0"
|
||||
let pattern = #"(\\d+)x(\\d+)\\+(\\d+)\\+(\\d+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: geometry, range: NSRange(geometry.startIndex..., in: geometry)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let width = Int(String(geometry[Range(match.range(at: 1), in: geometry)!])) ?? 0
|
||||
let height = Int(String(geometry[Range(match.range(at: 2), in: geometry)!])) ?? 0
|
||||
let x = Int(String(geometry[Range(match.range(at: 3), in: geometry)!])) ?? 0
|
||||
let y = Int(String(geometry[Range(match.range(at: 4), in: geometry)!])) ?? 0
|
||||
|
||||
return CGRect(x: CGFloat(x), y: CGFloat(y), width: CGFloat(width), height: CGFloat(height))
|
||||
}
|
||||
|
||||
private static func commandExists(_ command: String) -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
||||
process.arguments = [command]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum LinuxScreenCaptureError: Error, LocalizedError {
|
||||
case invalidScreenIndex(Int)
|
||||
case invalidWindowId(String)
|
||||
case commandFailed(String)
|
||||
case invalidCommandOutput
|
||||
case waylandNotSupported
|
||||
case x11NotSupported
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidScreenIndex(let index):
|
||||
return "Invalid screen index: \\(index)"
|
||||
case .invalidWindowId(let id):
|
||||
return "Invalid window ID: \\(id)"
|
||||
case .commandFailed(let command):
|
||||
return "Command failed: \\(command)"
|
||||
case .invalidCommandOutput:
|
||||
return "Invalid command output"
|
||||
case .waylandNotSupported:
|
||||
return "Wayland screen capture not supported"
|
||||
case .x11NotSupported:
|
||||
return "X11 screen capture not supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
#if os(Linux)
|
||||
import Foundation
|
||||
|
||||
/// Linux implementation of window management
|
||||
/// Currently simplified - full X11/Wayland implementation would require additional dependencies
|
||||
struct LinuxWindowManager: WindowManagerProtocol {
|
||||
|
||||
private let isWayland: Bool
|
||||
|
||||
init() {
|
||||
// Detect if we're running under Wayland
|
||||
self.isWayland = ProcessInfo.processInfo.environment["WAYLAND_DISPLAY"] != nil ||
|
||||
ProcessInfo.processInfo.environment["XDG_SESSION_TYPE"] == "wayland"
|
||||
}
|
||||
|
||||
func getWindows(for applicationId: String) async throws -> [PlatformWindowInfo] {
|
||||
// For now, return empty array as Linux window management is complex
|
||||
// TODO: Implement X11/Wayland window enumeration
|
||||
return []
|
||||
}
|
||||
|
||||
func getAllWindows() async throws -> [PlatformWindowInfo] {
|
||||
// For now, return empty array as Linux window management is complex
|
||||
// TODO: Implement X11/Wayland window enumeration
|
||||
return []
|
||||
}
|
||||
|
||||
func getWindow(by windowId: String) async throws -> PlatformWindowInfo? {
|
||||
// For now, return nil as Linux window management is complex
|
||||
// TODO: Implement X11/Wayland window lookup
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
// Window management is theoretically supported on Linux but requires
|
||||
// additional dependencies (X11 or Wayland libraries)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
#if os(Windows)
|
||||
import Foundation
|
||||
import WinSDK
|
||||
|
||||
/// Windows implementation of application finding using Win32 APIs
|
||||
struct WindowsApplicationFinder: ApplicationFinderProtocol {
|
||||
|
||||
func findApplications(matching query: String) async throws -> [ApplicationInfo] {
|
||||
let runningApps = try await getRunningApplications()
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
return runningApps.filter { app in
|
||||
app.name.lowercased().contains(lowercaseQuery) ||
|
||||
app.executablePath?.lowercased().contains(lowercaseQuery) == true ||
|
||||
app.id.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
func getRunningApplications() async throws -> [ApplicationInfo] {
|
||||
var applications: [ApplicationInfo] = []
|
||||
|
||||
// Get list of all processes
|
||||
var processIds = Array<DWORD>(repeating: 0, count: 1024)
|
||||
var bytesReturned: DWORD = 0
|
||||
|
||||
guard EnumProcesses(&processIds, DWORD(processIds.count * MemoryLayout<DWORD>.size), &bytesReturned) != 0 else {
|
||||
throw WindowsApplicationFinderError.failedToEnumerateProcesses
|
||||
}
|
||||
|
||||
let processCount = Int(bytesReturned) / MemoryLayout<DWORD>.size
|
||||
|
||||
for i in 0..<processCount {
|
||||
let processId = processIds[i]
|
||||
|
||||
// Skip system idle process
|
||||
guard processId != 0 else { continue }
|
||||
|
||||
if let appInfo = try? getApplicationInfo(processId: processId) {
|
||||
applications.append(appInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return applications
|
||||
}
|
||||
|
||||
func getApplication(by identifier: String) async throws -> ApplicationInfo? {
|
||||
// Try to parse as process ID
|
||||
if let processId = DWORD(identifier) {
|
||||
return try? getApplicationInfo(processId: processId)
|
||||
}
|
||||
|
||||
// Search by name
|
||||
let runningApps = try await getRunningApplications()
|
||||
return runningApps.first { app in
|
||||
app.name.lowercased() == identifier.lowercased() ||
|
||||
app.executablePath?.lowercased().contains(identifier.lowercased()) == true
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // Windows always supports application finding
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func getApplicationInfo(processId: DWORD) throws -> ApplicationInfo {
|
||||
let processHandle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processId)
|
||||
guard let processHandle = processHandle else {
|
||||
throw WindowsApplicationFinderError.failedToOpenProcess(processId)
|
||||
}
|
||||
defer { CloseHandle(processHandle) }
|
||||
|
||||
// Get process executable path
|
||||
var executablePath: String?
|
||||
var pathBuffer = Array<WCHAR>(repeating: 0, count: MAX_PATH)
|
||||
var pathSize = DWORD(MAX_PATH)
|
||||
|
||||
if QueryFullProcessImageNameW(processHandle, 0, &pathBuffer, &pathSize) != 0 {
|
||||
executablePath = String(decodingCString: pathBuffer, as: UTF16.self)
|
||||
}
|
||||
|
||||
// Get process name from executable path
|
||||
var processName = "Unknown"
|
||||
if let path = executablePath {
|
||||
processName = URL(fileURLWithPath: path).lastPathComponent
|
||||
if processName.hasSuffix(".exe") {
|
||||
processName = String(processName.dropLast(4))
|
||||
}
|
||||
} else {
|
||||
// Fallback: get module name
|
||||
var moduleHandle: HMODULE = HMODULE(bitPattern: 0)!
|
||||
var needed: DWORD = 0
|
||||
|
||||
if EnumProcessModules(processHandle, &moduleHandle, DWORD(MemoryLayout<HMODULE>.size), &needed) != 0 {
|
||||
var moduleNameBuffer = Array<WCHAR>(repeating: 0, count: MAX_PATH)
|
||||
if GetModuleBaseNameW(processHandle, moduleHandle, &moduleNameBuffer, DWORD(MAX_PATH)) != 0 {
|
||||
let moduleName = String(decodingCString: moduleNameBuffer, as: UTF16.self)
|
||||
if moduleName.hasSuffix(".exe") {
|
||||
processName = String(moduleName.dropLast(4))
|
||||
} else {
|
||||
processName = moduleName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ApplicationInfo(
|
||||
id: String(processId),
|
||||
name: processName,
|
||||
bundleIdentifier: nil, // Windows doesn't have bundle identifiers
|
||||
executablePath: executablePath,
|
||||
isRunning: true,
|
||||
processId: Int(processId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum WindowsApplicationFinderError: Error, LocalizedError {
|
||||
case failedToEnumerateProcesses
|
||||
case failedToOpenProcess(DWORD)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToEnumerateProcesses:
|
||||
return "Failed to enumerate running processes"
|
||||
case .failedToOpenProcess(let processId):
|
||||
return "Failed to open process with ID: \\(processId)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
#if os(Windows)
|
||||
import Foundation
|
||||
import WinSDK
|
||||
|
||||
/// Windows implementation of permissions checking
|
||||
struct WindowsPermissions: PermissionsProtocol {
|
||||
|
||||
func hasScreenRecordingPermission() async -> Bool {
|
||||
// On Windows, screen recording is generally available without special permissions
|
||||
// However, we should check if we can actually capture the screen
|
||||
let screenDC = GetDC(nil)
|
||||
guard screenDC != nil else { return false }
|
||||
|
||||
ReleaseDC(nil, screenDC)
|
||||
return true
|
||||
}
|
||||
|
||||
func requestScreenRecordingPermission() async -> Bool {
|
||||
// Windows doesn't require explicit permission for screen recording
|
||||
return await hasScreenRecordingPermission()
|
||||
}
|
||||
|
||||
func hasAccessibilityPermission() async -> Bool {
|
||||
// On Windows, window enumeration is generally available
|
||||
// Test by trying to enumerate windows
|
||||
var hasPermission = false
|
||||
|
||||
let enumProc: WNDENUMPROC = { hwnd, lParam in
|
||||
let hasPermissionPtr = UnsafeMutablePointer<Bool>(bitPattern: UInt(lParam))!
|
||||
hasPermissionPtr.pointee = true
|
||||
return FALSE // Stop enumeration after first window
|
||||
}
|
||||
|
||||
withUnsafeMutablePointer(to: &hasPermission) { hasPermissionPtr in
|
||||
EnumWindows(enumProc, LPARAM(UInt(bitPattern: hasPermissionPtr)))
|
||||
}
|
||||
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
func requestAccessibilityPermission() async -> Bool {
|
||||
// Windows doesn't require explicit permission for window enumeration
|
||||
return await hasAccessibilityPermission()
|
||||
}
|
||||
|
||||
func getPermissionInstructions() -> String {
|
||||
return \"\"\"
|
||||
Peekaboo on Windows generally works without special permissions.
|
||||
|
||||
However, if you encounter issues:
|
||||
|
||||
1. Make sure you're running as an Administrator if capturing elevated applications
|
||||
2. Some antivirus software may block screen capture - check your security settings
|
||||
3. Windows Defender SmartScreen might require approval for the first run
|
||||
|
||||
If you're still having issues, try running from an elevated command prompt.
|
||||
\"\"\"
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // Windows always supports permission checking
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,214 @@
|
||||
#if os(Windows)
|
||||
import Foundation
|
||||
import WinSDK
|
||||
|
||||
/// Windows implementation of screen capture using DXGI Desktop Duplication API and GDI+
|
||||
struct WindowsScreenCapture: ScreenCaptureProtocol {
|
||||
|
||||
func captureScreen(screenIndex: Int) async throws -> Data {
|
||||
let screens = try await getAvailableScreens()
|
||||
guard screenIndex < screens.count else {
|
||||
throw WindowsScreenCaptureError.invalidScreenIndex(screenIndex)
|
||||
}
|
||||
|
||||
let screen = screens[screenIndex]
|
||||
return try captureScreenArea(bounds: screen.bounds)
|
||||
}
|
||||
|
||||
func captureWindow(windowId: String, bounds: CGRect?) async throws -> Data {
|
||||
guard let hwnd = parseWindowHandle(windowId) else {
|
||||
throw WindowsScreenCaptureError.invalidWindowId(windowId)
|
||||
}
|
||||
|
||||
// Get window rectangle
|
||||
var rect = RECT()
|
||||
guard GetWindowRect(hwnd, &rect) != 0 else {
|
||||
throw WindowsScreenCaptureError.windowNotFound(windowId)
|
||||
}
|
||||
|
||||
let windowBounds = CGRect(
|
||||
x: CGFloat(rect.left),
|
||||
y: CGFloat(rect.top),
|
||||
width: CGFloat(rect.right - rect.left),
|
||||
height: CGFloat(rect.bottom - rect.top)
|
||||
)
|
||||
|
||||
return try captureScreenArea(bounds: bounds ?? windowBounds)
|
||||
}
|
||||
|
||||
func getAvailableScreens() async throws -> [ScreenInfo] {
|
||||
var screens: [ScreenInfo] = []
|
||||
var index = 0
|
||||
|
||||
// Enumerate all display devices
|
||||
var displayDevice = DISPLAY_DEVICEW()
|
||||
displayDevice.cb = DWORD(MemoryLayout<DISPLAY_DEVICEW>.size)
|
||||
|
||||
while EnumDisplayDevicesW(nil, DWORD(index), &displayDevice, 0) != 0 {
|
||||
// Get display settings
|
||||
var devMode = DEVMODEW()
|
||||
devMode.dmSize = WORD(MemoryLayout<DEVMODEW>.size)
|
||||
|
||||
if EnumDisplaySettingsW(displayDevice.DeviceName, ENUM_CURRENT_SETTINGS, &devMode) != 0 {
|
||||
let bounds = CGRect(
|
||||
x: CGFloat(devMode.dmPosition.x),
|
||||
y: CGFloat(devMode.dmPosition.y),
|
||||
width: CGFloat(devMode.dmPelsWidth),
|
||||
height: CGFloat(devMode.dmPelsHeight)
|
||||
)
|
||||
|
||||
let deviceName = String(cString: displayDevice.DeviceName)
|
||||
let isPrimary = (displayDevice.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE) != 0
|
||||
|
||||
let screenInfo = ScreenInfo(
|
||||
index: index,
|
||||
bounds: bounds,
|
||||
name: deviceName,
|
||||
isPrimary: isPrimary
|
||||
)
|
||||
|
||||
screens.append(screenInfo)
|
||||
}
|
||||
|
||||
index += 1
|
||||
displayDevice = DISPLAY_DEVICEW()
|
||||
displayDevice.cb = DWORD(MemoryLayout<DISPLAY_DEVICEW>.size)
|
||||
}
|
||||
|
||||
return screens
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // Windows always supports screen capture
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func captureScreenArea(bounds: CGRect) throws -> Data {
|
||||
// Try DXGI Desktop Duplication first (Windows 8+)
|
||||
if let data = try? captureWithDXGI(bounds: bounds) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Fallback to GDI
|
||||
return try captureWithGDI(bounds: bounds)
|
||||
}
|
||||
|
||||
private func captureWithDXGI(bounds: CGRect) throws -> Data {
|
||||
// DXGI Desktop Duplication implementation
|
||||
// This is a simplified version - full implementation would require more DXGI setup
|
||||
throw WindowsScreenCaptureError.dxgiNotAvailable
|
||||
}
|
||||
|
||||
private func captureWithGDI(bounds: CGRect) throws -> Data {
|
||||
let screenDC = GetDC(nil)
|
||||
guard screenDC != nil else {
|
||||
throw WindowsScreenCaptureError.failedToGetDC
|
||||
}
|
||||
defer { ReleaseDC(nil, screenDC) }
|
||||
|
||||
let memoryDC = CreateCompatibleDC(screenDC)
|
||||
guard memoryDC != nil else {
|
||||
throw WindowsScreenCaptureError.failedToCreateCompatibleDC
|
||||
}
|
||||
defer { DeleteDC(memoryDC) }
|
||||
|
||||
let width = Int32(bounds.width)
|
||||
let height = Int32(bounds.height)
|
||||
|
||||
let bitmap = CreateCompatibleBitmap(screenDC, width, height)
|
||||
guard bitmap != nil else {
|
||||
throw WindowsScreenCaptureError.failedToCreateBitmap
|
||||
}
|
||||
defer { DeleteObject(bitmap) }
|
||||
|
||||
let oldBitmap = SelectObject(memoryDC, bitmap)
|
||||
defer { SelectObject(memoryDC, oldBitmap) }
|
||||
|
||||
// Copy screen content to memory DC
|
||||
guard BitBlt(memoryDC, 0, 0, width, height, screenDC, Int32(bounds.minX), Int32(bounds.minY), SRCCOPY) != 0 else {
|
||||
throw WindowsScreenCaptureError.failedToCopyBits
|
||||
}
|
||||
|
||||
// Convert bitmap to PNG data
|
||||
return try convertBitmapToPNG(bitmap: bitmap!, width: width, height: height)
|
||||
}
|
||||
|
||||
private func convertBitmapToPNG(bitmap: HBITMAP, width: Int32, height: Int32) throws -> Data {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you would use GDI+ or WIC to convert to PNG
|
||||
|
||||
var bitmapInfo = BITMAPINFO()
|
||||
bitmapInfo.bmiHeader.biSize = DWORD(MemoryLayout<BITMAPINFOHEADER>.size)
|
||||
bitmapInfo.bmiHeader.biWidth = width
|
||||
bitmapInfo.bmiHeader.biHeight = -height // Negative for top-down DIB
|
||||
bitmapInfo.bmiHeader.biPlanes = 1
|
||||
bitmapInfo.bmiHeader.biBitCount = 32
|
||||
bitmapInfo.bmiHeader.biCompression = BI_RGB
|
||||
|
||||
let dataSize = Int(width * height * 4) // 4 bytes per pixel (BGRA)
|
||||
var pixelData = Data(count: dataSize)
|
||||
|
||||
let screenDC = GetDC(nil)
|
||||
defer { ReleaseDC(nil, screenDC) }
|
||||
|
||||
let result = pixelData.withUnsafeMutableBytes { bytes in
|
||||
GetDIBits(screenDC, bitmap, 0, UINT(height), bytes.baseAddress, &bitmapInfo, DIB_RGB_COLORS)
|
||||
}
|
||||
|
||||
guard result != 0 else {
|
||||
throw WindowsScreenCaptureError.failedToGetBitmapBits
|
||||
}
|
||||
|
||||
// For now, return raw bitmap data
|
||||
// In a real implementation, convert to PNG format
|
||||
return pixelData
|
||||
}
|
||||
|
||||
private func parseWindowHandle(_ windowId: String) -> HWND? {
|
||||
guard let handle = UInt(windowId, radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
return HWND(bitPattern: handle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum WindowsScreenCaptureError: Error, LocalizedError {
|
||||
case invalidScreenIndex(Int)
|
||||
case invalidWindowId(String)
|
||||
case windowNotFound(String)
|
||||
case dxgiNotAvailable
|
||||
case failedToGetDC
|
||||
case failedToCreateCompatibleDC
|
||||
case failedToCreateBitmap
|
||||
case failedToCopyBits
|
||||
case failedToGetBitmapBits
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidScreenIndex(let index):
|
||||
return "Invalid screen index: \\(index)"
|
||||
case .invalidWindowId(let id):
|
||||
return "Invalid window ID: \\(id)"
|
||||
case .windowNotFound(let id):
|
||||
return "Window not found: \\(id)"
|
||||
case .dxgiNotAvailable:
|
||||
return "DXGI Desktop Duplication not available"
|
||||
case .failedToGetDC:
|
||||
return "Failed to get device context"
|
||||
case .failedToCreateCompatibleDC:
|
||||
return "Failed to create compatible device context"
|
||||
case .failedToCreateBitmap:
|
||||
return "Failed to create bitmap"
|
||||
case .failedToCopyBits:
|
||||
return "Failed to copy screen bits"
|
||||
case .failedToGetBitmapBits:
|
||||
return "Failed to get bitmap bits"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
#if os(Windows)
|
||||
import Foundation
|
||||
import WinSDK
|
||||
|
||||
/// Windows implementation of window management using Win32 APIs
|
||||
struct WindowsWindowManager: WindowManagerProtocol {
|
||||
|
||||
func getWindows(for applicationId: String) async throws -> [PlatformWindowInfo] {
|
||||
// For now, return empty array as Windows window management needs Win32 API
|
||||
// TODO: Implement Windows window enumeration using Win32 API
|
||||
return []
|
||||
}
|
||||
|
||||
func getAllWindows() async throws -> [PlatformWindowInfo] {
|
||||
// For now, return empty array as Windows window management needs Win32 API
|
||||
// TODO: Implement Windows window enumeration using Win32 API
|
||||
return []
|
||||
}
|
||||
|
||||
func getWindow(by windowId: String) async throws -> PlatformWindowInfo? {
|
||||
// For now, return nil as Windows window management needs Win32 API
|
||||
// TODO: Implement Windows window lookup using Win32 API
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // Windows always supports window management
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func parseWindowHandle(_ windowId: String) -> HWND? {
|
||||
// Parse hex string to HWND
|
||||
guard let handle = UInt(windowId.dropFirst(2), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
return HWND(bitPattern: handle)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@ -0,0 +1,62 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// macOS implementation of application finding using NSWorkspace
|
||||
struct macOSApplicationFinder: ApplicationFinderProtocol {
|
||||
|
||||
func findApplications(matching query: String) async throws -> [ApplicationInfo] {
|
||||
let runningApps = try await getRunningApplications()
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
return runningApps.filter { app in
|
||||
app.name.lowercased().contains(lowercaseQuery) ||
|
||||
app.bundleIdentifier?.lowercased().contains(lowercaseQuery) == true ||
|
||||
app.id.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
func getRunningApplications() async throws -> [ApplicationInfo] {
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
|
||||
return runningApps.compactMap { app in
|
||||
guard let bundleIdentifier = app.bundleIdentifier,
|
||||
let localizedName = app.localizedName else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ApplicationInfo(
|
||||
id: String(app.processIdentifier),
|
||||
name: localizedName,
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
executablePath: app.executableURL?.path,
|
||||
isRunning: true,
|
||||
processId: Int(app.processIdentifier)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func getApplication(by identifier: String) async throws -> ApplicationInfo? {
|
||||
let runningApps = try await getRunningApplications()
|
||||
|
||||
// Try to find by process ID first
|
||||
if let pid = Int(identifier) {
|
||||
if let app = runningApps.first(where: { $0.processId == pid }) {
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find by bundle identifier
|
||||
return runningApps.first { app in
|
||||
app.bundleIdentifier == identifier ||
|
||||
app.name.lowercased() == identifier.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // macOS always supports application finding
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import ScreenCaptureKit
|
||||
|
||||
/// macOS implementation of permissions checking
|
||||
struct macOSPermissions: PermissionsProtocol {
|
||||
|
||||
func hasScreenRecordingPermission() async -> Bool {
|
||||
// Test by attempting to capture a small area of the screen
|
||||
let testImage = CGDisplayCreateImage(CGMainDisplayID())
|
||||
return testImage != nil
|
||||
}
|
||||
|
||||
func requestScreenRecordingPermission() async -> Bool {
|
||||
// On macOS, we can't programmatically request permission
|
||||
// The system will show a dialog when we first attempt screen capture
|
||||
return await hasScreenRecordingPermission()
|
||||
}
|
||||
|
||||
func hasAccessibilityPermission() async -> Bool {
|
||||
// Check if we can access window information
|
||||
let options = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let trusted = AXIsProcessTrustedWithOptions([options: false] as CFDictionary)
|
||||
return trusted
|
||||
}
|
||||
|
||||
func requestAccessibilityPermission() async -> Bool {
|
||||
// This will prompt the user to grant accessibility permission
|
||||
let options = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let trusted = AXIsProcessTrustedWithOptions([options: true] as CFDictionary)
|
||||
return trusted
|
||||
}
|
||||
|
||||
func getPermissionInstructions() -> String {
|
||||
return \"\"\"
|
||||
To use Peekaboo on macOS, you need to grant the following permissions:
|
||||
|
||||
1. Screen Recording Permission:
|
||||
- Go to System Preferences > Security & Privacy > Privacy > Screen Recording
|
||||
- Add and enable Peekaboo (or Terminal if running from command line)
|
||||
|
||||
2. Accessibility Permission (for window management):
|
||||
- Go to System Preferences > Security & Privacy > Privacy > Accessibility
|
||||
- Add and enable Peekaboo (or Terminal if running from command line)
|
||||
|
||||
After granting permissions, restart the application.
|
||||
\"\"\"
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // macOS always supports permission checking
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Framework Declarations
|
||||
|
||||
// Declare the Accessibility framework functions we need
|
||||
@_silgen_name("AXIsProcessTrustedWithOptions")
|
||||
func AXIsProcessTrustedWithOptions(_ options: CFDictionary?) -> Bool
|
||||
|
||||
let kAXTrustedCheckOptionPrompt = "AXTrustedCheckOptionPrompt" as CFString
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import ScreenCaptureKit
|
||||
import AppKit
|
||||
|
||||
/// macOS implementation of screen capture using ScreenCaptureKit
|
||||
struct macOSScreenCapture: ScreenCaptureProtocol {
|
||||
|
||||
func captureScreen(screenIndex: Int) async throws -> Data {
|
||||
let screens = NSScreen.screens
|
||||
guard screenIndex < screens.count else {
|
||||
throw ScreenCaptureError.invalidScreenIndex(screenIndex)
|
||||
}
|
||||
|
||||
let screen = screens[screenIndex]
|
||||
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
|
||||
|
||||
// Try ScreenCaptureKit first (macOS 12.3+)
|
||||
if #available(macOS 12.3, *) {
|
||||
return try await captureWithScreenCaptureKit(displayID: displayID)
|
||||
} else {
|
||||
// Fallback to CGImage
|
||||
return try captureWithCGImage(displayID: displayID)
|
||||
}
|
||||
}
|
||||
|
||||
func captureWindow(windowId: String, bounds: CGRect?) async throws -> Data {
|
||||
guard let windowNumber = Int(windowId) else {
|
||||
throw ScreenCaptureError.invalidWindowId(windowId)
|
||||
}
|
||||
|
||||
// Try ScreenCaptureKit first (macOS 12.3+)
|
||||
if #available(macOS 12.3, *) {
|
||||
return try await captureWindowWithScreenCaptureKit(windowNumber: windowNumber)
|
||||
} else {
|
||||
// Fallback to CGImage
|
||||
return try captureWindowWithCGImage(windowNumber: windowNumber, bounds: bounds)
|
||||
}
|
||||
}
|
||||
|
||||
func getAvailableScreens() async throws -> [ScreenInfo] {
|
||||
let screens = NSScreen.screens
|
||||
return screens.enumerated().map { index, screen in
|
||||
let frame = screen.frame
|
||||
let bounds = CGRect(
|
||||
x: frame.origin.x,
|
||||
y: frame.origin.y,
|
||||
width: frame.size.width,
|
||||
height: frame.size.height
|
||||
)
|
||||
|
||||
return ScreenInfo(
|
||||
index: index,
|
||||
bounds: bounds,
|
||||
name: screen.localizedName,
|
||||
isPrimary: screen == NSScreen.main
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // macOS always supports screen capture
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
private func captureWithScreenCaptureKit(displayID: CGDirectDisplayID) async throws -> Data {
|
||||
let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
||||
|
||||
guard let display = availableContent.displays.first(where: { $0.displayID == displayID }) else {
|
||||
throw ScreenCaptureError.displayNotFound(displayID)
|
||||
}
|
||||
|
||||
let filter = SCContentFilter(display: display, excludingWindows: [])
|
||||
let configuration = SCStreamConfiguration()
|
||||
configuration.width = Int(display.width)
|
||||
configuration.height = Int(display.height)
|
||||
configuration.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
|
||||
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
||||
return try convertCGImageToPNG(image)
|
||||
}
|
||||
|
||||
private func captureWithCGImage(displayID: CGDirectDisplayID) throws -> Data {
|
||||
guard let image = CGDisplayCreateImage(displayID) else {
|
||||
throw ScreenCaptureError.captureFailedForDisplay(displayID)
|
||||
}
|
||||
return try convertCGImageToPNG(image)
|
||||
}
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
private func captureWindowWithScreenCaptureKit(windowNumber: Int) async throws -> Data {
|
||||
let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
||||
|
||||
guard let window = availableContent.windows.first(where: { $0.windowID == CGWindowID(windowNumber) }) else {
|
||||
throw ScreenCaptureError.windowNotFound(windowNumber)
|
||||
}
|
||||
|
||||
let filter = SCContentFilter(desktopIndependentWindow: window)
|
||||
let configuration = SCStreamConfiguration()
|
||||
configuration.width = Int(window.frame.width)
|
||||
configuration.height = Int(window.frame.height)
|
||||
configuration.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
|
||||
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
||||
return try convertCGImageToPNG(image)
|
||||
}
|
||||
|
||||
private func captureWindowWithCGImage(windowNumber: Int, bounds: CGRect?) throws -> Data {
|
||||
let windowID = CGWindowID(windowNumber)
|
||||
let imageOption: CGWindowImageOption = [.boundsIgnoreFraming, .shouldBeOpaque]
|
||||
|
||||
guard let image = CGWindowListCreateImage(bounds ?? .null, .optionIncludingWindow, windowID, imageOption) else {
|
||||
throw ScreenCaptureError.windowCaptureFailedForWindow(windowNumber)
|
||||
}
|
||||
|
||||
return try convertCGImageToPNG(image)
|
||||
}
|
||||
|
||||
private func convertCGImageToPNG(_ image: CGImage) throws -> Data {
|
||||
let mutableData = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(mutableData, kUTTypePNG, 1, nil) else {
|
||||
throw ScreenCaptureError.imageConversionFailed
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw ScreenCaptureError.imageConversionFailed
|
||||
}
|
||||
|
||||
return mutableData as Data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum ScreenCaptureError: Error, LocalizedError {
|
||||
case invalidScreenIndex(Int)
|
||||
case invalidWindowId(String)
|
||||
case displayNotFound(CGDirectDisplayID)
|
||||
case windowNotFound(Int)
|
||||
case captureFailedForDisplay(CGDirectDisplayID)
|
||||
case windowCaptureFailedForWindow(Int)
|
||||
case imageConversionFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidScreenIndex(let index):
|
||||
return "Invalid screen index: \\(index)"
|
||||
case .invalidWindowId(let id):
|
||||
return "Invalid window ID: \\(id)"
|
||||
case .displayNotFound(let displayID):
|
||||
return "Display not found: \\(displayID)"
|
||||
case .windowNotFound(let windowNumber):
|
||||
return "Window not found: \\(windowNumber)"
|
||||
case .captureFailedForDisplay(let displayID):
|
||||
return "Failed to capture display: \\(displayID)"
|
||||
case .windowCaptureFailedForWindow(let windowNumber):
|
||||
return "Failed to capture window: \\(windowNumber)"
|
||||
case .imageConversionFailed:
|
||||
return "Failed to convert image to PNG"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import AppKit
|
||||
|
||||
/// macOS implementation of window management using AppKit and Core Graphics
|
||||
struct macOSWindowManager: WindowManagerProtocol {
|
||||
|
||||
func getWindows(for applicationId: String) async throws -> [PlatformWindowInfo] {
|
||||
let allWindows = try await getAllWindows()
|
||||
return allWindows.filter { $0.applicationId == applicationId }
|
||||
}
|
||||
|
||||
func getAllWindows() async throws -> [PlatformWindowInfo] {
|
||||
let windowList = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
|
||||
var windows: [PlatformWindowInfo] = []
|
||||
|
||||
for windowDict in windowList {
|
||||
guard let windowNumber = windowDict[kCGWindowNumber as String] as? Int,
|
||||
let ownerName = windowDict[kCGWindowOwnerName as String] as? String,
|
||||
let windowTitle = windowDict[kCGWindowName as String] as? String,
|
||||
let boundsDict = windowDict[kCGWindowBounds as String] as? [String: Any],
|
||||
let x = boundsDict["X"] as? CGFloat,
|
||||
let y = boundsDict["Y"] as? CGFloat,
|
||||
let width = boundsDict["Width"] as? CGFloat,
|
||||
let height = boundsDict["Height"] as? CGFloat else {
|
||||
continue
|
||||
}
|
||||
|
||||
let bounds = CGRect(x: x, y: y, width: width, height: height)
|
||||
let level = windowDict[kCGWindowLayer as String] as? Int ?? 0
|
||||
let ownerPID = windowDict[kCGWindowOwnerPID as String] as? Int ?? 0
|
||||
|
||||
// Skip windows with empty titles or very small dimensions
|
||||
guard !windowTitle.isEmpty, width > 50, height > 50 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let windowInfo = PlatformWindowInfo(
|
||||
id: String(windowNumber),
|
||||
title: windowTitle,
|
||||
bounds: bounds,
|
||||
applicationName: ownerName,
|
||||
applicationId: String(ownerPID),
|
||||
isVisible: true,
|
||||
isMinimized: false,
|
||||
level: level
|
||||
)
|
||||
|
||||
windows.append(windowInfo)
|
||||
}
|
||||
|
||||
return windows
|
||||
}
|
||||
|
||||
func getWindow(by windowId: String) async throws -> PlatformWindowInfo? {
|
||||
guard let windowNumber = Int(windowId) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let allWindows = try await getAllWindows()
|
||||
return allWindows.first { $0.id == windowId }
|
||||
}
|
||||
|
||||
static func isSupported() -> Bool {
|
||||
return true // macOS always supports window management
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining cross-platform application discovery functionality
|
||||
protocol ApplicationFinderProtocol: Sendable {
|
||||
/// Finds applications by name or identifier
|
||||
/// - Parameter query: Search query (name or identifier)
|
||||
/// - Returns: Array of matching applications
|
||||
func findApplications(matching query: String) async throws -> [ApplicationInfo]
|
||||
|
||||
/// Gets all running applications
|
||||
/// - Returns: Array of all running applications
|
||||
func getRunningApplications() async throws -> [ApplicationInfo]
|
||||
|
||||
/// Gets application information by identifier
|
||||
/// - Parameter identifier: Platform-specific application identifier
|
||||
/// - Returns: Application information if found
|
||||
func getApplication(by identifier: String) async throws -> ApplicationInfo?
|
||||
|
||||
/// Checks if application finding is available on this platform
|
||||
/// - Returns: True if application finding is supported
|
||||
static func isSupported() -> Bool
|
||||
}
|
||||
|
||||
/// Cross-platform application information
|
||||
struct ApplicationInfo: Sendable, Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let bundleIdentifier: String?
|
||||
let executablePath: String?
|
||||
let isRunning: Bool
|
||||
let processId: Int?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
bundleIdentifier: String? = nil,
|
||||
executablePath: String? = nil,
|
||||
isRunning: Bool = false,
|
||||
processId: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.bundleIdentifier = bundleIdentifier
|
||||
self.executablePath = executablePath
|
||||
self.isRunning = isRunning
|
||||
self.processId = processId
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining cross-platform permissions checking functionality
|
||||
protocol PermissionsProtocol: Sendable {
|
||||
/// Checks if screen recording permission is granted
|
||||
/// - Returns: True if permission is granted
|
||||
func hasScreenRecordingPermission() async -> Bool
|
||||
|
||||
/// Requests screen recording permission (if possible)
|
||||
/// - Returns: True if permission was granted
|
||||
func requestScreenRecordingPermission() async -> Bool
|
||||
|
||||
/// Checks if accessibility permission is granted (for window management)
|
||||
/// - Returns: True if permission is granted
|
||||
func hasAccessibilityPermission() async -> Bool
|
||||
|
||||
/// Requests accessibility permission (if possible)
|
||||
/// - Returns: True if permission was granted
|
||||
func requestAccessibilityPermission() async -> Bool
|
||||
|
||||
/// Gets a user-friendly message about missing permissions
|
||||
/// - Returns: Instructions for the user to grant permissions
|
||||
func getPermissionInstructions() -> String
|
||||
|
||||
/// Checks if permissions are available on this platform
|
||||
/// - Returns: True if permission checking is supported
|
||||
static func isSupported() -> Bool
|
||||
}
|
||||
|
||||
/// Permission status enumeration
|
||||
enum PermissionStatus: String, Sendable, Codable {
|
||||
case granted = "granted"
|
||||
case denied = "denied"
|
||||
case notDetermined = "not_determined"
|
||||
case notSupported = "not_supported"
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Protocol defining cross-platform screen capture functionality
|
||||
protocol ScreenCaptureProtocol: Sendable {
|
||||
/// Captures a screenshot of the specified screen
|
||||
/// - Parameter screenIndex: Index of the screen to capture (0 for primary)
|
||||
/// - Returns: PNG image data
|
||||
func captureScreen(screenIndex: Int) async throws -> Data
|
||||
|
||||
/// Captures a screenshot of a specific window
|
||||
/// - Parameters:
|
||||
/// - windowId: Platform-specific window identifier
|
||||
/// - bounds: Optional bounds to crop the capture
|
||||
/// - Returns: PNG image data
|
||||
func captureWindow(windowId: String, bounds: CGRect?) async throws -> Data
|
||||
|
||||
/// Gets information about available screens
|
||||
/// - Returns: Array of screen information
|
||||
func getAvailableScreens() async throws -> [ScreenInfo]
|
||||
|
||||
/// Checks if screen capture is available on this platform
|
||||
/// - Returns: True if screen capture is supported
|
||||
static func isSupported() -> Bool
|
||||
}
|
||||
|
||||
/// Cross-platform screen information
|
||||
struct ScreenInfo: Sendable, Codable, Identifiable {
|
||||
let id = UUID()
|
||||
let index: Int
|
||||
let bounds: CGRect
|
||||
let name: String
|
||||
let isPrimary: Bool
|
||||
|
||||
init(index: Int, bounds: CGRect, name: String, isPrimary: Bool) {
|
||||
self.index = index
|
||||
self.bounds = bounds
|
||||
self.name = name
|
||||
self.isPrimary = isPrimary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CGRect Sendable Conformance
|
||||
extension CGRect: @unchecked Sendable {}
|
||||
extension CGPoint: @unchecked Sendable {}
|
||||
extension CGSize: @unchecked Sendable {}
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import CoreGraphics
|
||||
#endif
|
||||
|
||||
/// Protocol defining cross-platform window management functionality
|
||||
protocol WindowManagerProtocol: Sendable {
|
||||
/// Gets all windows for a specific application
|
||||
/// - Parameter applicationId: Platform-specific application identifier
|
||||
/// - Returns: Array of window information
|
||||
func getWindows(for applicationId: String) async throws -> [PlatformWindowInfo]
|
||||
|
||||
/// Gets all visible windows on the system
|
||||
/// - Returns: Array of all visible windows
|
||||
func getAllWindows() async throws -> [PlatformWindowInfo]
|
||||
|
||||
/// Gets window information by window ID
|
||||
/// - Parameter windowId: Platform-specific window identifier
|
||||
/// - Returns: Window information if found
|
||||
func getWindow(by windowId: String) async throws -> PlatformWindowInfo?
|
||||
|
||||
/// Checks if window management is available on this platform
|
||||
/// - Returns: True if window management is supported
|
||||
static func isSupported() -> Bool
|
||||
}
|
||||
|
||||
/// Cross-platform window information for internal use
|
||||
struct PlatformWindowInfo: Sendable, Codable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let bounds: CGRect
|
||||
let applicationName: String
|
||||
let applicationId: String
|
||||
let isVisible: Bool
|
||||
let isMinimized: Bool
|
||||
let level: Int
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
bounds: CGRect,
|
||||
applicationName: String,
|
||||
applicationId: String,
|
||||
isVisible: Bool = true,
|
||||
isMinimized: Bool = false,
|
||||
level: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.bounds = bounds
|
||||
self.applicationName = applicationName
|
||||
self.applicationId = applicationId
|
||||
self.isVisible = isVisible
|
||||
self.isMinimized = isMinimized
|
||||
self.level = level
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
@preconcurrency import ScreenCaptureKit
|
||||
|
||||
#if os(macOS)
|
||||
@preconcurrency import ScreenCaptureKit
|
||||
#endif
|
||||
|
||||
// Legacy ScreenCapture class for backward compatibility
|
||||
// New code should use PlatformFactory.createScreenCapture()
|
||||
struct ScreenCapture: Sendable {
|
||||
#if os(macOS)
|
||||
static func captureDisplay(
|
||||
_ displayID: CGDirectDisplayID, to path: String, format: ImageFormat = .png
|
||||
) async throws {
|
||||
@ -32,27 +38,34 @@ struct ScreenCapture: Sendable {
|
||||
configuration: configuration
|
||||
)
|
||||
|
||||
try ImageSaver.saveImage(image, to: path, format: format)
|
||||
} catch let captureError as CaptureError {
|
||||
// Re-throw CaptureError as-is (no need to check for screen recording permission)
|
||||
throw captureError
|
||||
// Save the image
|
||||
try await ImageSaver.saveImage(image, to: path, format: format)
|
||||
|
||||
} catch let error as CaptureError {
|
||||
throw error
|
||||
} catch {
|
||||
// Check if this is a permission error from ScreenCaptureKit
|
||||
// Check if this is a permission error
|
||||
if PermissionErrorDetector.isScreenRecordingPermissionError(error) {
|
||||
throw CaptureError.screenRecordingPermissionDenied
|
||||
}
|
||||
throw error
|
||||
|
||||
// Try fallback to CGImage
|
||||
try await captureDisplayWithCGImage(displayID, to: path, format: format)
|
||||
}
|
||||
}
|
||||
|
||||
static func captureWindow(_ window: WindowData, to path: String, format: ImageFormat = .png) async throws {
|
||||
static func captureWindow(
|
||||
_ window: WindowData, to path: String, format: ImageFormat = .png
|
||||
) async throws {
|
||||
do {
|
||||
// Get available content
|
||||
let availableContent = try await SCShareableContent.current
|
||||
|
||||
// Find the window by ID
|
||||
guard let scWindow = availableContent.windows.first(where: { $0.windowID == window.windowId }) else {
|
||||
throw CaptureError.windowNotFound
|
||||
// Fallback to CGImage capture
|
||||
try await captureWindowWithCGImage(window, to: path, format: format)
|
||||
return
|
||||
}
|
||||
|
||||
// Create content filter for the specific window
|
||||
@ -63,7 +76,7 @@ struct ScreenCapture: Sendable {
|
||||
configuration.width = Int(window.bounds.width)
|
||||
configuration.height = Int(window.bounds.height)
|
||||
configuration.backgroundColor = .clear
|
||||
configuration.shouldBeOpaque = true
|
||||
configuration.shouldBeOpaque = false
|
||||
configuration.showsCursor = false
|
||||
|
||||
// Capture the image
|
||||
@ -72,16 +85,67 @@ struct ScreenCapture: Sendable {
|
||||
configuration: configuration
|
||||
)
|
||||
|
||||
try ImageSaver.saveImage(image, to: path, format: format)
|
||||
} catch let captureError as CaptureError {
|
||||
// Re-throw CaptureError as-is (no need to check for screen recording permission)
|
||||
throw captureError
|
||||
// Save the image
|
||||
try await ImageSaver.saveImage(image, to: path, format: format)
|
||||
|
||||
} catch let error as CaptureError {
|
||||
throw error
|
||||
} catch {
|
||||
// Check if this is a permission error from ScreenCaptureKit
|
||||
// Check if this is a permission error
|
||||
if PermissionErrorDetector.isScreenRecordingPermissionError(error) {
|
||||
throw CaptureError.screenRecordingPermissionDenied
|
||||
}
|
||||
throw error
|
||||
|
||||
// Try fallback to CGImage
|
||||
try await captureWindowWithCGImage(window, to: path, format: format)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback methods using CGImage
|
||||
private static func captureDisplayWithCGImage(
|
||||
_ displayID: CGDirectDisplayID, to path: String, format: ImageFormat
|
||||
) async throws {
|
||||
guard let cgImage = CGDisplayCreateImage(displayID) else {
|
||||
throw CaptureError.captureCreationFailed(nil)
|
||||
}
|
||||
|
||||
try await ImageSaver.saveImage(cgImage, to: path, format: format)
|
||||
}
|
||||
|
||||
private static func captureWindowWithCGImage(
|
||||
_ window: WindowData, to path: String, format: ImageFormat
|
||||
) async throws {
|
||||
let windowID = CGWindowID(window.windowId)
|
||||
let imageRef = CGWindowListCreateImage(
|
||||
window.bounds,
|
||||
.optionIncludingWindow,
|
||||
windowID,
|
||||
.bestResolution
|
||||
)
|
||||
|
||||
guard let cgImage = imageRef else {
|
||||
throw CaptureError.windowCaptureFailed(nil)
|
||||
}
|
||||
|
||||
try await ImageSaver.saveImage(cgImage, to: path, format: format)
|
||||
}
|
||||
#else
|
||||
// Non-macOS platforms - use platform factory
|
||||
static func captureDisplay(
|
||||
_ displayIndex: Int, to path: String, format: ImageFormat = .png
|
||||
) async throws {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
let imageData = try await screenCapture.captureScreen(screenIndex: displayIndex)
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
static func captureWindow(
|
||||
_ window: WindowData, to path: String, format: ImageFormat = .png
|
||||
) async throws {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
let imageData = try await screenCapture.captureWindow(windowId: String(window.windowId), bounds: window.bounds)
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// Legacy WindowManager class for backward compatibility
|
||||
// New code should use PlatformFactory.createWindowManager()
|
||||
final class WindowManager: Sendable {
|
||||
#if os(macOS)
|
||||
static func getWindowsForApp(pid: pid_t, includeOffScreen: Bool = false) throws(WindowError) -> [WindowData] {
|
||||
// Logger.shared.debug("Getting windows for PID: \(pid)")
|
||||
|
||||
// In CI environment, return empty array to avoid accessing window server
|
||||
if ProcessInfo.processInfo.environment["CI"] == "true" {
|
||||
return []
|
||||
@ -14,7 +18,6 @@ final class WindowManager: Sendable {
|
||||
let windowList = try fetchWindowList(includeOffScreen: includeOffScreen)
|
||||
let windows = extractWindowsForPID(pid, from: windowList)
|
||||
|
||||
// Logger.shared.debug("Found \(windows.count) windows for PID \(pid)")
|
||||
return windows.sorted { $0.windowIndex < $1.windowIndex }
|
||||
}
|
||||
|
||||
@ -30,95 +33,90 @@ final class WindowManager: Sendable {
|
||||
return windowList
|
||||
}
|
||||
|
||||
private static func extractWindowsForPID(_ pid: pid_t, from windowList: [[String: Any]]) -> [WindowData] {
|
||||
private static func extractWindowsForPID(_ targetPID: pid_t, from windowList: [[String: Any]]) -> [WindowData] {
|
||||
var windows: [WindowData] = []
|
||||
var windowIndex = 0
|
||||
|
||||
for windowInfo in windowList {
|
||||
if let window = parseWindowInfo(windowInfo, targetPID: pid, index: windowIndex) {
|
||||
windows.append(window)
|
||||
windowIndex += 1
|
||||
guard let pid = windowInfo[kCGWindowOwnerPID as String] as? pid_t,
|
||||
pid == targetPID else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let windowID = windowInfo[kCGWindowNumber as String] as? UInt32 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let title = windowInfo[kCGWindowName as String] as? String ?? ""
|
||||
|
||||
// Skip windows without titles (usually system windows)
|
||||
guard !title.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
let bounds = extractWindowBounds(from: windowInfo)
|
||||
let isOnScreen = windowInfo[kCGWindowIsOnscreen as String] as? Bool ?? false
|
||||
|
||||
let windowData = WindowData(
|
||||
windowId: windowID,
|
||||
title: title,
|
||||
bounds: bounds,
|
||||
isOnScreen: isOnScreen,
|
||||
windowIndex: windowIndex
|
||||
)
|
||||
|
||||
windows.append(windowData)
|
||||
windowIndex += 1
|
||||
}
|
||||
|
||||
return windows
|
||||
}
|
||||
|
||||
private static func parseWindowInfo(_ info: [String: Any], targetPID: pid_t, index: Int) -> WindowData? {
|
||||
guard let windowPID = info[kCGWindowOwnerPID as String] as? Int32,
|
||||
windowPID == targetPID,
|
||||
let windowID = info[kCGWindowNumber as String] as? CGWindowID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let title = info[kCGWindowName as String] as? String ?? "Untitled"
|
||||
let bounds = extractWindowBounds(from: info)
|
||||
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? true
|
||||
|
||||
return WindowData(
|
||||
windowId: windowID,
|
||||
title: title,
|
||||
bounds: bounds,
|
||||
isOnScreen: isOnScreen,
|
||||
windowIndex: index
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractWindowBounds(from windowInfo: [String: Any]) -> CGRect {
|
||||
guard let boundsDict = windowInfo[kCGWindowBounds as String] as? [String: Any] else {
|
||||
return .zero
|
||||
guard let boundsDict = windowInfo[kCGWindowBounds as String] as? [String: Any],
|
||||
let x = boundsDict["X"] as? CGFloat,
|
||||
let y = boundsDict["Y"] as? CGFloat,
|
||||
let width = boundsDict["Width"] as? CGFloat,
|
||||
let height = boundsDict["Height"] as? CGFloat else {
|
||||
return CGRect.zero
|
||||
}
|
||||
|
||||
let xCoordinate = boundsDict["X"] as? Double ?? 0
|
||||
let yCoordinate = boundsDict["Y"] as? Double ?? 0
|
||||
let width = boundsDict["Width"] as? Double ?? 0
|
||||
let height = boundsDict["Height"] as? Double ?? 0
|
||||
|
||||
return CGRect(x: xCoordinate, y: yCoordinate, width: width, height: height)
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
|
||||
static func getWindowsInfoForApp(
|
||||
pid: pid_t,
|
||||
includeOffScreen: Bool = false,
|
||||
includeBounds: Bool = false,
|
||||
includeIDs: Bool = false
|
||||
) throws(WindowError) -> [WindowInfo] {
|
||||
let windowDataArray = try getWindowsForApp(pid: pid, includeOffScreen: includeOffScreen)
|
||||
|
||||
return windowDataArray.map { windowData in
|
||||
WindowInfo(
|
||||
window_title: windowData.title,
|
||||
window_id: includeIDs ? windowData.windowId : nil,
|
||||
window_index: windowData.windowIndex,
|
||||
bounds: includeBounds ? WindowBounds(
|
||||
x_coordinate: Int(windowData.bounds.origin.x),
|
||||
y_coordinate: Int(windowData.bounds.origin.y),
|
||||
width: Int(windowData.bounds.size.width),
|
||||
height: Int(windowData.bounds.size.height)
|
||||
) : nil,
|
||||
is_on_screen: includeOffScreen ? windowData.isOnScreen : nil
|
||||
#else
|
||||
// Non-macOS platforms - use platform factory
|
||||
static func getWindowsForApp(pid: pid_t, includeOffScreen: Bool = false) async throws -> [WindowData] {
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
let windows = try await windowManager.getWindows(for: String(pid))
|
||||
|
||||
return windows.enumerated().map { index, window in
|
||||
WindowData(
|
||||
windowId: UInt32(window.id) ?? 0,
|
||||
title: window.title,
|
||||
bounds: window.bounds,
|
||||
isOnScreen: window.isVisible,
|
||||
windowIndex: index
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Extension to add the getWindowsForApp function to ImageCommand
|
||||
extension ImageCommand {
|
||||
func getWindowsForApp(pid: pid_t) throws(WindowError) -> [WindowData] {
|
||||
try WindowManager.getWindowsForApp(pid: pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Window-related errors
|
||||
enum WindowError: Error, LocalizedError, Sendable {
|
||||
case windowListFailed
|
||||
case noWindowsFound
|
||||
case invalidWindowID
|
||||
case windowNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .windowListFailed:
|
||||
"Failed to get window list from system"
|
||||
case .noWindowsFound:
|
||||
"No windows found for the specified application"
|
||||
return "Failed to retrieve window list from the system."
|
||||
case .invalidWindowID:
|
||||
return "Invalid window ID provided."
|
||||
case .windowNotFound:
|
||||
return "The specified window could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,17 +2,35 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
@available(macOS 14.0, *)
|
||||
@available(macOS 14.0, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
|
||||
struct PeekabooCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "peekaboo",
|
||||
abstract: "A macOS utility for screen capture, application listing, and window management",
|
||||
abstract: "A cross-platform utility for screen capture, application listing, and window management",
|
||||
version: Version.current,
|
||||
subcommands: [ImageCommand.self, ListCommand.self],
|
||||
defaultSubcommand: ImageCommand.self
|
||||
)
|
||||
|
||||
func run() async throws {
|
||||
// Root command doesn't do anything, subcommands handle everything
|
||||
// Check platform support
|
||||
guard PlatformFactory.isSupported else {
|
||||
print("❌ Peekaboo is not supported on this platform (\(PlatformFactory.currentPlatform))")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
// Show platform capabilities if running without subcommand
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
print("🌍 Peekaboo running on \(PlatformFactory.currentPlatform)")
|
||||
print("📋 Platform capabilities:")
|
||||
print(" Screen Capture: \(capabilities.screenCapture ? "✅" : "❌")")
|
||||
print(" Window Management: \(capabilities.windowManagement ? "✅" : "❌")")
|
||||
print(" Application Finding: \(capabilities.applicationFinding ? "✅" : "❌")")
|
||||
print(" Permissions: \(capabilities.permissions ? "✅" : "❌")")
|
||||
|
||||
if !capabilities.isFullySupported {
|
||||
print("⚠️ Some features may be limited on this platform")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
178
peekaboo-cli/Tests/peekabooTests/PlatformFactoryTests.swift
Normal file
178
peekaboo-cli/Tests/peekabooTests/PlatformFactoryTests.swift
Normal file
@ -0,0 +1,178 @@
|
||||
import Testing
|
||||
@testable import peekaboo
|
||||
|
||||
@Suite("Platform Factory Tests")
|
||||
struct PlatformFactoryTests {
|
||||
|
||||
@Test("Platform detection works correctly")
|
||||
func testPlatformDetection() {
|
||||
// Test that we can detect the current platform
|
||||
let platform = PlatformFactory.currentPlatform
|
||||
#expect(!platform.isEmpty, "Platform should be detected")
|
||||
|
||||
#if os(macOS)
|
||||
#expect(platform == "macOS")
|
||||
#elseif os(Windows)
|
||||
#expect(platform == "Windows")
|
||||
#elseif os(Linux)
|
||||
#expect(platform == "Linux")
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test("Platform support is correctly reported")
|
||||
func testPlatformSupport() {
|
||||
// Test that the current platform is supported
|
||||
#expect(PlatformFactory.isSupported, "Current platform should be supported")
|
||||
}
|
||||
|
||||
@Test("Platform capabilities are available")
|
||||
func testCapabilities() {
|
||||
// Test that we can get platform capabilities
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
|
||||
// All platforms should support at least some functionality
|
||||
#expect(
|
||||
capabilities.screenCapture ||
|
||||
capabilities.windowManagement ||
|
||||
capabilities.applicationFinding ||
|
||||
capabilities.permissions,
|
||||
"Platform should support at least one capability"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Screen capture implementation can be created")
|
||||
func testScreenCaptureCreation() {
|
||||
// Test that we can create a screen capture implementation
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
#expect(screenCapture != nil, "Should be able to create screen capture implementation")
|
||||
|
||||
// Test that the implementation reports correct support
|
||||
let isSupported = type(of: screenCapture).isSupported()
|
||||
#expect(isSupported, "Screen capture should be supported on current platform")
|
||||
}
|
||||
|
||||
@Test("Window manager implementation can be created")
|
||||
func testWindowManagerCreation() {
|
||||
// Test that we can create a window manager implementation
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
#expect(windowManager != nil, "Should be able to create window manager implementation")
|
||||
|
||||
// Note: Window management support varies by platform
|
||||
// macOS: fully supported
|
||||
// Linux/Windows: placeholder implementations for now
|
||||
}
|
||||
|
||||
@Test("Application finder implementation can be created")
|
||||
func testApplicationFinderCreation() {
|
||||
// Test that we can create an application finder implementation
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
#expect(applicationFinder != nil, "Should be able to create application finder implementation")
|
||||
|
||||
// Test that the implementation reports correct support
|
||||
let isSupported = type(of: applicationFinder).isSupported()
|
||||
#expect(isSupported, "Application finding should be supported on current platform")
|
||||
}
|
||||
|
||||
@Test("Permissions checker implementation can be created")
|
||||
func testPermissionsCheckerCreation() {
|
||||
// Test that we can create a permissions checker implementation
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
#expect(permissionsChecker != nil, "Should be able to create permissions checker implementation")
|
||||
|
||||
// Test that the implementation reports correct support
|
||||
let isSupported = type(of: permissionsChecker).isSupported()
|
||||
#expect(isSupported, "Permissions checking should be supported on current platform")
|
||||
}
|
||||
|
||||
@Test("Platform-specific implementations are correct")
|
||||
func testPlatformSpecificImplementations() {
|
||||
#if os(macOS)
|
||||
testMacOSImplementations()
|
||||
#elseif os(Windows)
|
||||
testWindowsImplementations()
|
||||
#elseif os(Linux)
|
||||
testLinuxImplementations()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func testMacOSImplementations() {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
#expect(screenCapture is macOSScreenCapture, "Should create macOS screen capture implementation")
|
||||
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
#expect(windowManager is macOSWindowManager, "Should create macOS window manager implementation")
|
||||
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
#expect(applicationFinder is macOSApplicationFinder, "Should create macOS application finder implementation")
|
||||
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
#expect(permissionsChecker is macOSPermissions, "Should create macOS permissions checker implementation")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(Windows)
|
||||
private func testWindowsImplementations() {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
#expect(screenCapture is WindowsScreenCapture, "Should create Windows screen capture implementation")
|
||||
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
#expect(windowManager is WindowsWindowManager, "Should create Windows window manager implementation")
|
||||
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
#expect(applicationFinder is WindowsApplicationFinder, "Should create Windows application finder implementation")
|
||||
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
#expect(permissionsChecker is WindowsPermissions, "Should create Windows permissions checker implementation")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(Linux)
|
||||
private func testLinuxImplementations() {
|
||||
let screenCapture = PlatformFactory.createScreenCapture()
|
||||
#expect(screenCapture is LinuxScreenCapture, "Should create Linux screen capture implementation")
|
||||
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
#expect(windowManager is LinuxWindowManager, "Should create Linux window manager implementation")
|
||||
|
||||
let applicationFinder = PlatformFactory.createApplicationFinder()
|
||||
#expect(applicationFinder is LinuxApplicationFinder, "Should create Linux application finder implementation")
|
||||
|
||||
let permissionsChecker = PlatformFactory.createPermissionsChecker()
|
||||
#expect(permissionsChecker is LinuxPermissions, "Should create Linux permissions checker implementation")
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test("Capabilities structure is properly formed")
|
||||
func testCapabilitiesStructure() {
|
||||
let capabilities = PlatformFactory.capabilities
|
||||
|
||||
// Test that capabilities structure is properly formed
|
||||
#expect(capabilities != nil, "Capabilities should not be nil")
|
||||
|
||||
// Test that isFullySupported works correctly
|
||||
let expectedFullSupport = capabilities.screenCapture &&
|
||||
capabilities.windowManagement &&
|
||||
capabilities.applicationFinding &&
|
||||
capabilities.permissions
|
||||
#expect(capabilities.isFullySupported == expectedFullSupport, "isFullySupported should match individual capabilities")
|
||||
}
|
||||
|
||||
@Test("Cross-platform window manager protocol compatibility")
|
||||
func testWindowManagerProtocolCompatibility() async throws {
|
||||
let windowManager = PlatformFactory.createWindowManager()
|
||||
|
||||
// Test that the protocol methods can be called without compilation errors
|
||||
let allWindows = try await windowManager.getAllWindows()
|
||||
#expect(allWindows != nil, "getAllWindows should return a non-nil array")
|
||||
|
||||
// Test getting windows for a non-existent application
|
||||
let appWindows = try await windowManager.getWindows(for: "non-existent-app")
|
||||
#expect(appWindows.isEmpty, "Should return empty array for non-existent application")
|
||||
|
||||
// Test getting a specific window
|
||||
let specificWindow = try await windowManager.getWindow(by: "non-existent-window")
|
||||
#expect(specificWindow == nil, "Should return nil for non-existent window")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user