32 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 4.5 c9d0c9812d Bump version to 0.9.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:02:36 -08:00
shadowdaoandClaude Opus 4.5 3521f777e9 Fix author name capitalization to ShadowDao
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:00:32 -08:00
shadowdaoandClaude Opus 4.5 8ce09fcaf6 Add author, updates, and donation links to About dialog
- Author link: shadowdao.com
- Updates link: shadowdao.com
- Donate link: liberapay.com/GoTakeAKnapp/
- Links are clickable and open in default browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:59:57 -08:00
shadowdaoandClaude Opus 4.5 a1a7334772 Add examples to App command dialog
Show platform-specific examples when adding or editing an App command:
- Windows: notepad.exe, paths with quotes
- Linux: firefox with URL
- macOS: open -a Safari

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:56:24 -08:00
shadowdaoandClaude Opus 4.5 063949cd7d Add fullscreen toggle and wake lock to prevent screen sleep
## Features
- Fullscreen button (⛶) in header to toggle fullscreen mode
- Wake Lock API integration to keep screen on while using the app
- Sun icon (☀) indicator shows wake lock status (bright = active)

## Wake Lock behavior
- Automatically requests wake lock when page loads
- Re-acquires wake lock when returning to the page
- Visual indicator pulses when active
- Gracefully hidden if Wake Lock API not supported

## Fullscreen
- Works on Android Chrome and desktop browsers
- iOS Safari has limited support (no fullscreen API)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:34:56 -08:00
shadowdaoandClaude Opus 4.5 da5d2d6ded Fix PWA to open as standalone app instead of browser tab
## manifest.json
- Add `id` and `scope` fields for proper PWA identification
- Split icon purposes into separate entries (was "any maskable", now separate)
- Add `prefer_related_applications: false`

## index.html
- Add `viewport-fit=cover` for notched devices
- Add `mobile-web-app-capable` meta tag
- Add `application-name` and `msapplication` meta tags
- Add both 192px and 512px apple-touch-icon sizes

## styles.css
- Add safe-area-inset padding for notched devices (iPhone X+)
- Use 100dvh for proper mobile viewport height
- Add bottom safe area to toast container and macro grid

## service-worker.js
- Bump cache version to v2 to force update

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:25:17 -08:00
shadowdaoandClaude Opus 4.5 b37def8fec Add category dropdown with existing categories in macro editor
- Replace QLineEdit with editable QComboBox for category field
- Populate dropdown with existing categories from saved macros
- Allow typing new category names (editable combo box)
- Add styled dropdown arrow and item view to match dark theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:05:10 -08:00
shadowdaoandClaude Opus 4.5 efabf55eae Fix macro not executing correctly on first run after save
The commands list was stored as a shallow copy, meaning the individual
command dicts were shared references with the CommandBuilder widget.
When the dialog closed, these shared objects could be affected.

Fix: Use copy.deepcopy() when storing commands in add_macro() and
update_macro() to ensure the macro manager has its own independent
copy of all command data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:57:12 -08:00
shadowdaoandClaude Opus 4.5 a3d5a9d001 Fix command button height to prevent text cutoff
- Set fixed height of 28px on all command action buttons
- Increase vertical padding from 4px to 6px
- Add min-height: 20px in stylesheet for safety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:53:36 -08:00
shadowdaoandClaude Opus 4.5 256e8c109c Simplify web interface to execute-only, improve desktop editor UX
## Web Interface
- Remove Add/Edit functionality from web interface (execute-only now)
- Remove modal dialog and command builder
- Simplified JS from 480 to 267 lines
- Users can still create/edit macros in the desktop app

## Desktop Editor
- Fix Edit button padding (set fixed width of 50px)
- Capitalize key options for better readability (Enter, Tab, etc.)
- Display keys capitalized in command list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:48:21 -08:00
shadowdaoandClaude Opus 4.5 a71c1f5ec4 Fix PyInstaller build issues and add right-click edit
## Fixes
- Web interface now loads correctly in built app (use sys._MEIPASS for bundled web files)
- Macro execution no longer locks up (use pyperclip clipboard for Unicode text support)
- Right-click context menu works (use Qt signals instead of fragile parent traversal)

## Changes
- web_server.py: Use get_resource_path() for web directory
- macro_manager.py: Use clipboard paste for text commands instead of typewrite
- gui/main_window.py: Add edit_requested/delete_requested signals to MacroButton
- pyproject.toml: Add pyperclip dependency
- All .spec files: Add pyperclip to hidden imports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:37:43 -08:00
shadowdaoandClaude Opus 4.5 f61df830ca Fix uvicorn logging error in PyInstaller builds
Set log_config=None to disable uvicorn's default logging configuration
which fails in frozen executables with "Unable to configure formatter 'default'"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:25:41 -08:00
shadowdaoandClaude Opus 4.5 f6743339c0 Add web server error handling and proper shutdown
- Add stop() method to WebServer class
- Store server instance for graceful shutdown
- Add error handling in start_server() with user-friendly alert
- Show warning dialog if server fails to start (e.g., port in use)
- Stop server in closeEvent before application exit
- Add status bar messages for server state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:15:38 -08:00
shadowdaoandClaude Opus 4.5 418407b2b9 Fix icon loading in PyInstaller builds
PyInstaller bundles data files into sys._MEIPASS, not the executable directory.

- Add get_resource_path() helper in main.py and main_window.py
- Use _MEIPASS for bundled resources (icon, web files)
- Keep app_dir pointing to executable directory for user data (macros.json)

This fixes the missing icon in taskbar and system tray on Windows builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:12:39 -08:00
shadowdaoandClaude Opus 4.5 9bba673ba7 Fix PySide6 enum access for Windows compatibility
- Import QStyle from PySide6.QtWidgets
- Use QStyle.StandardPixmap.SP_ComputerIcon instead of self.style().SP_ComputerIcon
- Use QSystemTrayIcon.ActivationReason.DoubleClick instead of QSystemTrayIcon.DoubleClick

PySide6 requires fully qualified enum paths unlike PyQt5.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:07:49 -08:00
shadowdaoandClaude Opus 4.5 dbdb465fde Add BUILDING.md with uv and PyInstaller build instructions
Comprehensive guide covering:
- Installing uv on Windows/Linux/macOS
- Building executables with uv run pyinstaller
- Platform-specific build commands
- Troubleshooting common issues
- Detailed cleanup instructions for build artifacts
- Clean rebuild commands for all platforms
- Alternative pip-based build approach

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:05:50 -08:00
shadowdaoandClaude Opus 4.5 5888aeb603 Modernize application to v0.9.0 with PySide6, FastAPI, and PWA support
## Major Changes

### Build System
- Replace requirements.txt with pyproject.toml for modern dependency management
- Support for uv package manager alongside pip
- Update PyInstaller spec files for new dependencies and structure

### Desktop GUI (Tkinter → PySide6)
- Complete rewrite of UI using PySide6/Qt6
- New modular structure in gui/ directory:
  - main_window.py: Main application window
  - macro_editor.py: Macro creation/editing dialog
  - command_builder.py: Visual command sequence builder
- Modern dark theme with consistent styling
- System tray integration

### Web Server (Flask → FastAPI)
- Migrate from Flask/Waitress to FastAPI/Uvicorn
- Add WebSocket support for real-time updates
- Full CRUD API for macro management
- Image upload endpoint

### Web Interface → PWA
- New web/ directory with standalone static files
- PWA manifest and service worker for installability
- Offline caching support
- Full macro editing from web interface
- Responsive mobile-first design
- Command builder UI matching desktop functionality

### Macro System Enhancement
- New command sequence model replacing simple text/app types
- Command types: text, key, hotkey, wait, app
- Support for delays between commands (wait in ms)
- Support for key presses between commands (enter, tab, etc.)
- Automatic migration of existing macros to new format
- Backward compatibility maintained

### Files Added
- pyproject.toml
- gui/__init__.py, main_window.py, macro_editor.py, command_builder.py
- gui/widgets/__init__.py
- web/index.html, manifest.json, service-worker.js
- web/css/styles.css, web/js/app.js
- web/icons/icon-192.png, icon-512.png

### Files Removed
- requirements.txt (replaced by pyproject.toml)
- ui_components.py (replaced by gui/ modules)
- web_templates.py (replaced by web/ static files)
- main.spec (consolidated into platform-specific specs)

### Files Modified
- main.py: Simplified entry point for PySide6
- macro_manager.py: Command sequence model and migration
- web_server.py: FastAPI implementation
- config.py: Version bump to 0.9.0
- All .spec files: Updated for PySide6 and new structure
- README.md: Complete rewrite for v0.9.0
- .gitea/workflows/release.yml: Disabled pending build testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:57:14 -08:00
jknapp ded281cc64 Merge pull request 'Working on the Linux Build Issues' (#5) from fix-linux-update into main
Build and Release / create-release (push) Successful in 3s
Build and Release / build-windows (push) Successful in 36s
Build and Release / build-linux (push) Successful in 1m7s
Build and Release / attach-to-release (push) Successful in 4s
Reviewed-on: #5
2025-06-05 22:21:49 +00:00
shadowdao f0af9ad84e Working on the Linux Build Issues 2025-06-05 15:21:34 -07:00
jknapp 00120c8562 Merge pull request 'fixes Linux bugs' (#4) from update-build-and-docs into main
Build and Release / create-release (push) Successful in 4s
Build and Release / build-windows (push) Successful in 39s
Build and Release / build-linux (push) Failing after 1m1s
Build and Release / attach-to-release (push) Has been skipped
Reviewed-on: #4
2025-06-05 22:13:17 +00:00
shadowdao 073bfbf7d9 fixes Linux bugs 2025-06-05 15:12:31 -07:00
jknapp c4d151a6d2 Merge pull request 'Fix build errors' (#3) from update-build-and-docs into main
Build and Release / create-release (push) Successful in 4s
Build and Release / build-linux (push) Successful in 33s
Build and Release / build-windows (push) Successful in 4m19s
Build and Release / attach-to-release (push) Successful in 9s
Reviewed-on: #3
2025-06-05 21:39:27 +00:00
shadowdao 45dfe44b59 Fix build errors 2025-06-05 14:39:00 -07:00
jknapp 80f4c30fb9 Merge pull request 'update-build-and-docs' (#2) from update-build-and-docs into main
Build and Release / create-release (push) Successful in 5s
Build and Release / build-windows (push) Failing after 34s
Build and Release / build-linux (push) Failing after 39s
Build and Release / attach-to-release (push) Has been skipped
Reviewed-on: #2
2025-06-05 21:34:40 +00:00
shadowdao c80474f520 update to remove mac build because I do not have a mac to build on yet 2025-06-05 14:33:03 -07:00
shadowdao 0a66c1d663 Enhanced project documentation and release automation
- Improved README.md formatting and structure
- Added installation instructions for pre-built executables
- Included platform-specific examples for Windows, Linux, and macOS
- Set up Gitea workflow for automated builds on new releases
- Added platform-specific PyInstaller spec files for all supported platforms
- Updated repository links to point to repo.anhonesthost.net

These changes make the project more accessible to new users and streamline
the release process with automatic executable generation for all platforms.
2025-06-05 13:54:36 -07:00
jknapp 2f8fa238fc Merge pull request 'Add support for Tabs and other enhancements' (#1) from new-rework into main
Reviewed-on: #1
2025-06-01 21:19:42 +00:00
shadowdao d2a9be6274 Updating README
Updated the README to reflect the changes to the app including the tabs
2025-06-01 14:18:27 -07:00
shadowdao a477be23cc Updating README
Updated the README to reflect the changes to the app including the tabs
2025-06-01 14:17:50 -07:00
shadowdao 5727a9ee68 Release Update
Updated MacroPad to support Tabs, fix system tray behavior, and break apart the monolith
2025-06-01 14:02:29 -07:00
shadowdao 896875ce90 updating main app to resolve previous issues
Tabs are working correctly, and the app that minimizes to the tray
2025-06-01 13:27:27 -07:00
shadowdao 9f3484dff2 Big Update
This update will need to get cleaned up, but for right now we are doing testing.
2025-06-01 12:38:41 -07:00
33 changed files with 4008 additions and 1763 deletions
+159
View File
@@ -0,0 +1,159 @@
# =============================================================================
# WORKFLOW DISABLED - Pending testing of v0.9.0 modernization
# =============================================================================
# This workflow is temporarily disabled while testing the new:
# - PySide6 GUI (replacing Tkinter)
# - FastAPI web server (replacing Flask)
# - pyproject.toml build system (replacing requirements.txt)
# - PWA web interface
#
# Uncomment the workflow below once builds are verified locally.
# =============================================================================
# name: Build and Release
#
# on:
# push:
# branches:
# - main
#
# jobs:
# create-release:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Get version
# id: get_version
# run: |
# VERSION=$(cat version.txt)
# echo "VERSION=$VERSION" >> $GITHUB_ENV
#
# - name: Create Release
# id: create_release
# uses: softprops/action-gh-release@v1
# with:
# tag_name: v${{ env.VERSION }}
# name: Release v${{ env.VERSION }}
# draft: false
# prerelease: false
#
# build-windows:
# needs: [create-release]
# runs-on: windows-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Set up Python
# uses: actions/setup-python@v4
# with:
# python-version: '3.11'
#
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install pyinstaller
# pip install -e .
#
# - name: Build executable
# run: |
# pyinstaller macropad.spec
#
# - name: Upload Windows artifact
# uses: actions/upload-artifact@v3
# with:
# name: macropad-windows
# path: dist/macropad.exe
#
# build-linux:
# needs: [create-release]
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Set up Python
# uses: actions/setup-python@v4
# with:
# python-version: '3.11'
#
# - name: Install system dependencies
# run: |
# sudo apt-get update
# # PySide6 requirements
# sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1
# # System tray requirements
# sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0
# sudo apt-get install -y gir1.2-appindicator3-0.1
# sudo apt-get install -y libcairo2-dev libgirepository1.0-dev
#
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install pyinstaller
# pip install -e .
#
# - name: Build executable
# run: |
# pyinstaller macropad_linux.spec
#
# - name: Upload Linux artifact
# uses: actions/upload-artifact@v3
# with:
# name: macropad-linux
# path: dist/macropad
#
# # MacOS build - requires macos runner
# # build-macos:
# # needs: [create-release]
# # runs-on: macos-latest
# # steps:
# # - name: Checkout code
# # uses: actions/checkout@v3
# #
# # - name: Set up Python
# # uses: actions/setup-python@v4
# # with:
# # python-version: '3.11'
# #
# # - name: Install dependencies
# # run: |
# # python -m pip install --upgrade pip
# # pip install pyinstaller
# # pip install -e .
# #
# # - name: Build executable
# # run: |
# # pyinstaller macropad_macos.spec
# #
# # - name: Upload macOS artifact
# # uses: actions/upload-artifact@v3
# # with:
# # name: macropad-macos
# # path: dist/macropad.app
#
# attach-to-release:
# needs: [create-release, build-windows, build-linux]
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Get version
# id: get_version
# run: |
# VERSION=$(cat version.txt)
# echo "VERSION=$VERSION" >> $GITHUB_ENV
#
# - name: Download all artifacts
# uses: actions/download-artifact@v3
#
# - name: Attach executables to release
# uses: softprops/action-gh-release@v1
# with:
# tag_name: v${{ env.VERSION }}
# files: |
# macropad-windows/macropad.exe
# macropad-linux/macropad
+3 -1
View File
@@ -1,4 +1,6 @@
macros.json macros.json
macro_images/ macro_images/
macro_images/* macro_images/*
build/ build/
.venv
__pycache__
+239
View File
@@ -0,0 +1,239 @@
# Building MacroPad Server
This guide explains how to build standalone executables for MacroPad Server using `uv` and PyInstaller.
## Prerequisites
### Install uv
**Windows (PowerShell):**
```powershell
irm https://astral.sh/uv/install.ps1 | iex
```
**Linux/macOS:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
### Clone the Repository
```bash
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
```
## Building Executables
> **Important:** PyInstaller cannot cross-compile. You must build on the target platform:
> - Windows executables → build on Windows
> - Linux executables → build on Linux
> - macOS executables → build on macOS
### Step 1: Install Dependencies
```bash
uv sync
```
### Step 2: Add PyInstaller
```bash
uv add --dev pyinstaller
```
### Step 3: Build for Your Platform
#### Windows
```powershell
uv run pyinstaller macropad.spec
```
The executable will be created at: `dist\macropad.exe`
#### Linux
```bash
# Install system dependencies first (Ubuntu/Debian)
sudo apt-get update
sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1
# Build
uv run pyinstaller macropad_linux.spec
```
The executable will be created at: `dist/macropad`
#### macOS
```bash
uv run pyinstaller macropad_macos.spec
```
The app bundle will be created at: `dist/MacroPad Server.app`
## Build Output
After a successful build, you'll find the executable in the `dist/` directory:
| Platform | Output |
|----------|--------|
| Windows | `dist/macropad.exe` |
| Linux | `dist/macropad` |
| macOS | `dist/MacroPad Server.app` |
## Troubleshooting
### Missing Hidden Imports
If you get import errors when running the built executable, you may need to add hidden imports to the `.spec` file:
```python
hiddenimports=[
'missing_module_name',
# ... existing imports
],
```
### Linux: PySide6 Display Issues
Ensure you have the required Qt libraries:
```bash
sudo apt-get install -y \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libegl1 \
libxcb-cursor0
```
### Linux: System Tray Issues
For system tray support on Linux:
```bash
sudo apt-get install -y \
libgtk-3-dev \
gir1.2-appindicator3-0.1
```
### Windows: Antivirus False Positives
PyInstaller executables may trigger antivirus warnings. This is a known issue with PyInstaller-built applications. You may need to add an exception in your antivirus software.
### macOS: Unsigned Application
The built app is unsigned and will trigger Gatekeeper:
1. Right-click the app → Open
2. Or: `xattr -cr "dist/MacroPad Server.app"`
## Development Builds
For debugging, you can enable console output by editing the `.spec` file:
```python
exe = EXE(
...
console=True, # Change from False to True
...
)
```
## Cleaning Up
PyInstaller creates several directories and files during the build process. Here's how to clean them up:
### Build Artifacts
| Directory/File | Description |
|----------------|-------------|
| `build/` | Temporary build files (can be large) |
| `dist/` | Final executable output |
| `*.spec` | Spec files (keep these - they're source files) |
| `__pycache__/` | Python bytecode cache |
### Clean Commands
#### Windows (PowerShell)
```powershell
# Remove build artifacts
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
# Also clear Python cache (optional)
Get-ChildItem -Recurse -Directory -Name __pycache__ | Remove-Item -Recurse -Force
```
#### Windows (Command Prompt)
```cmd
rmdir /s /q build dist
```
#### Linux/macOS
```bash
# Remove build artifacts
rm -rf build/ dist/
# Also clear Python cache (optional)
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null
```
### Clean Rebuild
To perform a completely clean rebuild:
#### Windows (PowerShell)
```powershell
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
uv run pyinstaller macropad.spec
```
#### Linux
```bash
rm -rf build/ dist/
uv run pyinstaller macropad_linux.spec
```
#### macOS
```bash
rm -rf build/ dist/
uv run pyinstaller macropad_macos.spec
```
### Space Recovery
The `build/` directory can consume significant disk space (often 500MB+). If you're done testing, remove it:
```bash
# Keep the executable, remove build intermediates
rm -rf build/
```
## Alternative: Using pip
If you prefer not to use uv:
```bash
# Create virtual environment
python -m venv .venv
# Activate it
# Windows:
.venv\Scripts\activate
# Linux/macOS:
source .venv/bin/activate
# Install dependencies
pip install -e .
pip install pyinstaller
# Build
pyinstaller macropad.spec
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+223 -62
View File
@@ -1,100 +1,261 @@
# MP-Server # MacroPad Server
A versatile MacroPad server application that lets you create, manage, and execute custom macros from both a local interface and remotely via a web interface. A cross-platform macro management application with desktop and web interfaces. Create powerful command sequences with delays, key presses, and text input - accessible locally or remotely via a PWA-enabled web interface.
## Features ## Features
- **Text Macros**: Insert frequently used text snippets with a single click ### Macro Capabilities
- **Application Macros**: Launch applications or scripts directly - **Command Sequences**: Build multi-step macros with:
- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros - **Text**: Type text strings
- **Keys**: Press individual keys (Enter, Tab, Escape, etc.)
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
- **Wait**: Insert delays between commands (in milliseconds)
- **App**: Launch applications or scripts
- **Custom Images**: Assign images to macros for easy identification - **Custom Images**: Assign images to macros for easy identification
- **Web Interface**: Access and trigger your macros from other devices on your network - **Category Management**: Organize macros into custom tabs
- **System Tray Integration**: Runs silently in your system tray for easy access
- **Persistent Storage**: Macros are automatically saved for future sessions ### Interfaces
- **Desktop GUI**: Modern PySide6/Qt interface with visual command builder
- **Web Interface**: PWA-enabled for installation on any device
- **System Tray**: Minimize to tray, always accessible
### Additional Features
- **QR Code Generation**: Quickly connect mobile devices
- **Real-time Sync**: WebSocket updates across all connected devices
- **Offline Support**: PWA caches for offline macro viewing
- **Dark Theme**: Modern dark interface throughout
- **Auto-Migration**: Existing macros automatically upgraded to new format
## Requirements ## Requirements
- Python 3.11+ - Python 3.11+
- Required Python packages: - Dependencies managed via `pyproject.toml`
- tkinter
- flask ### Core Dependencies
- pyautogui - PySide6 (Desktop GUI)
- pystray - FastAPI + Uvicorn (Web server)
- Pillow (PIL) - PyAutoGUI (Keyboard automation)
- flask_cors - Pillow (Image processing)
- waitress - pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
## Installation ## Installation
1. Clone or download this repository ### Method 1: Using uv (Recommended)
2. Install the required dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python mp-server-v2.py
```
## Alternative Installation Method ```bash
#### Windows only # Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
1. Create a Folder you wish to run MacroPad from # Clone and install
2. Download ```mp-serverv2.exe``` from the ```dist``` folder git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
3. Accept the security notices, and run the application cd MP-Server
uv sync
```
### Method 2: Using pip
```bash
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
pip install -e .
```
### Method 3: Pre-built Executables
1. Go to the [Releases](https://repo.anhonesthost.net/MacroPad/MP-Server/releases) page
2. Download for your operating system:
- Windows: `macropad.exe`
- Linux: `macropad`
- macOS: `MacroPad Server.app`
3. Run the downloaded file
> [!IMPORTANT] > [!IMPORTANT]
> The executable is unsigned and may trigger security warnings. You may need to click "More info" and "Run anyway" in Windows SmartScreen or add an exception in your antivirus software. > Executables are unsigned and may trigger security warnings. You may need to:
> - Windows: Click "More info" → "Run anyway" in SmartScreen
> - Linux: Run `chmod +x macropad` before executing
> - macOS: Right-click → Open, or adjust Gatekeeper settings
## Usage ## Usage
### Main Interface ### Running the Application
When launched, MP-Server displays your existing macros with options to: ```bash
# If installed with uv
uv run python main.py
- **Add New Macro**: Create text snippets or application shortcuts # If installed with pip
- **Edit Macro**: Modify existing macros python main.py
- **Delete Macro**: Remove unwanted macros ```
- **Minimize to Tray**: Hide the application to your system tray
- **Exit**: Close the application completely
### Creating a Macro ### Creating a Macro
1. Click the "Add Macro" button 1. Click **+ Add Macro** in the toolbar
2. Fill in the details: 2. Enter a **Name** and optional **Category**
- **Name**: A descriptive name for your macro 3. Build your command sequence using the buttons:
- **Type**: Choose between Text or Application - **+ Text**: Add text to type
- **Command/Text**: The text to insert or application command to run - **+ Key**: Add a key press (enter, tab, escape, etc.)
- **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter - **+ Hotkey**: Add a key combination (ctrl+c, alt+tab)
- **Image**: Optionally add an image for visual identification - **+ Wait**: Add a delay in milliseconds
3. Click "Save" to create your macro - **+ App**: Add an application to launch
4. Reorder commands with **Up/Down** buttons
5. Click **Save**
### Remote Access ### Example: Login Macro
The application runs a web server enabling remote access: A macro that types a username, waits, presses Tab, types a password, and presses Enter:
1. Note your computer's local IP address (shown in the application header) ```
2. From another device on the same network, open a web browser [TEXT] myusername
3. Navigate to `http://<your-ip-address>:40000` [WAIT] 200ms
4. Click on any macro to execute it on your main computer [KEY] tab
[TEXT] mypassword
[KEY] enter
```
### Web Interface (PWA)
1. Start the application (web server starts automatically on port 40000)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`)
3. Open this URL on any device on your network
4. **Install as PWA**:
- Mobile: Tap browser menu → "Add to Home Screen"
- Desktop: Click install icon in address bar
The web interface provides full macro management:
- View and execute macros
- Create and edit macros with command builder
- Organize into categories
- Real-time sync across devices
### System Tray ### System Tray
When minimized to the system tray: - Minimize window → App continues in tray
- Right-click the tray icon to show options - Right-click tray icon:
- Select "Show" to restore the window - **Show**: Restore window
- Select "Exit" to close the application - **Quit**: Exit application
## Command Types Reference
| Type | Description | Parameters |
|------|-------------|------------|
| `text` | Types a text string | `value`: The text to type |
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
| `wait` | Delays execution | `ms`: Milliseconds to wait |
| `app` | Launches application | `command`: Shell command to execute |
## Example Application Commands ## Example Application Commands
### Windows Examples ### Windows
```bash
# Steam game
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
#### Steam Applications # Chrome to website
```"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030``` "C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com
#### Chrome to a website # Open folder
explorer.exe "C:\Users\YourUsername\Documents"
```
```"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao``` ### Linux
```bash
# Firefox
firefox https://example.com
# Steam game
steam steam://rungameid/2767030
#### Special Thanks to CatArgent_ on Twitch for proof reading my stuff and providing valuable feedback. # Terminal
gnome-terminal
```
### macOS
```bash
# Safari
open -a Safari https://example.com
# VS Code
open -a "Visual Studio Code"
# Folder
open ~/Documents
```
## Building Executables
Build platform-specific executables using PyInstaller:
```bash
# Install PyInstaller
pip install pyinstaller
# Windows (run on Windows)
pyinstaller macropad.spec
# Linux (run on Linux)
pyinstaller macropad_linux.spec
# macOS (run on macOS)
pyinstaller macropad_macos.spec
```
> [!NOTE]
> PyInstaller cannot cross-compile. You must build on the target platform.
## Project Structure
```
MP-Server/
├── main.py # Application entry point
├── config.py # Configuration constants
├── macro_manager.py # Macro storage and execution
├── web_server.py # FastAPI web server
├── pyproject.toml # Dependencies and build config
├── gui/ # PySide6 desktop interface
│ ├── main_window.py
│ ├── macro_editor.py
│ └── command_builder.py
├── web/ # PWA web interface
│ ├── index.html
│ ├── manifest.json
│ ├── service-worker.js
│ ├── css/styles.css
│ ├── js/app.js
│ └── icons/
└── macros.json # Macro storage (auto-created)
```
## Migrating from v0.8.x
Existing macros are automatically migrated on first run. The old format:
```json
{
"type": "text",
"command": "Hello",
"modifiers": {"enter": true}
}
```
Becomes the new command sequence format:
```json
{
"type": "sequence",
"commands": [
{"type": "text", "value": "Hello"},
{"type": "key", "value": "enter"}
]
}
```
## License
MIT License
## Acknowledgments
Special thanks to CatArgent_ on Twitch for proofreading and providing valuable feedback.
+19
View File
@@ -0,0 +1,19 @@
# Configuration and constants for MacroPad Server
VERSION = "0.9.2"
DEFAULT_PORT = 40000
# UI Theme colors
THEME = {
'bg_color': "#2e2e2e",
'fg_color': "#ffffff",
'highlight_color': "#3e3e3e",
'accent_color': "#007acc",
'button_bg': "#505050",
'button_fg': "#ffffff",
'tab_bg': "#404040",
'tab_selected': "#007acc"
}
# File extensions for images
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp"]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
# MacroPad Server GUI Module
# PySide6-based desktop interface
from .main_window import MainWindow
from .macro_editor import MacroEditorDialog, CommandBuilder
__all__ = ['MainWindow', 'MacroEditorDialog', 'CommandBuilder']
+5
View File
@@ -0,0 +1,5 @@
# Command builder widget (re-exported from macro_editor for convenience)
from .macro_editor import CommandBuilder, CommandItem
__all__ = ['CommandBuilder', 'CommandItem']
+644
View File
@@ -0,0 +1,644 @@
# Macro editor dialog with command builder (PySide6)
import os
from typing import Optional, List, Dict
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea, QCompleter
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap, QIcon
from config import THEME, IMAGE_EXTENSIONS
class CommandItem(QWidget):
"""Widget representing a single command in the list."""
delete_clicked = Signal()
move_up_clicked = Signal()
move_down_clicked = Signal()
edit_clicked = Signal()
def __init__(self, command: dict, parent=None):
super().__init__(parent)
self.command = command
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(8)
# Type label
type_label = QLabel(command.get("type", "").upper())
type_label.setFixedWidth(60)
type_label.setStyleSheet(f"""
background-color: {THEME['accent_color']};
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
""")
type_label.setAlignment(Qt.AlignCenter)
layout.addWidget(type_label)
# Value label
value_label = QLabel(self._get_display_value())
value_label.setStyleSheet(f"color: {THEME['fg_color']}; font-family: monospace;")
layout.addWidget(value_label, 1)
# Action buttons
btn_style = f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 6px 8px;
border-radius: 4px;
min-height: 20px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
"""
edit_btn = QPushButton("Edit")
edit_btn.setFixedSize(50, 28)
edit_btn.setStyleSheet(btn_style)
edit_btn.clicked.connect(self.edit_clicked.emit)
layout.addWidget(edit_btn)
up_btn = QPushButton("^")
up_btn.setStyleSheet(btn_style)
up_btn.setFixedSize(30, 28)
up_btn.clicked.connect(self.move_up_clicked.emit)
layout.addWidget(up_btn)
down_btn = QPushButton("v")
down_btn.setStyleSheet(btn_style)
down_btn.setFixedSize(30, 28)
down_btn.clicked.connect(self.move_down_clicked.emit)
layout.addWidget(down_btn)
del_btn = QPushButton("X")
del_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
color: white;
border: none;
padding: 6px 8px;
border-radius: 4px;
min-height: 20px;
}}
QPushButton:hover {{
background-color: #c82333;
}}
""")
del_btn.setFixedSize(30, 28)
del_btn.clicked.connect(self.delete_clicked.emit)
layout.addWidget(del_btn)
def _get_display_value(self) -> str:
"""Get display text for the command."""
cmd_type = self.command.get("type", "")
if cmd_type == "text":
return self.command.get("value", "")[:50]
elif cmd_type == "key":
key = self.command.get("value", "")
return key.capitalize() if key else ""
elif cmd_type == "hotkey":
keys = self.command.get("keys", [])
return " + ".join(k.capitalize() for k in keys)
elif cmd_type == "wait":
return f"{self.command.get('ms', 0)}ms"
elif cmd_type == "app":
return self.command.get("command", "")[:50]
return ""
class CommandBuilder(QWidget):
"""Widget for building command sequences."""
commands_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.commands: List[dict] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Command list
self.list_widget = QListWidget()
self.list_widget.setStyleSheet(f"""
QListWidget {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
}}
QListWidget::item {{
padding: 4px;
}}
""")
self.list_widget.setMinimumHeight(150)
layout.addWidget(self.list_widget)
# Add command buttons
btn_layout = QHBoxLayout()
for cmd_type, label in [
("text", "+ Text"),
("key", "+ Key"),
("hotkey", "+ Hotkey"),
("wait", "+ Wait"),
("app", "+ App")
]:
btn = QPushButton(label)
btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 12px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
btn.clicked.connect(lambda checked, t=cmd_type: self.add_command(t))
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
def set_commands(self, commands: List[dict]):
"""Set the command list."""
self.commands = list(commands)
self.refresh()
def get_commands(self) -> List[dict]:
"""Get the command list."""
return list(self.commands)
def refresh(self):
"""Refresh the command list display."""
self.list_widget.clear()
for i, cmd in enumerate(self.commands):
item = QListWidgetItem(self.list_widget)
widget = CommandItem(cmd)
widget.delete_clicked.connect(lambda idx=i: self.remove_command(idx))
widget.move_up_clicked.connect(lambda idx=i: self.move_command(idx, -1))
widget.move_down_clicked.connect(lambda idx=i: self.move_command(idx, 1))
widget.edit_clicked.connect(lambda idx=i: self.edit_command(idx))
item.setSizeHint(widget.sizeHint())
self.list_widget.addItem(item)
self.list_widget.setItemWidget(item, widget)
def add_command(self, cmd_type: str):
"""Add a new command."""
command = {"type": cmd_type}
if cmd_type == "text":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(self, "Text Command", "Enter text to type:")
if not ok or not text:
return
command["value"] = text
elif cmd_type == "key":
from PySide6.QtWidgets import QInputDialog
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
if not ok or not key:
return
command["value"] = key.lower()
elif cmd_type == "hotkey":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(
self, "Hotkey Command",
"Enter key combination (comma separated, e.g., ctrl,c):"
)
if not ok or not text:
return
command["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
from PySide6.QtWidgets import QInputDialog
ms, ok = QInputDialog.getInt(
self, "Wait Command",
"Enter delay in milliseconds:",
500, 0, 60000, 100
)
if not ok:
return
command["ms"] = ms
elif cmd_type == "app":
from PySide6.QtWidgets import QInputDialog
cmd, ok = QInputDialog.getText(
self, "App Command",
"Enter application command:\n\n"
"Examples:\n"
" Windows: notepad.exe\n"
" Windows: \"C:\\Program Files\\App\\app.exe\"\n"
" Linux: firefox https://example.com\n"
" macOS: open -a Safari"
)
if not ok or not cmd:
return
command["command"] = cmd
self.commands.append(command)
self.refresh()
self.commands_changed.emit()
def remove_command(self, index: int):
"""Remove a command at index."""
if 0 <= index < len(self.commands):
del self.commands[index]
self.refresh()
self.commands_changed.emit()
def move_command(self, index: int, direction: int):
"""Move a command up or down."""
new_index = index + direction
if 0 <= new_index < len(self.commands):
self.commands[index], self.commands[new_index] = \
self.commands[new_index], self.commands[index]
self.refresh()
self.commands_changed.emit()
def edit_command(self, index: int):
"""Edit a command at index."""
if not (0 <= index < len(self.commands)):
return
cmd = self.commands[index]
cmd_type = cmd.get("type", "")
from PySide6.QtWidgets import QInputDialog
if cmd_type == "text":
text, ok = QInputDialog.getText(
self, "Edit Text", "Enter text:",
text=cmd.get("value", "")
)
if ok and text:
cmd["value"] = text
elif cmd_type == "key":
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
keys_lower = [k.lower() for k in keys]
current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
if ok and key:
cmd["value"] = key.lower()
elif cmd_type == "hotkey":
text, ok = QInputDialog.getText(
self, "Edit Hotkey", "Enter key combination:",
text=",".join(cmd.get("keys", []))
)
if ok and text:
cmd["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
ms, ok = QInputDialog.getInt(
self, "Edit Wait", "Enter delay in milliseconds:",
cmd.get("ms", 500), 0, 60000, 100
)
if ok:
cmd["ms"] = ms
elif cmd_type == "app":
text, ok = QInputDialog.getText(
self, "Edit App",
"Enter application command:\n\n"
"Examples:\n"
" Windows: notepad.exe\n"
" Windows: \"C:\\Program Files\\App\\app.exe\"\n"
" Linux: firefox https://example.com\n"
" macOS: open -a Safari",
text=cmd.get("command", "")
)
if ok and text:
cmd["command"] = text
self.refresh()
self.commands_changed.emit()
class MacroEditorDialog(QDialog):
"""Dialog for creating/editing macros."""
def __init__(self, macro_manager, macro_id: Optional[str] = None, parent=None):
super().__init__(parent)
self.macro_manager = macro_manager
self.macro_id = macro_id
self.image_path = ""
self.setWindowTitle("Edit Macro" if macro_id else "Add Macro")
self.setMinimumSize(500, 500)
self.setStyleSheet(f"""
QDialog {{
background-color: {THEME['highlight_color']};
}}
QLabel {{
color: {THEME['fg_color']};
}}
QLineEdit {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 8px;
color: {THEME['fg_color']};
}}
QLineEdit:focus {{
border-color: {THEME['accent_color']};
}}
QComboBox {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 8px;
color: {THEME['fg_color']};
}}
QComboBox:focus {{
border-color: {THEME['accent_color']};
}}
QComboBox::drop-down {{
border: none;
width: 20px;
}}
QComboBox::down-arrow {{
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid {THEME['fg_color']};
margin-right: 5px;
}}
QComboBox QAbstractItemView {{
background-color: {THEME['bg_color']};
color: {THEME['fg_color']};
selection-background-color: {THEME['accent_color']};
border: 1px solid {THEME['button_bg']};
}}
""")
self.setup_ui()
# Load existing macro data if editing
if macro_id:
self.load_macro()
def setup_ui(self):
"""Setup the dialog UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Scroll area for content
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet("border: none;")
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(12)
# Name field
name_group = QGroupBox("Macro Name")
name_group.setStyleSheet(f"""
QGroupBox {{
color: {THEME['fg_color']};
font-weight: bold;
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
margin-top: 8px;
padding-top: 8px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
}}
""")
name_layout = QVBoxLayout(name_group)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Enter macro name")
name_layout.addWidget(self.name_input)
content_layout.addWidget(name_group)
# Category field
category_group = QGroupBox("Category (optional)")
category_group.setStyleSheet(name_group.styleSheet())
category_layout = QVBoxLayout(category_group)
self.category_input = QComboBox()
self.category_input.setEditable(True)
self.category_input.setInsertPolicy(QComboBox.NoInsert)
self.category_input.lineEdit().setPlaceholderText("Select or enter category")
# Populate with existing categories (excluding "All")
existing_categories = [c for c in self.macro_manager.get_unique_tabs() if c != "All"]
self.category_input.addItem("") # Empty option for no category
self.category_input.addItems(existing_categories)
category_layout.addWidget(self.category_input)
content_layout.addWidget(category_group)
# Command builder
commands_group = QGroupBox("Commands")
commands_group.setStyleSheet(name_group.styleSheet())
commands_layout = QVBoxLayout(commands_group)
self.command_builder = CommandBuilder()
commands_layout.addWidget(self.command_builder)
content_layout.addWidget(commands_group)
# Image selection
image_group = QGroupBox("Image (optional)")
image_group.setStyleSheet(name_group.styleSheet())
image_layout = QHBoxLayout(image_group)
self.image_preview = QLabel()
self.image_preview.setFixedSize(64, 64)
self.image_preview.setStyleSheet(f"""
background-color: {THEME['bg_color']};
border-radius: 4px;
""")
self.image_preview.setAlignment(Qt.AlignCenter)
image_layout.addWidget(self.image_preview)
image_btn_layout = QVBoxLayout()
select_btn = QPushButton("Select Image")
select_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
select_btn.clicked.connect(self.select_image)
image_btn_layout.addWidget(select_btn)
clear_btn = QPushButton("Clear Image")
clear_btn.setStyleSheet(select_btn.styleSheet())
clear_btn.clicked.connect(self.clear_image)
image_btn_layout.addWidget(clear_btn)
image_layout.addLayout(image_btn_layout)
image_layout.addStretch()
content_layout.addWidget(image_group)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Dialog buttons
btn_layout = QHBoxLayout()
if self.macro_id:
delete_btn = QPushButton("Delete")
delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #c82333;
}}
""")
delete_btn.clicked.connect(self.delete_macro)
btn_layout.addWidget(delete_btn)
btn_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 10px 20px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
}}
""")
save_btn.clicked.connect(self.save_macro)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
def load_macro(self):
"""Load existing macro data into the form."""
macro = self.macro_manager.get_macro(self.macro_id)
if not macro:
return
self.name_input.setText(macro.get("name", ""))
self.category_input.setCurrentText(macro.get("category", ""))
self.command_builder.set_commands(macro.get("commands", []))
if macro.get("image_path"):
self.image_path = macro["image_path"]
pixmap = QPixmap(self.image_path)
if not pixmap.isNull():
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def select_image(self):
"""Open file dialog to select an image."""
ext_filter = "Images (" + " ".join(f"*{ext}" for ext in IMAGE_EXTENSIONS) + ")"
file_path, _ = QFileDialog.getOpenFileName(
self, "Select Image", "", ext_filter
)
if file_path:
self.image_path = file_path
pixmap = QPixmap(file_path)
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def clear_image(self):
"""Clear the selected image."""
self.image_path = ""
self.image_preview.clear()
def save_macro(self):
"""Save the macro."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Error", "Please enter a macro name")
return
commands = self.command_builder.get_commands()
if not commands:
QMessageBox.warning(self, "Error", "Please add at least one command")
return
category = self.category_input.currentText().strip()
if self.macro_id:
# Update existing macro
self.macro_manager.update_macro(
self.macro_id,
name=name,
commands=commands,
category=category,
image_path=self.image_path if self.image_path else None
)
else:
# Create new macro
self.macro_manager.add_macro(
name=name,
commands=commands,
category=category,
image_path=self.image_path
)
self.accept()
def delete_macro(self):
"""Delete the current macro."""
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(self.macro_id)
self.accept()
+571
View File
@@ -0,0 +1,571 @@
# Main window for MacroPad Server (PySide6)
import os
import sys
import threading
from typing import Optional
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base_path, relative_path)
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTabWidget, QGridLayout,
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from config import VERSION, THEME, DEFAULT_PORT
from macro_manager import MacroManager
from web_server import WebServer
class MacroButton(QPushButton):
"""Custom button widget for displaying a macro."""
# Signals for context menu actions
edit_requested = Signal(str)
delete_requested = Signal(str)
def __init__(self, macro_id: str, macro: dict, parent=None):
super().__init__(parent)
self.macro_id = macro_id
self.macro = macro
self.setFixedSize(120, 100)
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
border-radius: 8px;
padding: 8px;
text-align: center;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
QPushButton:pressed {{
background-color: {THEME['accent_color']};
}}
""")
# Layout
layout = QVBoxLayout(self)
layout.setSpacing(4)
layout.setContentsMargins(4, 4, 4, 4)
# Image or placeholder
image_label = QLabel()
image_label.setFixedSize(48, 48)
image_label.setAlignment(Qt.AlignCenter)
if macro.get("image_path"):
pixmap = QPixmap(macro["image_path"])
if not pixmap.isNull():
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label.setPixmap(pixmap)
else:
self._set_placeholder(image_label, macro["name"])
else:
self._set_placeholder(image_label, macro["name"])
layout.addWidget(image_label, alignment=Qt.AlignCenter)
# Name label
name_label = QLabel(macro["name"])
name_label.setAlignment(Qt.AlignCenter)
name_label.setWordWrap(True)
name_label.setStyleSheet(f"color: {THEME['fg_color']}; font-size: 11px;")
layout.addWidget(name_label)
def _set_placeholder(self, label: QLabel, name: str):
"""Set a placeholder with the first letter of the name."""
label.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
border-radius: 8px;
font-size: 20px;
font-weight: bold;
color: {THEME['fg_color']};
""")
label.setText(name[0].upper() if name else "?")
def contextMenuEvent(self, event):
"""Show context menu on right-click."""
menu = QMenu(self)
edit_action = menu.addAction("Edit")
delete_action = menu.addAction("Delete")
action = menu.exec_(event.globalPos())
if action == edit_action:
self.edit_requested.emit(self.macro_id)
elif action == delete_action:
self.delete_requested.emit(self.macro_id)
class MainWindow(QMainWindow):
"""Main application window."""
macros_changed = Signal()
def __init__(self, app_dir: str):
super().__init__()
self.app_dir = app_dir
self.current_tab = "All"
self.sort_by = "name"
# Initialize macro manager
data_file = os.path.join(app_dir, "macros.json")
images_dir = os.path.join(app_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
# Initialize web server
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
self.server_thread = None
# Setup UI
self.setup_ui()
self.setup_menu()
self.setup_tray()
# Start web server
self.start_server()
# Connect signals
self.macros_changed.connect(self.refresh_macros)
# Load initial data
self.refresh_tabs()
self.refresh_macros()
def setup_ui(self):
"""Setup the main UI components."""
self.setWindowTitle(f"MacroPad Server v{VERSION}")
self.setMinimumSize(600, 400)
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
# Central widget
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Toolbar
toolbar = QWidget()
toolbar.setStyleSheet(f"background-color: {THEME['highlight_color']};")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(10, 10, 10, 10)
add_btn = QPushButton("+ Add Macro")
add_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
}}
""")
add_btn.clicked.connect(self.add_macro)
toolbar_layout.addWidget(add_btn)
toolbar_layout.addStretch()
# IP address label
self.ip_label = QLabel()
self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};")
self.update_ip_label()
toolbar_layout.addWidget(self.ip_label)
qr_btn = QPushButton("QR Code")
qr_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
qr_btn.clicked.connect(self.show_qr_code)
toolbar_layout.addWidget(qr_btn)
layout.addWidget(toolbar)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(f"""
QTabWidget::pane {{
border: none;
background: {THEME['bg_color']};
}}
QTabBar::tab {{
background: {THEME['tab_bg']};
color: {THEME['fg_color']};
padding: 8px 16px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}}
QTabBar::tab:selected {{
background: {THEME['tab_selected']};
}}
QTabBar::tab:hover {{
background: {THEME['highlight_color']};
}}
""")
self.tab_widget.currentChanged.connect(self.on_tab_changed)
layout.addWidget(self.tab_widget)
# Status bar
self.status_bar = QStatusBar()
self.status_bar.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
""")
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
def setup_menu(self):
"""Setup the menu bar."""
menubar = self.menuBar()
menubar.setStyleSheet(f"""
QMenuBar {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenuBar::item:selected {{
background-color: {THEME['accent_color']};
}}
QMenu {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenu::item:selected {{
background-color: {THEME['accent_color']};
}}
""")
# File menu
file_menu = menubar.addMenu("File")
add_action = QAction("Add Macro", self)
add_action.setShortcut("Ctrl+N")
add_action.triggered.connect(self.add_macro)
file_menu.addAction(add_action)
file_menu.addSeparator()
quit_action = QAction("Quit", self)
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
# View menu
view_menu = menubar.addMenu("View")
refresh_action = QAction("Refresh", self)
refresh_action.setShortcut("F5")
refresh_action.triggered.connect(self.refresh_all)
view_menu.addAction(refresh_action)
# Sort submenu
sort_menu = view_menu.addMenu("Sort By")
for sort_option in [("Name", "name"), ("Type", "type"), ("Recent", "recent")]:
action = QAction(sort_option[0], self)
action.triggered.connect(lambda checked, s=sort_option[1]: self.set_sort(s))
sort_menu.addAction(action)
# Help menu
help_menu = menubar.addMenu("Help")
about_action = QAction("About", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def setup_tray(self):
"""Setup system tray icon."""
self.tray_icon = QSystemTrayIcon(self)
# Load icon from bundled resources
icon_path = get_resource_path("Macro Pad.png")
if os.path.exists(icon_path):
icon = QIcon(icon_path)
self.tray_icon.setIcon(icon)
self.setWindowIcon(icon)
else:
self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
# Tray menu
tray_menu = QMenu()
show_action = tray_menu.addAction("Show")
show_action.triggered.connect(self.show)
quit_action = tray_menu.addAction("Quit")
quit_action.triggered.connect(self.close)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.activated.connect(self.on_tray_activated)
self.tray_icon.show()
def on_tray_activated(self, reason):
"""Handle tray icon activation."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.show()
self.activateWindow()
def start_server(self):
"""Start the web server in a background thread."""
self.server_error = None
def run():
try:
self.web_server.create_app()
self.web_server.run()
except Exception as e:
self.server_error = str(e)
# Emit signal to show error on main thread
QTimer.singleShot(0, self.show_server_error)
self.server_thread = threading.Thread(target=run, daemon=True)
self.server_thread.start()
# Give the server a moment to start, then check for errors
QTimer.singleShot(1000, self.check_server_started)
def check_server_started(self):
"""Check if server started successfully."""
if self.server_error:
self.show_server_error()
else:
self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}")
def show_server_error(self):
"""Show server error dialog."""
error_msg = self.server_error or "Unknown error"
self.status_bar.showMessage(f"Server failed to start")
QMessageBox.warning(
self,
"Web Server Error",
f"Failed to start web server on port {DEFAULT_PORT}.\n\n"
f"Error: {error_msg}\n\n"
"The web interface will not be available.\n"
"Check if another application is using the port."
)
def stop_server(self):
"""Stop the web server."""
if self.web_server:
self.web_server.stop()
self.status_bar.showMessage("Server stopped")
def update_ip_label(self):
"""Update the IP address label."""
try:
import netifaces
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr', '')
if ip and not ip.startswith('127.'):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
return
except Exception:
pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
def refresh_tabs(self):
"""Refresh the tab widget."""
self.tab_widget.blockSignals(True)
self.tab_widget.clear()
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(f"background-color: {THEME['bg_color']}; border: none;")
container = QWidget()
container.setStyleSheet(f"background-color: {THEME['bg_color']};")
scroll.setWidget(container)
self.tab_widget.addTab(scroll, tab_name)
self.tab_widget.blockSignals(False)
def refresh_macros(self):
"""Refresh the macro grid."""
current_index = self.tab_widget.currentIndex()
if current_index < 0:
return
scroll = self.tab_widget.widget(current_index)
container = scroll.widget()
# Clear existing layout
if container.layout():
while container.layout().count():
item = container.layout().takeAt(0)
if item.widget():
item.widget().deleteLater()
else:
container.setLayout(QGridLayout())
layout = container.layout()
layout.setSpacing(10)
layout.setContentsMargins(10, 10, 10, 10)
layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
# Get macros for current tab
tab_name = self.tab_widget.tabText(current_index)
macro_list = self.macro_manager.get_sorted_macros(self.sort_by)
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
# Add macro buttons
cols = max(1, (self.width() - 40) // 130)
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
btn.edit_requested.connect(self.edit_macro)
btn.delete_requested.connect(self.delete_macro)
layout.addWidget(btn, i // cols, i % cols)
def on_tab_changed(self, index):
"""Handle tab change."""
self.refresh_macros()
def execute_macro(self, macro_id: str):
"""Execute a macro."""
success = self.macro_manager.execute_macro(macro_id)
if success:
self.status_bar.showMessage("Macro executed", 2000)
else:
self.status_bar.showMessage("Macro execution failed", 2000)
def add_macro(self):
"""Open dialog to add a new macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def edit_macro(self, macro_id: str):
"""Open dialog to edit a macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, macro_id=macro_id, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def delete_macro(self, macro_id: str):
"""Delete a macro with confirmation."""
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(macro_id)
self.refresh_tabs()
self.refresh_macros()
def set_sort(self, sort_by: str):
"""Set the sort order."""
self.sort_by = sort_by
self.refresh_macros()
def refresh_all(self):
"""Refresh tabs and macros."""
self.refresh_tabs()
self.refresh_macros()
def show_qr_code(self):
"""Show QR code dialog."""
try:
import qrcode
from PySide6.QtWidgets import QDialog, QVBoxLayout
from io import BytesIO
url = self.ip_label.text()
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to QPixmap
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
pixmap = QPixmap()
pixmap.loadFromData(buffer.read())
# Show dialog
dialog = QDialog(self)
dialog.setWindowTitle("QR Code")
layout = QVBoxLayout(dialog)
label = QLabel()
label.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation))
layout.addWidget(label)
url_label = QLabel(url)
url_label.setStyleSheet(f"color: {THEME['fg_color']};")
url_label.setAlignment(Qt.AlignCenter)
layout.addWidget(url_label)
dialog.exec_()
except ImportError:
QMessageBox.warning(self, "Error", "QR code library not available")
def show_about(self):
"""Show about dialog."""
about_box = QMessageBox(self)
about_box.setWindowTitle("About MacroPad Server")
about_box.setTextFormat(Qt.RichText)
about_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
about_box.setText(
f"<h2>MacroPad Server v{VERSION}</h2>"
"<p>A cross-platform macro management application<br>"
"with desktop and web interfaces.</p>"
"<p><b>Author:</b> <a href='https://shadowdao.com'>ShadowDao</a></p>"
"<p><b>Updates:</b> <a href='https://shadowdao.com'>shadowdao.com</a></p>"
"<p><b>Donate:</b> <a href='https://liberapay.com/GoTakeAKnapp/'>Liberapay</a></p>"
)
about_box.setStandardButtons(QMessageBox.Ok)
about_box.exec()
def closeEvent(self, event):
"""Handle window close."""
# Stop the web server
self.stop_server()
# Hide tray icon
self.tray_icon.hide()
event.accept()
def resizeEvent(self, event):
"""Handle window resize."""
super().resizeEvent(event)
self.refresh_macros()
+1
View File
@@ -0,0 +1 @@
# MacroPad Server GUI Widgets
+359
View File
@@ -0,0 +1,359 @@
# Macro management and execution with command sequence support
import copy
import json
import os
import uuid
import pyautogui
import subprocess
import time
from PIL import Image
from typing import Optional
class MacroManager:
"""Manages macro storage, migration, and execution with command sequences."""
def __init__(self, data_file: str, images_dir: str, app_dir: str):
self.data_file = data_file
self.images_dir = images_dir
self.app_dir = app_dir
self.macros = {}
self.load_macros()
def load_macros(self):
"""Load macros from JSON file and migrate if needed."""
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
if migrated:
self.save_macros()
print("Migrated macros to new command sequence format")
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def _migrate_macro(self, old_macro: dict) -> dict:
"""Convert old macro format to new command sequence format."""
if old_macro.get("type") == "sequence":
return old_macro
commands = []
modifiers = old_macro.get("modifiers", {})
if old_macro.get("type") == "text":
# Build held keys list
held_keys = []
for mod in ["ctrl", "alt", "shift"]:
if modifiers.get(mod):
held_keys.append(mod)
if held_keys:
# Use hotkey for modified text
commands.append({
"type": "hotkey",
"keys": held_keys + [old_macro.get("command", "")]
})
else:
# Plain text
commands.append({
"type": "text",
"value": old_macro.get("command", "")
})
# Add enter if requested
if modifiers.get("enter"):
commands.append({"type": "key", "value": "enter"})
elif old_macro.get("type") == "app":
commands.append({
"type": "app",
"command": old_macro.get("command", "")
})
return {
"name": old_macro.get("name", "Unnamed"),
"type": "sequence",
"commands": commands,
"category": old_macro.get("category", ""),
"image_path": old_macro.get("image_path", ""),
"last_used": old_macro.get("last_used", 0)
}
def save_macros(self):
"""Save macros to JSON file."""
try:
with open(self.data_file, "w") as file:
json.dump(self.macros, file, indent=4)
except Exception as e:
print(f"Error saving macros: {e}")
def get_sorted_macros(self, sort_by: str = "name"):
"""Get macros sorted by specified criteria."""
macro_list = list(self.macros.items())
if sort_by == "name":
macro_list.sort(key=lambda x: x[1]["name"].lower())
elif sort_by == "type":
# Sort by first command type in sequence
def get_first_type(macro):
cmds = macro.get("commands", [])
return cmds[0].get("type", "") if cmds else ""
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
elif sort_by == "recent":
macro_list.sort(
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
reverse=True
)
return macro_list
def filter_macros_by_tab(self, macro_list: list, tab_name: str):
"""Filter macros based on tab/category name."""
if tab_name == "All":
return macro_list
filtered = []
for macro_id, macro in macro_list:
# Check category match
if macro.get("category") == tab_name:
filtered.append((macro_id, macro))
# Check first command type match
elif macro.get("commands"):
first_type = macro["commands"][0].get("type", "").title()
if first_type == tab_name:
filtered.append((macro_id, macro))
return filtered
def get_unique_tabs(self) -> list:
"""Get list of unique tabs based on categories."""
tabs = ["All"]
categories = set()
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
for category in sorted(categories):
if category and category not in tabs:
tabs.append(category)
return tabs
def add_macro(
self,
name: str,
commands: list,
category: str = "",
image_path: str = ""
) -> str:
"""Add a new macro with command sequence."""
macro_id = str(uuid.uuid4())
# Process image if provided
image_path_reference = self._process_image(image_path) if image_path else ""
# Create macro data (deep copy commands to avoid reference issues)
macro_data = {
"name": name,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"last_used": 0
}
self.macros[macro_id] = macro_data
self.save_macros()
return macro_id
def update_macro(
self,
macro_id: str,
name: str,
commands: list,
category: str = "",
image_path: Optional[str] = None
) -> bool:
"""Update an existing macro."""
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
# Keep old image or update with new one
if image_path is not None:
image_path_reference = self._process_image(image_path) if image_path else ""
else:
image_path_reference = macro.get("image_path", "")
# Update macro data (deep copy commands to avoid reference issues)
self.macros[macro_id] = {
"name": name,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"last_used": macro.get("last_used", 0)
}
self.save_macros()
return True
def _process_image(self, image_path: str) -> str:
"""Process and store an image for a macro."""
if not image_path:
return ""
try:
file_ext = os.path.splitext(image_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(image_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
return os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
return ""
def delete_macro(self, macro_id: str) -> bool:
"""Delete a macro."""
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
# Delete associated image file
if macro.get("image_path"):
try:
img_path = os.path.join(self.app_dir, macro["image_path"])
if os.path.exists(img_path):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[macro_id]
self.save_macros()
return True
def execute_macro(self, macro_id: str) -> bool:
"""Execute a macro by ID."""
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
# Update last_used timestamp
self.macros[macro_id]["last_used"] = time.time()
self.save_macros()
try:
commands = macro.get("commands", [])
for cmd in commands:
self._execute_command(cmd)
return True
except Exception as e:
print(f"Error executing macro: {e}")
return False
def _execute_command(self, cmd: dict):
"""Execute a single command from a sequence."""
cmd_type = cmd.get("type", "")
if cmd_type == "text":
# Type text string using clipboard for Unicode support
value = cmd.get("value", "")
if value:
try:
import pyperclip
# Save current clipboard, paste text, restore (optional)
pyperclip.copy(value)
pyautogui.hotkey("ctrl", "v")
time.sleep(0.05) # Small delay after paste
except ImportError:
# Fallback to typewrite for ASCII-only
if len(value) == 1:
pyautogui.press(value)
else:
pyautogui.typewrite(value, interval=0.02)
elif cmd_type == "key":
# Press a single key
key = cmd.get("value", "")
if key:
pyautogui.press(key)
elif cmd_type == "hotkey":
# Press key combination
keys = cmd.get("keys", [])
if keys:
pyautogui.hotkey(*keys)
elif cmd_type == "wait":
# Delay in milliseconds
ms = cmd.get("ms", 0)
if ms > 0:
time.sleep(ms / 1000.0)
elif cmd_type == "app":
# Launch application
command = cmd.get("command", "")
if command:
subprocess.Popen(command, shell=True)
# Legacy API compatibility methods
def add_macro_legacy(
self,
name: str,
macro_type: str,
command: str,
category: str = "",
modifiers: Optional[dict] = None,
image_path: str = ""
) -> str:
"""Add macro using legacy format (auto-converts to sequence)."""
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
# Build command sequence
commands = []
held_keys = []
for mod in ["ctrl", "alt", "shift"]:
if modifiers.get(mod):
held_keys.append(mod)
if macro_type == "text":
if held_keys:
commands.append({"type": "hotkey", "keys": held_keys + [command]})
else:
commands.append({"type": "text", "value": command})
if modifiers.get("enter"):
commands.append({"type": "key", "value": "enter"})
elif macro_type == "app":
commands.append({"type": "app", "command": command})
return self.add_macro(name, commands, category, image_path)
def get_macro(self, macro_id: str) -> Optional[dict]:
"""Get a macro by ID."""
return self.macros.get(macro_id)
def get_all_macros(self) -> dict:
"""Get all macros."""
return self.macros.copy()
+82
View File
@@ -0,0 +1,82 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Windows)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='macropad',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='Macro Pad.png',
)
+97
View File
@@ -0,0 +1,97 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Linux)
import os
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
'PySide6.QtDBus',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'pystray._base',
'netifaces',
'websockets',
'multipart',
# Linux system tray
'gi',
'gi.repository.Gtk',
'gi.repository.AppIndicator3',
'gi.repository.GdkPixbuf',
# Packaging
'packaging.version',
'packaging.specifiers',
'packaging.requirements',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='macropad',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # Set to True for debugging
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+93
View File
@@ -0,0 +1,93 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (macOS)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='macropad',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=True,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
app = BUNDLE(
exe,
name='MacroPad Server.app',
icon='Macro Pad.png',
bundle_identifier='com.macropad.server',
info_plist={
'CFBundleShortVersionString': '0.9.2',
'CFBundleName': 'MacroPad Server',
'NSHighResolutionCapable': True,
},
)
+63
View File
@@ -0,0 +1,63 @@
# Main application file for MacroPad Server
# PySide6 version
import os
import sys
import multiprocessing
def get_app_dir():
"""Get the application directory (where macros.json and macro_images are stored)."""
if getattr(sys, 'frozen', False):
# Running as compiled executable - use executable's directory for user data
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.abspath(__file__))
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
# Running as compiled executable - resources are in _MEIPASS
base_path = sys._MEIPASS
else:
# Running as script - resources are in the script directory
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def main():
"""Main entry point."""
# Required for multiprocessing on Windows
multiprocessing.freeze_support()
# Get directories
app_dir = get_app_dir()
# Import PySide6 after freeze_support
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
# Create application
app = QApplication(sys.argv)
app.setApplicationName("MacroPad Server")
app.setOrganizationName("MacroPad")
# Set application icon (from bundled resources)
icon_path = get_resource_path("Macro Pad.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
# Import and create main window
from gui.main_window import MainWindow
window = MainWindow(app_dir)
window.show()
# Run application
sys.exit(app.exec())
if __name__ == "__main__":
main()
-971
View File
@@ -1,971 +0,0 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import json
import os
import uuid
import pyautogui
import subprocess
import threading
import pystray
from PIL import Image, ImageTk
from flask import Flask, render_template_string, request, jsonify, send_file
import webbrowser
from waitress import serve
import logging
import socket
import qrcode
import sys
import time
class MacroPadServer:
def __init__(self, root):
self.root = root
self.root.title("MacroPad Server")
self.root.geometry("800x600")
self.configure_styles()
# Set Version Str
self.version_str = "0.5.4 Beta"
# Set up directories
if getattr(sys, 'frozen', False):
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
self.data_file = os.path.join(base_dir, "macros.json")
self.images_dir = os.path.join(base_dir, "macro_images")
self.app_dir = base_dir
os.makedirs(self.images_dir, exist_ok=True)
self.macros = {}
self.load_macros()
self.image_cache = {} # Cache for images
# Set up server
self.server_running = False
self.web_app = None
self.flask_thread = None
self.server_port = 40000
# Create UI
self.create_ui()
# Set up window close handler
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def configure_styles(self):
# Dark theme
self.bg_color = "#2e2e2e"
self.fg_color = "#ffffff"
self.highlight_color = "#3e3e3e"
self.accent_color = "#007acc"
self.button_bg = "#505050"
self.button_fg = "#ffffff"
self.root.configure(bg=self.bg_color)
# Configure ttk styles
style = ttk.Style()
style.theme_use("clam")
style.configure("TButton", background=self.button_bg, foreground=self.button_fg)
style.map("TButton", background=[("active", self.accent_color)])
style.configure("TFrame", background=self.bg_color)
style.configure("TLabel", background=self.bg_color, foreground=self.fg_color)
def create_ui(self):
# Create main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Left side: Macro list and buttons
left_frame = ttk.Frame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Macro list
self.listbox_frame = ttk.Frame(left_frame)
self.listbox_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Right side: Details and server controls
right_frame = ttk.Frame(main_frame)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0))
#Set version details
version_label = tk.Label(self.root, text=self.version_str,
bg=self.bg_color, fg=self.fg_color,
font=('Helvetica', 8)) # Using smaller font for version text
version_label.pack(side=tk.BOTTOM, anchor=tk.SE, padx=5, pady=(0, 2))
# Button container
button_frame = ttk.Frame(left_frame)
button_frame.pack(fill=tk.X, pady=(0, 10))
button_style = {'style': 'TButton'}
# Add buttons
ttk.Button(button_frame, text="Add Macro", command=self.add_macro, **button_style).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Edit Macro", command=self.edit_macro, **button_style).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Delete Macro", command=self.delete_macro, **button_style).pack(side=tk.LEFT, padx=2)
# Server controls
server_frame = ttk.Frame(right_frame)
server_frame.pack(fill=tk.X, pady=10)
ttk.Label(server_frame, text="Web Server Control:").grid(row=0, column=0, sticky="w", pady=5)
self.server_button = ttk.Button(server_frame, text="Start Web Server", command=self.toggle_server)
self.server_button.grid(row=0, column=1, padx=5, pady=5)
# Status display
self.status_var = tk.StringVar(value="Web server not running")
ttk.Label(server_frame, textvariable=self.status_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# QR code placeholder
self.qr_label = ttk.Label(right_frame)
self.qr_label.pack(pady=10)
# Server URL display
self.url_var = tk.StringVar(value="")
self.url_label = ttk.Label(right_frame, textvariable=self.url_var)
self.url_label.pack(pady=5)
# Open in browser button
self.browser_button = ttk.Button(right_frame, text="Open in Browser",
command=self.open_in_browser, state=tk.DISABLED)
self.browser_button.pack(pady=5)
# Display any existing macros
self.display_macros()
def load_macros(self):
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def save_macros(self):
try:
with open(self.data_file, "w") as file:
json.dump(self.macros, file, indent=4)
except Exception as e:
print(f"Error saving macros: {e}")
def display_macros(self):
# Clear the previous listbox
for widget in self.listbox_frame.winfo_children():
widget.destroy()
# Create scrollable canvas for macros
canvas = tk.Canvas(self.listbox_frame, bg=self.bg_color, highlightthickness=0)
scrollbar = ttk.Scrollbar(self.listbox_frame, orient="vertical", command=canvas.yview)
scrollable_frame = ttk.Frame(canvas)
# Configure canvas and scrollable frame
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
# Pack canvas and scrollbar
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Display macros
row = 0
for macro_id, macro in self.macros.items():
frame = ttk.Frame(scrollable_frame)
frame.pack(fill="x", pady=5, padx=5)
# Create button with image if available
button_text = macro["name"]
button = tk.Button(
frame, text=button_text, bg=self.button_bg, fg=self.button_fg,
activebackground=self.accent_color, activeforeground=self.button_fg,
relief=tk.RAISED, bd=2, pady=8, command=lambda mid=macro_id: self.execute_macro(mid)
)
# Try to display image on button
if "image_path" in macro and macro["image_path"]:
try:
if macro["image_path"] in self.image_cache:
button_image = self.image_cache[macro["image_path"]]
else:
img_path = os.path.join(self.app_dir, macro["image_path"])
img = Image.open(img_path)
img = img.resize((32, 32))
button_image = ImageTk.PhotoImage(img)
self.image_cache[macro["image_path"]] = button_image
button.config(image=button_image, compound=tk.LEFT)
button.image = button_image # Keep a reference
except Exception as e:
print(f"Error loading image for {macro['name']}: {e}")
button.pack(side=tk.LEFT, fill=tk.X, expand=True)
row += 1
# Display message if no macros
if not self.macros:
label = tk.Label(scrollable_frame, text="No macros defined",
bg=self.bg_color, fg=self.fg_color)
label.pack(pady=20)
def toggle_server(self):
if self.server_running:
self.stop_server()
else:
self.start_server()
def start_server(self):
try:
if not self.server_running:
self.server_running = True
self.flask_thread = threading.Thread(target=self.run_web_server)
self.flask_thread.daemon = True
self.flask_thread.start()
self.server_button.config(text="Stop Web Server")
# Get the systems internal IP address
ip_address = self.get_ip_addresses()
if ip_address:
# Set the URL display
url = f"http://{ip_address}:{self.server_port}"
url_text = "Web UI available at:\n" + "\n" + url
self.url_var.set(url_text)
# Enable browser button
self.browser_button.config(state=tk.NORMAL)
# Generate and display QR code for the first IP
self.generate_qr_code(url)
else:
self.url_var.set("No network interfaces found")
except Exception as e:
self.status_var.set(f"Error starting server: {e}")
self.server_running = False
def stop_server(self):
if self.server_running:
self.server_running = False
self.status_var.set("Web server stopped")
self.server_button.config(text="Start Web Server")
self.url_var.set("")
self.browser_button.config(state=tk.DISABLED)
# Clear QR code
self.qr_label.config(image="")
# The Flask server will be stopped on the next request
def get_ip_addresses(self):
"""Get the primary internal IPv4 address of the machine."""
try:
# Create a socket to connect to an external server
# This helps determine which network interface is used for outbound connections
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# We don't need to actually send data - just configure the socket
s.connect(("8.8.8.8", 80))
# Get the IP address that would be used for this connection
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
print(f"Error getting IP address: {e}")
# Fallback method if the above doesn't work
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
# Don't return localhost address
if ip.startswith("127."):
for addr_info in socket.getaddrinfo(hostname, None):
potential_ip = addr_info[4][0]
# If IPv4 and not localhost
if '.' in potential_ip and not potential_ip.startswith("127."):
return potential_ip
else:
return ip
except:
return "127.0.0.1" # Last resort fallback
def generate_qr_code(self, url):
try:
# Try to import qrcode
import qrcode
# Generate QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
# Create an image from the QR code
qr_img = qr.make_image(fill_color="black", back_color="white")
# Convert to PhotoImage for display
qr_photoimg = ImageTk.PhotoImage(qr_img)
# Update label
self.qr_label.config(image=qr_photoimg)
self.qr_label.image = qr_photoimg # Keep a reference
except ImportError:
self.qr_label.config(text="QR code generation requires 'qrcode' package")
except Exception as e:
print(f"Error generating QR code: {e}")
self.qr_label.config(text="Error generating QR code")
def open_in_browser(self):
# Open the web interface in the default browser
if self.server_running:
webbrowser.open(f"http://localhost:{self.server_port}")
def create_web_app(self):
app = Flask(__name__)
# Disable Flask's logging except for errors
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
# Define HTML templates
index_html = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad Web Interface</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #2e2e2e;
color: #ffffff;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
h1 {
color: #007acc;
margin: 0;
}
.refresh-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
}
.refresh-button:hover {
background-color: #007acc;
}
.refresh-button svg {
margin-right: 5px;
}
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.macro-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 15px 10px;
text-align: center;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100px;
}
.macro-button:hover, .macro-button:active {
background-color: #007acc;
}
.macro-button img {
max-width: 64px;
max-height: 64px;
margin-bottom: 10px;
}
.status {
text-align: center;
margin: 20px 0;
padding: 10px;
border-radius: 5px;
}
.success {
background-color: #4CAF50;
color: white;
display: none;
}
.error {
background-color: #f44336;
color: white;
display: none;
}
@media (max-width: 600px) {
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.macro-button {
padding: 10px 5px;
font-size: 14px;
}
h1 {
font-size: 24px;
}
.refresh-button {
padding: 8px 12px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="header-container">
<h1>MacroPad Web Interface</h1>
<button class="refresh-button" onclick="loadMacros()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Refresh
</button>
</div>
<div class="status success" id="success-status">Macro executed successfully!</div>
<div class="status error" id="error-status">Failed to execute macro</div>
<div class="macro-grid" id="macro-grid">
<!-- Macros will be loaded here -->
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
loadMacros();
});
function loadMacros() {
fetch('/api/macros')
.then(response => response.json())
.then(macros => {
const macroGrid = document.getElementById('macro-grid');
macroGrid.innerHTML = '';
if (Object.keys(macros).length === 0) {
macroGrid.innerHTML = '<p style="grid-column: 1 / -1; text-align: center;">No macros defined.</p>';
return;
}
for (const [macroId, macro] of Object.entries(macros)) {
const button = document.createElement('button');
button.className = 'macro-button';
button.onclick = function() { executeMacro(macroId); };
// Add image if available
if (macro.image_path) {
const img = document.createElement('img');
img.src = `/api/image/${encodeURIComponent(macro.image_path)}`;
img.alt = macro.name;
img.onerror = function() {
this.style.display = 'none';
};
button.appendChild(img);
}
const text = document.createTextNode(macro.name);
button.appendChild(text);
macroGrid.appendChild(button);
}
})
.catch(error => {
console.error('Error loading macros:', error);
});
}
function executeMacro(macroId) {
fetch('/api/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ macro_id: macroId })
})
.then(response => response.json())
.then(data => {
const successStatus = document.getElementById('success-status');
const errorStatus = document.getElementById('error-status');
if (data.success) {
successStatus.style.display = 'block';
errorStatus.style.display = 'none';
setTimeout(() => { successStatus.style.display = 'none'; }, 2000);
} else {
errorStatus.style.display = 'block';
successStatus.style.display = 'none';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
}
})
.catch(error => {
console.error('Error executing macro:', error);
const errorStatus = document.getElementById('error-status');
errorStatus.style.display = 'block';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
});
}
</script>
</body>
</html>
'''
@app.route('/')
def index():
return render_template_string(index_html)
@app.route('/api/macros')
def get_macros():
return jsonify(self.macros)
@app.route('/api/image/<path:image_path>')
def get_image(image_path):
try:
# Using os.path.join would translate slashes incorrectly on Windows
# Use normpath instead
image_path = os.path.join(self.app_dir, image_path)
return send_file(image_path)
except Exception as e:
return str(e), 404
@app.route('/api/execute', methods=['POST'])
def execute_macro():
if not self.server_running:
return jsonify({"success": False, "error": "Server is shutting down"})
data = request.get_json()
if not data or 'macro_id' not in data:
return jsonify({"success": False, "error": "Invalid request"})
macro_id = data['macro_id']
success = self.execute_macro(macro_id)
return jsonify({"success": success})
return app
def run_web_server(self):
# Create Flask app
self.web_app = self.create_web_app()
# Update the status in the GUI
self.status_var.set(f"Web server running on port {self.server_port}")
# Run the Flask app with waitress for production-ready serving
try:
serve(self.web_app, host='0.0.0.0', port=self.server_port, threads=4)
except Exception as e:
self.status_var.set(f"Web server error: {e}")
self.server_running = False
def add_macro(self):
dialog = tk.Toplevel(self.root)
dialog.title("Add Macro")
dialog.geometry("450x350") # Increased height for additional options
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Apply dark theme to dialog
tk.Label(dialog, text="Macro Name:", bg=self.bg_color, fg=self.fg_color).grid(row=0, column=0, padx=5, pady=5, sticky="w")
name_entry = tk.Entry(dialog, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
name_entry.grid(row=0, column=1, padx=5, pady=5)
tk.Label(dialog, text="Type:", bg=self.bg_color, fg=self.fg_color).grid(row=1, column=0, padx=5, pady=5, sticky="w")
type_var = tk.StringVar(value="text")
radio_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color}
tk.Radiobutton(dialog, text="Text", variable=type_var, value="text", **radio_style).grid(row=1, column=1, sticky="w")
tk.Radiobutton(dialog, text="Application", variable=type_var, value="app", **radio_style).grid(row=2, column=1, sticky="w")
tk.Label(dialog, text="Command/Text:", bg=self.bg_color, fg=self.fg_color).grid(row=3, column=0, padx=5, pady=5, sticky="w")
command_text = tk.Text(dialog, width=30, height=5, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
command_text.grid(row=3, column=1, padx=5, pady=5)
# Modifiers frame
mod_frame = tk.Frame(dialog, bg=self.bg_color)
mod_frame.grid(row=4, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=self.bg_color, fg=self.fg_color).pack(side=tk.LEFT, padx=5)
# Add checkboxes for modifiers
ctrl_var = tk.BooleanVar(value=False)
alt_var = tk.BooleanVar(value=False)
shift_var = tk.BooleanVar(value=False)
enter_var = tk.BooleanVar(value=False)
checkbox_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color,
'activebackground': self.bg_color, 'activeforeground': self.fg_color}
tk.Checkbutton(mod_frame, text="Ctrl", variable=ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
image_path = tk.StringVar()
tk.Label(dialog, text="Image:", bg=self.bg_color, fg=self.fg_color).grid(row=5, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(dialog, textvariable=image_path, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
image_entry.grid(row=5, column=1, padx=5, pady=5)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Browse...", command=lambda: image_path.set(filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])), **button_style).grid(row=5, column=2)
def save_macro():
name = name_entry.get().strip()
if not name:
tk.messagebox.showerror("Error", "Macro name is required")
return
macro_type = type_var.get()
command = command_text.get("1.0", tk.END).strip()
macro_id = str(uuid.uuid4())
# Process image if provided
image_path_reference = ""
img_path = image_path.get()
if img_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(img_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(img_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
# Create macro with modifier keys
self.macros[macro_id] = {
"name": name,
"type": macro_type,
"command": command,
"image_path": image_path_reference,
"modifiers": {
"ctrl": ctrl_var.get(),
"alt": alt_var.get(),
"shift": shift_var.get(),
"enter": enter_var.get()
}
}
self.save_macros()
self.display_macros()
dialog.destroy()
tk.Button(dialog, text="Save", command=save_macro, **button_style).grid(row=6, column=0, padx=5, pady=20)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).grid(row=6, column=1, padx=5, pady=20)
def edit_macro(self):
# First, show a dialog to select which macro to edit
if not self.macros:
tk.messagebox.showinfo("No Macros", "There are no macros to edit.")
return
dialog = tk.Toplevel(self.root)
dialog.title("Select Macro to Edit")
dialog.geometry("200x340")
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Create a listbox to show available macros
tk.Label(dialog, text="Select a macro to edit:", bg=self.bg_color, fg=self.fg_color).pack(pady=5)
listbox = tk.Listbox(dialog, bg=self.highlight_color, fg=self.fg_color, selectbackground=self.accent_color)
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate the listbox with macro names
macro_ids = []
for macro_id, macro in self.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_select():
if not listbox.curselection():
tk.messagebox.showwarning("No Selection", "Please select a macro to edit.")
return
idx = listbox.curselection()[0]
selected_macro_id = macro_ids[idx]
dialog.destroy()
self.open_edit_dialog(selected_macro_id)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Edit Selected", command=on_select, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).pack(pady=5)
def open_edit_dialog(self, macro_id):
# Open dialog to edit the selected macro
macro = self.macros[macro_id]
dialog = tk.Toplevel(self.root)
dialog.title("Edit Macro")
dialog.geometry("450x350") # Increased height for additional options
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Apply dark theme to dialog
tk.Label(dialog, text="Macro Name:", bg=self.bg_color, fg=self.fg_color).grid(row=0, column=0, padx=5, pady=5, sticky="w")
name_entry = tk.Entry(dialog, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
name_entry.grid(row=0, column=1, padx=5, pady=5)
name_entry.insert(0, macro["name"])
tk.Label(dialog, text="Type:", bg=self.bg_color, fg=self.fg_color).grid(row=1, column=0, padx=5, pady=5, sticky="w")
type_var = tk.StringVar(value=macro["type"])
radio_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color}
tk.Radiobutton(dialog, text="Text", variable=type_var, value="text", **radio_style).grid(row=1, column=1, sticky="w")
tk.Radiobutton(dialog, text="Application", variable=type_var, value="app", **radio_style).grid(row=2, column=1, sticky="w")
tk.Label(dialog, text="Command/Text:", bg=self.bg_color, fg=self.fg_color).grid(row=3, column=0, padx=5, pady=5, sticky="w")
command_text = tk.Text(dialog, width=30, height=5, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
command_text.grid(row=3, column=1, padx=5, pady=5)
command_text.insert("1.0", macro["command"])
# Modifiers frame
mod_frame = tk.Frame(dialog, bg=self.bg_color)
mod_frame.grid(row=4, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=self.bg_color, fg=self.fg_color).pack(side=tk.LEFT, padx=5)
# Get existing modifiers or set defaults
modifiers = macro.get("modifiers", {})
# Add checkboxes for modifiers
ctrl_var = tk.BooleanVar(value=modifiers.get("ctrl", False))
alt_var = tk.BooleanVar(value=modifiers.get("alt", False))
shift_var = tk.BooleanVar(value=modifiers.get("shift", False))
enter_var = tk.BooleanVar(value=modifiers.get("enter", False))
checkbox_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color,
'activebackground': self.bg_color, 'activeforeground': self.fg_color}
tk.Checkbutton(mod_frame, text="Ctrl", variable=ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
image_path = tk.StringVar()
tk.Label(dialog, text="Image:", bg=self.bg_color, fg=self.fg_color).grid(row=5, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(dialog, textvariable=image_path, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
image_entry.grid(row=5, column=1, padx=5, pady=5)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Browse...", command=lambda: image_path.set(filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])), **button_style).grid(row=5, column=2)
def save_edited_macro():
name = name_entry.get().strip()
if not name:
tk.messagebox.showerror("Error", "Macro name is required")
return
new_type = type_var.get()
command = command_text.get("1.0", tk.END).strip()
# Keep the old image or update with new one
image_path_reference = macro.get("image_path", "")
img_path = image_path.get()
if img_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(img_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(img_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
# Update macro with modifiers
self.macros[macro_id] = {
"name": name,
"type": new_type,
"command": command,
"image_path": image_path_reference,
"modifiers": {
"ctrl": ctrl_var.get(),
"alt": alt_var.get(),
"shift": shift_var.get(),
"enter": enter_var.get()
}
}
self.save_macros()
self.display_macros()
dialog.destroy()
tk.Button(dialog, text="Save", command=save_edited_macro, **button_style).grid(row=6, column=0, padx=5, pady=20)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).grid(row=6, column=1, padx=5, pady=20)
def delete_macro(self):
# Show a dialog to select which macro to delete
if not self.macros:
tk.messagebox.showinfo("No Macros", "There are no macros to delete.")
return
dialog = tk.Toplevel(self.root)
dialog.title("Delete Macro")
dialog.geometry("200x340")
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Create a listbox to show available macros
tk.Label(dialog, text="Select a macro to delete:", bg=self.bg_color, fg=self.fg_color).pack(pady=5)
listbox = tk.Listbox(dialog, bg=self.highlight_color, fg=self.fg_color, selectbackground=self.accent_color)
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate the listbox with macro names
macro_ids = []
for macro_id, macro in self.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_delete():
if not listbox.curselection():
tk.messagebox.showwarning("No Selection", "Please select a macro to delete.")
return
idx = listbox.curselection()[0]
selected_macro_id = macro_ids[idx]
selected_name = self.macros[selected_macro_id]["name"]
# Confirm deletion
if tk.messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{selected_name}'?"):
# Delete associated image file if it exists
macro = self.macros[selected_macro_id]
if "image_path" in macro and macro["image_path"]:
try:
img_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), macro["image_path"])
if os.path.exists(img_path):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[selected_macro_id]
self.save_macros()
self.display_macros()
dialog.destroy()
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Delete Selected", command=on_delete, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).pack(pady=5)
def execute_macro(self, macro_id):
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
try:
if macro["type"] == "text":
# Handle key modifiers
modifiers = macro.get("modifiers", {})
# Add modifier keys if enabled
if modifiers.get("ctrl", False):
pyautogui.keyDown('ctrl')
if modifiers.get("alt", False):
pyautogui.keyDown('alt')
if modifiers.get("shift", False):
pyautogui.keyDown('shift')
if str(macro["command"]) and len(str(macro["command"])) == 1:
pyautogui.keyDown(macro["command"])
time.sleep(0.5)
pyautogui.keyUp(macro["command"])
else:
pyautogui.typewrite(macro["command"], interval=0.02)
# Release modifier keys in reverse order
if modifiers.get("shift", False):
pyautogui.keyUp('shift')
if modifiers.get("alt", False):
pyautogui.keyUp('alt')
if modifiers.get("ctrl", False):
pyautogui.keyUp('ctrl')
# Add Enter/Return if requested
if modifiers.get("enter", False):
pyautogui.press('enter')
elif macro["type"] == "app":
subprocess.Popen(macro["command"], shell=True)
return True
except Exception as e:
print(f"Error executing macro: {e}")
return False
def create_tray_icon(self):
# Create tray icon
icon_image = Image.new("RGB", (64, 64), self.accent_color)
menu = (
pystray.MenuItem('Show', self.show_window),
pystray.MenuItem('Exit', self.exit_app)
)
self.tray_icon = pystray.Icon("macropad", icon_image, "MacroPad Server", menu)
self.tray_icon.run_detached()
def show_window(self, icon=None, item=None):
# Show the window from tray
self.root.deiconify()
self.root.state('normal')
self.root.lift()
self.root.focus_force()
def exit_app(self, icon=None, item=None):
# Actually exit the app
self.stop_server()
self.server_running = False
if hasattr(self, 'tray_icon'):
self.tray_icon.stop()
self.root.quit()
def minimize_to_tray(self):
# Create tray icon if it doesn't exist
if not hasattr(self, 'tray_icon'):
self.create_tray_icon()
self.root.withdraw()
def on_closing(self):
# When the window is closed, minimize to tray instead of closing
self.minimize_to_tray()
if __name__ == "__main__":
root = tk.Tk()
app = MacroPadServer(root)
root.mainloop()
-38
View File
@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['mp-server-v2.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='mp-server-v2',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
-645
View File
@@ -1,645 +0,0 @@
import tkinter as tk
from tkinter import filedialog, ttk
import json
import socket
import threading
import os
import pyautogui
import subprocess
from PIL import Image, ImageTk
import pystray # Add this import for system tray functionality
import uuid
class MacroPadServer:
def __init__(self, root):
self.root = root
self.root.title("MacroPad Server")
self.root.geometry("800x600")
# Create the image Directory
self.images_dir = "macro_images"
os.makedirs(self.images_dir, exist_ok=True)
# Set dark theme colors
self.bg_color = "#2E2E2E"
self.fg_color = "#FFFFFF"
self.highlight_color = "#3D3D3D"
self.accent_color = "#007BFF"
self.button_bg = "#444444"
self.button_fg = "#FFFFFF"
# Apply theme
self.root.configure(bg=self.bg_color)
self.style = ttk.Style()
self.style.theme_use('clam')
# Configure styles
self.style.configure('TFrame', background=self.bg_color)
self.style.configure('TLabel', background=self.bg_color, foreground=self.fg_color)
self.style.configure('TButton', background=self.button_bg, foreground=self.button_fg, borderwidth=0)
self.style.map('TButton',
background=[('active', self.accent_color), ('disabled', self.highlight_color)],
foreground=[('active', self.button_fg), ('disabled', '#999999')])
self.style.configure('TRadiobutton', background=self.bg_color, foreground=self.fg_color)
self.macros = {}
self.load_macros()
# Create GUI
self.create_widgets()
# Start server thread
self.server_running = True
self.server_thread = threading.Thread(target=self.run_server)
self.server_thread.daemon = True
self.server_thread.start()
# Set up system tray icon
self.setup_tray_icon()
# Capture the window close event
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
# Capture the window minimize event
self.root.bind("<Unmap>", lambda e: self.minimize_to_tray() if self.root.state() == 'iconic' else None)
def setup_tray_icon(self):
# Create a simple icon for the tray
icon_image = Image.new("RGB", (64, 64), color=self.accent_color)
# Menu for the tray icon
menu = (
pystray.MenuItem('Show', self.show_window),
pystray.MenuItem('Exit', self.exit_app)
)
self.icon = pystray.Icon("macropad_server", icon_image, "MacroPad Server", menu)
def minimize_to_tray(self):
self.root.withdraw() # Hide window
if not self.icon.visible:
# Start the tray icon if it's not already running
self.icon_thread = threading.Thread(target=self.icon.run)
self.icon_thread.daemon = True
self.icon_thread.start()
def show_window(self, icon=None, item=None):
self.root.deiconify() # Show window
self.root.lift() # Bring window to front
self.root.focus_force() # Focus the window
if self.icon.visible:
self.icon.stop() # Remove icon
def exit_app(self, icon=None, item=None):
if self.icon.visible:
self.icon.stop()
self.server_running = False
self.save_macros()
self.root.destroy()
def create_widgets(self):
# Toolbar
toolbar = tk.Frame(self.root, bg=self.highlight_color)
toolbar.pack(side=tk.TOP, fill=tk.X)
# Custom button style
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'padx': 10,
'pady': 5, 'bd': 0, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'relief': tk.FLAT}
tk.Button(toolbar, text="Add Macro", command=self.add_macro, **button_style).pack(side=tk.LEFT, padx=5, pady=5)
tk.Button(toolbar, text="Edit Macro", command=self.edit_macro, **button_style).pack(side=tk.LEFT, padx=5, pady=5)
tk.Button(toolbar, text="Delete Macro", command=self.delete_macro, **button_style).pack(side=tk.LEFT, padx=5, pady=5)
tk.Button(toolbar, text="Minimize to Tray", command=self.minimize_to_tray, **button_style).pack(side=tk.LEFT, padx=5, pady=5)
# Macro list frame
list_frame = tk.Frame(self.root, bg=self.bg_color)
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Scrollable canvas for macros
self.canvas = tk.Canvas(list_frame, bg=self.bg_color, highlightthickness=0)
scrollbar = tk.Scrollbar(list_frame, orient="vertical", command=self.canvas.yview)
self.scrollable_frame = tk.Frame(self.canvas, bg=self.bg_color)
self.scrollable_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
)
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
self.canvas.configure(yscrollcommand=scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
self.display_macros()
# Status bar
self.status_var = tk.StringVar()
self.status_var.set("Server ready. Waiting for connections...")
status_bar = tk.Label(self.root, textvariable=self.status_var, bd=1, relief=tk.SUNKEN,
anchor=tk.W, bg=self.highlight_color, fg=self.fg_color)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
def load_macros(self):
try:
if os.path.exists("macros.json"):
with open("macros.json", "r") as f:
self.macros = json.load(f)
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def save_macros(self):
try:
with open("macros.json", "w") as f:
json.dump(self.macros, f)
except Exception as e:
print(f"Error saving macros: {e}")
def display_macros(self):
# Clear existing macros
for widget in self.scrollable_frame.winfo_children():
widget.destroy()
# Display macros in a grid
row, col = 0, 0
max_cols = 3
for macro_id, macro in self.macros.items():
frame = tk.Frame(self.scrollable_frame, bd=2, relief=tk.RAISED, padx=5, pady=5,
bg=self.highlight_color)
frame.grid(row=row, column=col, padx=10, pady=10, sticky="nsew")
# Load image if exists
if "image_path" in macro and macro["image_path"]:
try:
img_path = macro["image_path"]
img = Image.open(img_path)
# Resize for display
img.thumbnail((64, 64)) # Keep aspect ratio for display
photo = ImageTk.PhotoImage(img)
label = tk.Label(frame, image=photo, bg=self.highlight_color)
label.image = photo # Keep a reference
label.pack()
except Exception as e:
print(f"Error displaying image: {e}")
tk.Label(frame, text="[No Image]", width=8, height=4, bg=self.highlight_color, fg=self.fg_color).pack()
else:
tk.Label(frame, text="[No Image]", width=8, height=4, bg=self.highlight_color, fg=self.fg_color).pack()
tk.Label(frame, text=macro["name"], bg=self.highlight_color, fg=self.fg_color).pack()
col += 1
if col >= max_cols:
col = 0
row += 1
def add_macro(self):
# Create a dialog to add a new macro
dialog = tk.Toplevel(self.root)
dialog.title("Add Macro")
dialog.geometry("450x350") # Increased height for additional options
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Apply dark theme to dialog
tk.Label(dialog, text="Macro Name:", bg=self.bg_color, fg=self.fg_color).grid(row=0, column=0, padx=5, pady=5, sticky="w")
name_entry = tk.Entry(dialog, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
name_entry.grid(row=0, column=1, padx=5, pady=5)
tk.Label(dialog, text="Type:", bg=self.bg_color, fg=self.fg_color).grid(row=1, column=0, padx=5, pady=5, sticky="w")
type_var = tk.StringVar(value="text")
radio_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color}
tk.Radiobutton(dialog, text="Text", variable=type_var, value="text", **radio_style).grid(row=1, column=1, sticky="w")
tk.Radiobutton(dialog, text="Application", variable=type_var, value="app", **radio_style).grid(row=2, column=1, sticky="w")
tk.Label(dialog, text="Command/Text:", bg=self.bg_color, fg=self.fg_color).grid(row=3, column=0, padx=5, pady=5, sticky="w")
command_text = tk.Text(dialog, width=30, height=5, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
command_text.grid(row=3, column=1, padx=5, pady=5)
# Key modifiers frame
mod_frame = tk.Frame(dialog, bg=self.bg_color)
mod_frame.grid(row=4, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=self.bg_color, fg=self.fg_color).pack(side=tk.LEFT, padx=5)
# Add checkboxes for modifiers
ctrl_var = tk.BooleanVar(value=False)
alt_var = tk.BooleanVar(value=False)
shift_var = tk.BooleanVar(value=False)
enter_var = tk.BooleanVar(value=False)
checkbox_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color,
'activebackground': self.bg_color, 'activeforeground': self.fg_color}
tk.Checkbutton(mod_frame, text="Ctrl", variable=ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
image_path = tk.StringVar()
tk.Label(dialog, text="Image:", bg=self.bg_color, fg=self.fg_color).grid(row=5, column=0, padx=5, pady=5, sticky="w")
tk.Entry(dialog, textvariable=image_path, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color).grid(row=5, column=1, padx=5, pady=5)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Browse...", command=lambda: image_path.set(filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])), **button_style).grid(row=5, column=2)
def save_macro():
name = name_entry.get().strip()
if not name:
tk.messagebox.showerror("Error", "Macro name is required")
return
macro_type = type_var.get()
command = command_text.get("1.0", tk.END).strip()
# Generate a unique ID
macro_id = str(len(self.macros) + 1)
# Process image
image_path_reference = ""
img_path = image_path.get()
if img_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(img_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(img_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
# Create macro with modifier keys
self.macros[macro_id] = {
"name": name,
"type": macro_type,
"command": command,
"image_path": image_path_reference,
"modifiers": {
"ctrl": ctrl_var.get(),
"alt": alt_var.get(),
"shift": shift_var.get(),
"enter": enter_var.get()
}
}
self.save_macros()
self.display_macros()
dialog.destroy()
tk.Button(dialog, text="Save", command=save_macro, **button_style).grid(row=6, column=0, padx=5, pady=20)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).grid(row=6, column=1, padx=5, pady=20)
def edit_macro(self):
# First, show a dialog to select which macro to edit
if not self.macros:
tk.messagebox.showinfo("No Macros", "There are no macros to edit.")
return
dialog = tk.Toplevel(self.root)
dialog.title("Select Macro to Edit")
dialog.geometry("200x340")
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Create a listbox to show available macros
tk.Label(dialog, text="Select a macro to edit:", bg=self.bg_color, fg=self.fg_color).pack(pady=5)
listbox = tk.Listbox(dialog, bg=self.highlight_color, fg=self.fg_color, selectbackground=self.accent_color)
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate the listbox with macro names
macro_ids = []
for macro_id, macro in self.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_select():
if not listbox.curselection():
tk.messagebox.showwarning("No Selection", "Please select a macro to edit.")
return
idx = listbox.curselection()[0]
selected_macro_id = macro_ids[idx]
dialog.destroy()
self.open_edit_dialog(selected_macro_id)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Edit Selected", command=on_select, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).pack(pady=5)
def open_edit_dialog(self, macro_id):
# Open dialog to edit the selected macro
macro = self.macros[macro_id]
dialog = tk.Toplevel(self.root)
dialog.title("Edit Macro")
dialog.geometry("450x350") # Increased height for additional options
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Apply dark theme to dialog
tk.Label(dialog, text="Macro Name:", bg=self.bg_color, fg=self.fg_color).grid(row=0, column=0, padx=5, pady=5, sticky="w")
name_entry = tk.Entry(dialog, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
name_entry.grid(row=0, column=1, padx=5, pady=5)
name_entry.insert(0, macro["name"])
tk.Label(dialog, text="Type:", bg=self.bg_color, fg=self.fg_color).grid(row=1, column=0, padx=5, pady=5, sticky="w")
type_var = tk.StringVar(value=macro["type"])
radio_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color}
tk.Radiobutton(dialog, text="Text", variable=type_var, value="text", **radio_style).grid(row=1, column=1, sticky="w")
tk.Radiobutton(dialog, text="Application", variable=type_var, value="app", **radio_style).grid(row=2, column=1, sticky="w")
tk.Label(dialog, text="Command/Text:", bg=self.bg_color, fg=self.fg_color).grid(row=3, column=0, padx=5, pady=5, sticky="w")
command_text = tk.Text(dialog, width=30, height=5, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
command_text.grid(row=3, column=1, padx=5, pady=5)
command_text.insert("1.0", macro["command"])
# Modifiers frame
mod_frame = tk.Frame(dialog, bg=self.bg_color)
mod_frame.grid(row=4, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=self.bg_color, fg=self.fg_color).pack(side=tk.LEFT, padx=5)
# Get existing modifiers or set defaults
modifiers = macro.get("modifiers", {})
# Add checkboxes for modifiers
ctrl_var = tk.BooleanVar(value=modifiers.get("ctrl", False))
alt_var = tk.BooleanVar(value=modifiers.get("alt", False))
shift_var = tk.BooleanVar(value=modifiers.get("shift", False))
enter_var = tk.BooleanVar(value=modifiers.get("enter", False))
checkbox_style = {'bg': self.bg_color, 'fg': self.fg_color, 'selectcolor': self.accent_color,
'activebackground': self.bg_color, 'activeforeground': self.fg_color}
tk.Checkbutton(mod_frame, text="Ctrl", variable=ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
image_path = tk.StringVar()
tk.Label(dialog, text="Image:", bg=self.bg_color, fg=self.fg_color).grid(row=5, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(dialog, textvariable=image_path, width=30, bg=self.highlight_color, fg=self.fg_color, insertbackground=self.fg_color)
image_entry.grid(row=5, column=1, padx=5, pady=5)
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Browse...", command=lambda: image_path.set(filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])), **button_style).grid(row=5, column=2)
def save_edited_macro():
name = name_entry.get().strip()
if not name:
tk.messagebox.showerror("Error", "Macro name is required")
return
new_type = type_var.get()
command = command_text.get("1.0", tk.END).strip()
# Keep the old image or update with new one
image_path_reference = macro.get("image_path", "")
img_path = image_path.get()
if img_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(img_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(img_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
# Update macro with modifiers
self.macros[macro_id] = {
"name": name,
"type": new_type,
"command": command,
"image_path": image_path_reference,
"modifiers": {
"ctrl": ctrl_var.get(),
"alt": alt_var.get(),
"shift": shift_var.get(),
"enter": enter_var.get()
}
}
self.save_macros()
self.display_macros()
dialog.destroy()
tk.Button(dialog, text="Save", command=save_edited_macro, **button_style).grid(row=6, column=0, padx=5, pady=20)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).grid(row=6, column=1, padx=5, pady=20)
def delete_macro(self):
# Show a dialog to select which macro to delete
if not self.macros:
tk.messagebox.showinfo("No Macros", "There are no macros to delete.")
return
dialog = tk.Toplevel(self.root)
dialog.title("Delete Macro")
dialog.geometry("200x340")
dialog.transient(self.root)
dialog.configure(bg=self.bg_color)
# Create a listbox to show available macros
tk.Label(dialog, text="Select a macro to delete:", bg=self.bg_color, fg=self.fg_color).pack(pady=5)
listbox = tk.Listbox(dialog, bg=self.highlight_color, fg=self.fg_color, selectbackground=self.accent_color)
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate the listbox with macro names
macro_ids = []
for macro_id, macro in self.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_delete():
if not listbox.curselection():
tk.messagebox.showwarning("No Selection", "Please select a macro to delete.")
return
idx = listbox.curselection()[0]
selected_macro_id = macro_ids[idx]
selected_name = self.macros[selected_macro_id]["name"]
# Confirm deletion
if tk.messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{selected_name}'?"):
# Delete associated image file if it exists
macro = self.macros[selected_macro_id]
if "image_path" in macro and macro["image_path"]:
try:
img_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), macro["image_path"])
if os.path.exists(img_path):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[selected_macro_id]
self.save_macros()
self.display_macros()
dialog.destroy()
button_style = {'bg': self.button_bg, 'fg': self.button_fg, 'activebackground': self.accent_color,
'activeforeground': self.button_fg, 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Delete Selected", command=on_delete, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy, **button_style).pack(pady=5)
def execute_macro(self, macro_id):
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
try:
if macro["type"] == "text":
# Handle key modifiers
modifiers = macro.get("modifiers", {})
keys_to_press = []
# Add modifier keys if enabled
if modifiers.get("ctrl", False):
keys_to_press.append('ctrl')
if modifiers.get("alt", False):
keys_to_press.append('alt')
if modifiers.get("shift", False):
keys_to_press.append('shift')
# If there are modifier keys, use hotkey functionality
if keys_to_press:
# For single characters with modifiers, use hotkey
if len(macro["command"]) == 1:
keys_to_press.append(macro["command"], interval=0.02)
pyautogui.hotkey(*keys_to_press)
else:
# For longer text, press modifiers, type text, then release
for key in keys_to_press:
pyautogui.keyDown(key)
pyautogui.typewrite(macro["command"], interval=0.02)
for key in reversed(keys_to_press):
pyautogui.keyUp(key)
else:
# No modifiers, just type the text
pyautogui.typewrite(macro["command"], interval=0.02)
# Add Enter/Return if requested
if modifiers.get("enter", False):
pyautogui.press('enter')
elif macro["type"] == "app":
subprocess.Popen(macro["command"], shell=True)
return True
except Exception as e:
print(f"Error executing macro: {e}")
return False
def run_server(self):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server_socket.bind(('0.0.0.0', 40000))
server_socket.listen(5)
while self.server_running:
client_socket, address = server_socket.accept()
self.status_var.set(f"Client connected from {address}")
client_thread = threading.Thread(target=self.handle_client, args=(client_socket,))
client_thread.daemon = True
client_thread.start()
except Exception as e:
print(f"Server error: {e}")
finally:
server_socket.close()
def handle_client(self, client_socket):
try:
while self.server_running:
data = b""
while True:
chunk = client_socket.recv(4096)
if not chunk:
break
data += chunk
try:
# Try to see if we've received a complete JSON object
json_data = json.loads(data.decode('utf-8'))
break # If successful, break out of the inner loop
except json.JSONDecodeError:
# If it's not a complete JSON object yet, keep reading
continue
if not data:
break
try:
request = json_data
if request['action'] == 'get_macros':
# Send all macros to client
client_socket.send(json.dumps(self.macros).encode('utf-8'))
elif request['action'] == 'execute':
# Execute the specified macro
success = self.execute_macro(request['macro_id'])
response = {'success': success}
client_socket.send(json.dumps(response).encode('utf-8'))
elif request['action'] == 'get_image':
image_path = request['image_path']
if os.path.exists(image_path):
try:
with open(image_path, 'rb') as f:
image_data =f.read()
image_size = len(image_data)
client_socket.send(str(image_size).encode('utf-8'))
client_socket.recv(1024) # Wait for acknowledgment
client_socket.sendall(image_data)
except FileNotFoundError:
client_socket.send(b"ERROR: Image not found")
except Exception as e:
client_socket.send(f"ERROR: {str(e)}".encode('utf-8'))
else:
client_socket.send(json.dumps({'error': 'Unknown action'}).encode('utf-8'))
except (json.JSONDecodeError, KeyError, TypeError) as e:
error_msg = {'error': f'Invalid request: {str(e)}'}
client_socket.send(json.dumps(error_msg).encode('utf-8'))
print(f"JSON processing error: {e}, Data received: {data[:100]}")
except Exception as e:
print(f"Client handling error: {e}")
finally:
client_socket.close()
self.status_var.set("Client disconnected. Waiting for connections...")
def on_closing(self):
# When the window is closed, minimize to tray instead of closing
self.minimize_to_tray()
if __name__ == "__main__":
root = tk.Tk()
app = MacroPadServer(root)
root.mainloop()
-38
View File
@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['mp-server.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='mp-server',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+63
View File
@@ -0,0 +1,63 @@
[project]
name = "macropad-server"
version = "0.9.0"
description = "A cross-platform macro management application with desktop and web interfaces"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "jknapp"}
]
keywords = ["macropad", "macro", "automation", "keyboard", "pwa"]
dependencies = [
# Image processing
"pillow>=10.0.0",
# Keyboard/mouse automation
"pyautogui>=0.9.54",
"pyperclip>=1.8.0",
# System tray
"pystray>=0.19.5",
# Web server
"fastapi>=0.104.0",
"uvicorn>=0.24.0",
"websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads
# Network utilities
"netifaces>=0.11.0",
# QR code generation
"qrcode>=7.4.2",
# Desktop GUI
"PySide6>=6.6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"black>=23.0.0",
"ruff>=0.1.0",
"pyinstaller>=6.0.0",
]
[project.scripts]
macropad-server = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["gui"]
[dependency-groups]
dev = [
"pyinstaller>=6.0.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.black]
line-length = 100
target-version = ["py311"]
-7
View File
@@ -1,7 +0,0 @@
pillow
pyautogui
pystray
flask
waitress
netifaces
qrcode
+1 -1
View File
@@ -1 +1 @@
0.5.4 0.9.0
+596
View File
@@ -0,0 +1,596 @@
/* MacroPad PWA Styles */
:root {
--bg-color: #2e2e2e;
--fg-color: #ffffff;
--highlight-color: #3e3e3e;
--accent-color: #007acc;
--button-bg: #505050;
--button-hover: #606060;
--tab-bg: #404040;
--tab-selected: #007acc;
--danger-color: #dc3545;
--success-color: #28a745;
--warning-color: #ffc107;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
/* Safe area for notched devices */
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* Header */
.header {
background-color: var(--highlight-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.header-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.header-btn:hover {
background: #0096ff;
}
.header-btn.secondary {
background: var(--button-bg);
}
.header-btn.secondary:hover {
background: var(--button-hover);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-color);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
background: var(--tab-bg);
color: var(--fg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.tab:hover {
background: var(--button-hover);
}
.tab.active {
background: var(--tab-selected);
}
/* Macro Grid */
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.macro-card {
background: var(--button-bg);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
min-height: 120px;
}
.macro-card:hover {
background: var(--button-hover);
transform: translateY(-2px);
}
.macro-card:active {
transform: translateY(0);
}
.macro-card.executing {
animation: pulse 0.3s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(0.95); background: var(--accent-color); }
100% { transform: scale(1); }
}
.macro-image {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.macro-image-placeholder {
width: 64px;
height: 64px;
background: var(--highlight-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.macro-name {
text-align: center;
font-size: 0.9rem;
word-break: break-word;
}
.macro-edit-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.macro-card {
position: relative;
}
.macro-card:hover .macro-edit-btn {
opacity: 1;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--highlight-color);
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--bg-color);
}
.modal-header h2 {
font-size: 1.2rem;
}
.modal-close {
background: none;
border: none;
color: var(--fg-color);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
}
.modal-body {
padding: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--bg-color);
}
/* Form Elements */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: #aaa;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem;
background: var(--bg-color);
border: 1px solid var(--button-bg);
border-radius: 4px;
color: var(--fg-color);
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
}
/* Command Builder */
.command-list {
background: var(--bg-color);
border-radius: 4px;
padding: 0.5rem;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
}
.command-item {
background: var(--button-bg);
border-radius: 4px;
padding: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.command-item:last-child {
margin-bottom: 0;
}
.command-type {
background: var(--accent-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
min-width: 50px;
text-align: center;
}
.command-value {
flex: 1;
font-family: monospace;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-actions {
display: flex;
gap: 0.25rem;
}
.command-actions button {
background: var(--highlight-color);
border: none;
color: var(--fg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.command-actions button:hover {
background: var(--button-hover);
}
.command-actions button.delete {
color: var(--danger-color);
}
.add-command-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.add-command-btn {
background: var(--button-bg);
border: none;
color: var(--fg-color);
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.add-command-btn:hover {
background: var(--accent-color);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: #0096ff;
}
.btn-secondary {
background: var(--button-bg);
color: white;
}
.btn-secondary:hover {
background: var(--button-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #c82333;
}
/* Status/Toast Messages */
.toast-container {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom));
right: 1rem;
z-index: 300;
}
.toast {
background: var(--highlight-color);
color: var(--fg-color);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
}
.toast.success {
border-left: 4px solid var(--success-color);
}
.toast.error {
border-left: 4px solid var(--danger-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Connection Status */
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #aaa;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger-color);
}
.status-dot.connected {
background: var(--success-color);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #888;
}
.empty-state p {
margin-bottom: 1rem;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--button-bg);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.header h1 {
font-size: 1.2rem;
}
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
}
.macro-card {
padding: 0.75rem;
min-height: 100px;
}
.macro-image,
.macro-image-placeholder {
width: 48px;
height: 48px;
}
.modal {
max-width: 100%;
margin: 0.5rem;
}
}
/* Install Banner */
.install-banner {
background: var(--accent-color);
color: white;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.install-banner button {
background: white;
color: var(--accent-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.install-banner .dismiss {
background: transparent;
color: white;
padding: 0.5rem;
}
/* Fullscreen Button */
.header-btn.icon-btn {
padding: 0.5rem 0.75rem;
font-size: 1.2rem;
line-height: 1;
}
/* Wake Lock Status */
.wake-lock-status {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
opacity: 0.4;
transition: opacity 0.3s;
}
.wake-lock-status.active {
opacity: 1;
}
.wake-lock-status .wake-icon {
color: #ffc107;
}
.wake-lock-status.active .wake-icon {
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Fullscreen styles */
:fullscreen .header {
padding-top: 0.5rem;
}
:fullscreen .macro-grid {
padding-bottom: 1rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#007acc">
<meta name="description" content="Remote macro control for your desktop">
<!-- PWA / iOS specific -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<meta name="application-name" content="MacroPad">
<meta name="msapplication-TileColor" content="#007acc">
<meta name="msapplication-tap-highlight" content="no">
<title>MacroPad</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icons/icon-512.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" sizes="512x512" href="/static/icons/icon-512.png">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<!-- Header -->
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status">
<div class="status-dot"></div>
<span>Disconnected</span>
</div>
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
<span class="wake-icon">☀</span>
</div>
<button class="header-btn icon-btn" id="fullscreen-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen">⛶</button>
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container">
<!-- Tabs rendered dynamically -->
</nav>
<!-- Macro Grid -->
<main class="macro-grid" id="macro-grid">
<div class="loading">
<div class="spinner"></div>
</div>
</main>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
// MacroPad PWA Application (Execute-only)
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.wakeLock = null;
this.init();
}
async init() {
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.setupWakeLock();
this.checkInstallPrompt();
}
// API Methods
async loadTabs() {
try {
const response = await fetch('/api/tabs');
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
} catch (error) {
console.error('Error loading tabs:', error);
this.showToast('Error loading tabs', 'error');
}
}
async loadMacros() {
try {
const url = this.currentTab === 'All'
? '/api/macros'
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(url);
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const response = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Execution failed');
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
}
}
// WebSocket
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateConnectionStatus(true);
};
this.ws.onclose = () => {
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
}
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'macro_created':
case 'macro_updated':
case 'macro_deleted':
this.loadTabs();
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
}
updateConnectionStatus(connected) {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.connection-status span');
if (dot) {
dot.classList.toggle('connected', connected);
}
if (text) {
text.textContent = connected ? 'Connected' : 'Disconnected';
}
}
// Rendering
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<p class="hint">Create macros in the desktop app</p>
</div>
`;
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
const imageSrc = macro.image_path
? `/api/image/${macro.image_path}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
${firstChar}
</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
}
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// PWA Install Prompt
checkInstallPrompt() {
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showInstallBanner(deferredPrompt);
});
}
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
async installPWA() {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
if (outcome === 'accepted') {
document.querySelector('.install-banner')?.remove();
}
this.deferredPrompt = null;
}
// Refresh
refresh() {
this.loadTabs();
this.loadMacros();
}
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.log('Fullscreen error:', err);
});
} else {
document.exitFullscreen();
}
}
// Wake Lock - prevents screen from sleeping
async setupWakeLock() {
if (!('wakeLock' in navigator)) {
console.log('Wake Lock API not supported');
document.getElementById('wake-lock-status')?.remove();
return;
}
// Request wake lock
await this.requestWakeLock();
// Re-acquire wake lock when page becomes visible again
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
await this.requestWakeLock();
}
});
}
async requestWakeLock() {
try {
this.wakeLock = await navigator.wakeLock.request('screen');
this.updateWakeLockStatus(true);
this.wakeLock.addEventListener('release', () => {
this.updateWakeLockStatus(false);
});
} catch (err) {
console.log('Wake Lock error:', err);
this.updateWakeLockStatus(false);
}
}
updateWakeLockStatus(active) {
const status = document.getElementById('wake-lock-status');
if (status) {
status.classList.toggle('active', active);
status.title = active ? 'Screen will stay on' : 'Screen may sleep';
}
}
}
// Initialize app
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MacroPadApp();
});
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "/",
"name": "MacroPad Server",
"short_name": "MacroPad",
"description": "Remote macro control for your desktop",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#2e2e2e",
"theme_color": "#007acc",
"orientation": "any",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["utilities", "productivity"],
"prefer_related_applications": false
}
+72
View File
@@ -0,0 +1,72 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v3';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
'/static/js/app.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/manifest.json'
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Caching app assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Always fetch API requests from network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
});
+289
View File
@@ -0,0 +1,289 @@
# FastAPI web server for MacroPad
import os
import sys
import asyncio
from typing import List, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
import uvicorn
from config import DEFAULT_PORT, VERSION
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
class Command(BaseModel):
"""Single command in a macro sequence."""
type: str # text, key, hotkey, wait, app
value: Optional[str] = None
keys: Optional[List[str]] = None
ms: Optional[int] = None
command: Optional[str] = None
class MacroCreate(BaseModel):
"""Request model for creating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class MacroUpdate(BaseModel):
"""Request model for updating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class ExecuteRequest(BaseModel):
"""Request model for executing a macro."""
macro_id: str
class TabCreate(BaseModel):
"""Request model for creating a tab."""
name: str
class ConnectionManager:
"""Manages WebSocket connections for real-time updates."""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
pass
class WebServer:
"""FastAPI-based web server for MacroPad."""
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT):
self.macro_manager = macro_manager
self.app_dir = app_dir
self.port = port
self.app = None
self.manager = ConnectionManager()
self.server = None
def create_app(self) -> FastAPI:
"""Create FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title="MacroPad Server",
version=VERSION,
lifespan=lifespan
)
# Serve static files from web directory (bundled with app)
web_dir = get_resource_path("web")
if os.path.exists(web_dir):
app.mount("/static", StaticFiles(directory=web_dir), name="static")
# Serve macro images (user data directory)
images_dir = os.path.join(self.app_dir, "macro_images")
if os.path.exists(images_dir):
app.mount("/images", StaticFiles(directory=images_dir), name="images")
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the main PWA page."""
index_path = os.path.join(web_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path, media_type="text/html")
return HTMLResponse("<h1>MacroPad Server</h1><p>Web interface not found.</p>")
@app.get("/manifest.json")
async def manifest():
"""Serve PWA manifest."""
manifest_path = os.path.join(web_dir, "manifest.json")
if os.path.exists(manifest_path):
return FileResponse(manifest_path, media_type="application/json")
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/service-worker.js")
async def service_worker():
"""Serve service worker."""
sw_path = os.path.join(web_dir, "service-worker.js")
if os.path.exists(sw_path):
return FileResponse(sw_path, media_type="application/javascript")
raise HTTPException(status_code=404, detail="Service worker not found")
@app.get("/api/tabs")
async def get_tabs():
"""Get available tab categories."""
return {"tabs": self.macro_manager.get_unique_tabs()}
@app.get("/api/macros")
async def get_macros():
"""Get all macros."""
macros = self.macro_manager.get_all_macros()
return {"macros": macros}
@app.get("/api/macros/{tab}")
async def get_macros_by_tab(tab: str):
"""Get macros filtered by tab/category."""
all_macros = self.macro_manager.get_sorted_macros()
filtered = self.macro_manager.filter_macros_by_tab(all_macros, tab)
return {"macros": dict(filtered)}
@app.get("/api/macro/{macro_id}")
async def get_macro(macro_id: str):
"""Get a single macro by ID."""
macro = self.macro_manager.get_macro(macro_id)
if macro:
return {"macro": macro}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/execute")
async def execute_macro(request: ExecuteRequest):
"""Execute a macro by ID."""
success = self.macro_manager.execute_macro(request.macro_id)
if success:
# Broadcast execution to all connected clients
await self.manager.broadcast({
"type": "executed",
"macro_id": request.macro_id
})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found or execution failed")
@app.post("/api/macros")
async def create_macro(macro: MacroCreate):
"""Create a new macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
macro_id = self.macro_manager.add_macro(
name=macro.name,
commands=commands,
category=macro.category or ""
)
# Broadcast update
await self.manager.broadcast({"type": "macro_created", "macro_id": macro_id})
return {"success": True, "macro_id": macro_id}
@app.put("/api/macros/{macro_id}")
async def update_macro(macro_id: str, macro: MacroUpdate):
"""Update an existing macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
success = self.macro_manager.update_macro(
macro_id=macro_id,
name=macro.name,
commands=commands,
category=macro.category or ""
)
if success:
await self.manager.broadcast({"type": "macro_updated", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.delete("/api/macros/{macro_id}")
async def delete_macro(macro_id: str):
"""Delete a macro."""
success = self.macro_manager.delete_macro(macro_id)
if success:
await self.manager.broadcast({"type": "macro_deleted", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for a macro."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Save to temp location
import tempfile
import shutil
ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
shutil.copyfileobj(file.file, tmp)
return {"path": tmp.name}
@app.get("/api/image/{image_path:path}")
async def get_image(image_path: str):
"""Get macro image (legacy compatibility)."""
full_path = os.path.join(self.app_dir, image_path)
if os.path.exists(full_path):
return FileResponse(full_path)
raise HTTPException(status_code=404, detail="Image not found")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time updates."""
await self.manager.connect(websocket)
try:
while True:
data = await websocket.receive_json()
# Handle incoming messages if needed
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
self.manager.disconnect(websocket)
self.app = app
return app
def run(self):
"""Run the web server (blocking)."""
if not self.app:
self.create_app()
config = uvicorn.Config(
self.app,
host="0.0.0.0",
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
)
self.server = uvicorn.Server(config)
self.server.run()
def stop(self):
"""Stop the web server."""
if self.server:
self.server.should_exit = True
async def run_async(self):
"""Run the web server asynchronously."""
if not self.app:
self.create_app()
config = uvicorn.Config(
self.app,
host="0.0.0.0",
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
)
self.server = uvicorn.Server(config)
await self.server.serve()