23 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
ded281cc64 Merge pull request 'Working on the Linux Build Issues' (#5) from fix-linux-update into main
All checks were successful
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
f0af9ad84e Working on the Linux Build Issues 2025-06-05 15:21:34 -07:00
00120c8562 Merge pull request 'fixes Linux bugs' (#4) from update-build-and-docs into main
Some checks failed
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
073bfbf7d9 fixes Linux bugs 2025-06-05 15:12:31 -07:00
c4d151a6d2 Merge pull request 'Fix build errors' (#3) from update-build-and-docs into main
All checks were successful
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
45dfe44b59 Fix build errors 2025-06-05 14:39:00 -07:00
30 changed files with 3725 additions and 1593 deletions

View File

@@ -1,140 +1,159 @@
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 -r requirements.txt
- 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 dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r requirements.txt
- 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 is temporarily disabled
# Uncomment this section when macOS build environment becomes available
# =============================================================================
# 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
#
# 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 -r requirements.txt
#
# - 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
# Uncomment the workflow below once builds are verified locally.
# =============================================================================
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
# macropad-macos/macropad.app/**/*
# 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

239
BUILDING.md Normal file
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
Macro Pad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

301
README.md
View File

@@ -1,164 +1,261 @@
# 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
- **Text Macros**: Insert frequently used text snippets with a single click
- **Application Macros**: Launch applications or scripts directly
- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros
### Macro Capabilities
- **Command Sequences**: Build multi-step macros with:
- **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
- **Category Management**: Organize macros into custom tabs for better organization
- **Web Interface**: Access and trigger your macros from other devices on your network
- **System Tray Integration**: Minimize to tray when minimized, exit when closed
- **QR Code Generation**: Quickly connect mobile devices to the web interface
- **Sorting Options**: Sort macros by name, type, or recent usage
- **Persistent Storage**: Macros are automatically saved for future sessions
- **Dark Theme**: Modern dark interface for comfortable use
- **Modular Architecture**: Clean separation of concerns with dedicated modules
- **Category Management**: Organize macros into custom tabs
### 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
- Python 3.11+
- Required Python packages (install via requirements.txt):
- tkinter
- flask
- pyautogui
- pystray
- Pillow (PIL)
- waitress
- netifaces
- qrcode
- Dependencies managed via `pyproject.toml`
### Core Dependencies
- PySide6 (Desktop GUI)
- FastAPI + Uvicorn (Web server)
- PyAutoGUI (Keyboard automation)
- Pillow (Image processing)
- pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
## Installation
### Method 1: From Source
### Method 1: Using uv (Recommended)
1. Clone or download this repository
2. Install the required dependencies:
```bash
pip install -r requirements.txt
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and install
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
uv sync
```
### Method 2: Pre-built Executables
### 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 the appropriate version for your operating system:
2. Download for your operating system:
- Windows: `macropad.exe`
- Linux: `macropad`
- macOS: `macropad.app`
- macOS: `MacroPad Server.app`
3. Run the downloaded file
> [!IMPORTANT]
> The executables are unsigned and may trigger security warnings. You may need to click "More info" and "Run anyway" in Windows SmartScreen, adjust permissions on Linux (`chmod +x macropad`), or override Gatekeeper on macOS.
> 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
### Main Interface
### Running the Application
When launched, MacroPad 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
- **Edit Macro**: Modify existing macros
- **Delete Macro**: Remove unwanted macros
- **Sort Options**: Sort the Macros by type, name, and recent usage
- **Manage Tabs**: Assign categories to macros for better organization
- **Start Web Server**: Starts the web server to serve the MacroPad web interface.
# If installed with pip
python main.py
```
### Creating a Macro
1. Click the "Add Macro" button
2. Fill in the details:
- **Name**: A descriptive name for your macro
- **Category**: Assign a category to associate with a tab
- **Type**: Choose between Text or Application
- **Command/Text**: The text to insert or application command to run
- **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter
- **Image**: Optionally add an image for visual identification
3. Click "Save" to create your macro
1. Click **+ Add Macro** in the toolbar
2. Enter a **Name** and optional **Category**
3. Build your command sequence using the buttons:
- **+ Text**: Add text to type
- **+ Key**: Add a key press (enter, tab, escape, etc.)
- **+ Hotkey**: Add a key combination (ctrl+c, alt+tab)
- **+ Wait**: Add a delay in milliseconds
- **+ 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
3. Navigate to `http://<your-ip-address>:40000`
4. Click on any macro to execute it on your main computer
```
[TEXT] myusername
[WAIT] 200ms
[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
When minimized to the system tray:
- Right-click the tray icon to show options
- Select "Show" to restore the window
- Select "Exit" to close the application
- Minimize window → App continues in tray
- Right-click tray icon:
- **Show**: Restore window
- **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
### Windows Examples
#### Steam Applications
```
### Windows
```bash
# Steam game
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
```
#### Chrome to a website
```
"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao
```
# Chrome to website
"C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com
#### Run Notepad
```
notepad.exe
```
#### Open File Explorer to a specific location
```
# Open folder
explorer.exe "C:\Users\YourUsername\Documents"
```
### Linux Examples
#### Opening Firefox
```
### Linux
```bash
# Firefox
firefox https://example.com
```
#### Opening Steam
```
# Steam game
steam steam://rungameid/2767030
```
#### Launch Terminal
```
# Terminal
gnome-terminal
```
#### Open File Manager
```
nautilus ~/Documents
```
### macOS Examples
#### Opening Safari
```
### macOS
```bash
# Safari
open -a Safari https://example.com
```
#### Opening Terminal
```
open -a Terminal
```
# VS Code
open -a "Visual Studio Code"
#### Open Finder to a specific location
```
# Folder
open ~/Documents
```
#### Launch Applications
```
open -a "Visual Studio Code"
## 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
```
#### Special Thanks to CatArgent_ on Twitch for proof reading my stuff and providing valuable feedback.
> [!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.

View File

@@ -1,6 +1,6 @@
# Configuration and constants for MacroPad Server
VERSION = "0.8.5 Beta"
VERSION = "0.9.2"
DEFAULT_PORT = 40000
# UI Theme colors

BIN
dist/macropad.exe vendored

Binary file not shown.

7
gui/__init__.py Normal file
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
gui/command_builder.py Normal file
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
gui/macro_editor.py Normal file
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
gui/main_window.py Normal file
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
gui/widgets/__init__.py Normal file
View File

@@ -0,0 +1 @@
# MacroPad Server GUI Widgets

View File

@@ -1,5 +1,6 @@
# Macro management and execution
# Macro management and execution with command sequence support
import copy
import json
import os
import uuid
@@ -7,238 +8,352 @@ import pyautogui
import subprocess
import time
from PIL import Image
from typing import Optional
class MacroManager:
def __init__(self, data_file, images_dir, app_dir):
"""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"""
"""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"""
"""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="name"):
"""Get macros sorted by specified criteria"""
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":
macro_list.sort(key=lambda x: (x[1].get("type", ""), x[1]["name"].lower()))
# 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":
# Sort by last_used timestamp if available, otherwise by name
macro_list.sort(key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), reverse=True)
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, tab_name):
"""Filter macros based on tab name"""
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 type match
if macro.get("type", "").title() == tab_name:
# Check category match
if macro.get("category") == tab_name:
filtered.append((macro_id, macro))
# Check custom category match
elif 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):
"""Get list of unique tabs based on macro types and categories"""
def get_unique_tabs(self) -> list:
"""Get list of unique tabs based on categories."""
tabs = ["All"]
unique_types = set()
categories = set()
for macro in self.macros.values():
if macro.get("type"):
unique_types.add(macro["type"].title())
if macro.get("category"):
unique_types.add(macro["category"])
for tab_type in sorted(unique_types):
if tab_type not in ["All"]:
tabs.append(tab_type)
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, macro_type, command, category="", modifiers=None, image_path=""):
"""Add a new macro"""
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
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 = ""
if image_path:
try:
# Generate unique filename for the image
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)
# 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 data
# 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": macro_type,
"command": command,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"modifiers": modifiers,
"last_used": 0
}
if category:
macro_data["category"] = category
self.macros[macro_id] = macro_data
self.save_macros()
return macro_id
def update_macro(self, macro_id, name, macro_type, command, category="", modifiers=None, image_path=""):
"""Update an existing macro"""
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
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
macro = self.macros[macro_id]
# Keep the old image or update with new one
image_path_reference = macro.get("image_path", "")
if image_path:
try:
# Generate unique filename for the image
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)
# 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 data
updated_macro = {
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": macro_type,
"command": command,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"modifiers": modifiers,
"last_used": macro.get("last_used", 0)
}
if category:
updated_macro["category"] = category
self.macros[macro_id] = updated_macro
self.save_macros()
return True
def delete_macro(self, macro_id):
"""Delete a macro"""
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 it exists
if "image_path" in macro and macro["image_path"]:
# 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):
"""Execute a macro by ID"""
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 for recent sorting
# Update last_used timestamp
self.macros[macro_id]["last_used"] = time.time()
self.save_macros()
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')
# Handle single character vs multi-character commands
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)
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
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()

View File

@@ -1,12 +1,54 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Windows)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
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=[],
@@ -14,6 +56,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -35,4 +78,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='Macro Pad.png',
)

View File

@@ -1,25 +1,84 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Linux)
import os
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
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,
optimize=0,
)
pyz = PYZ(a.pure)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='macropad',
@@ -29,10 +88,10 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
console=True, # Set to True for debugging
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
)

View File

@@ -1,12 +1,54 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (macOS)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
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=[],
@@ -14,6 +56,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -39,7 +82,12 @@ exe = EXE(
app = BUNDLE(
exe,
name='macropad.app',
icon=None,
bundle_identifier=None,
)
name='MacroPad Server.app',
icon='Macro Pad.png',
bundle_identifier='com.macropad.server',
info_plist={
'CFBundleShortVersionString': '0.9.2',
'CFBundleName': 'MacroPad Server',
'NSHighResolutionCapable': True,
},
)

527
main.py
View File

@@ -1,502 +1,63 @@
# Main application file for MacroPad Server
# PySide6 version
import tkinter as tk
from tkinter import ttk, messagebox
import os
import sys
import threading
import socket
import qrcode
import webbrowser
import pystray
from PIL import Image, ImageTk, ImageDraw, ImageFont
from config import VERSION, DEFAULT_PORT, THEME
from macro_manager import MacroManager
from web_server import WebServer
from ui_components import MacroDialog, MacroSelector, TabManager
import multiprocessing
class MacroPadServer:
def __init__(self, root):
self.root = root
self.root.title("MacroPad Server")
self.root.geometry("800x600")
self.configure_styles()
# 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__))
data_file = os.path.join(base_dir, "macros.json")
images_dir = os.path.join(base_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
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__))
# Initialize components
self.macro_manager = MacroManager(data_file, images_dir, base_dir)
self.web_server = WebServer(self.macro_manager, base_dir, DEFAULT_PORT)
# UI state
self.current_sort = "name"
self.current_tab = "All"
self.image_cache = {}
# Server state
self.server_running = False
self.flask_thread = None
# Tray state
self.tray_icon = None
self.is_closing = False
# Create UI
self.create_ui()
# Set up window event handlers
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
self.root.bind('<Unmap>', self.on_minimize)
# Initialize tray icon
self.create_tray_icon()
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 configure_styles(self):
"""Configure the dark theme styles"""
self.root.configure(bg=THEME['bg_color'])
style = ttk.Style()
style.theme_use("clam")
style.configure("TButton", background=THEME['button_bg'], foreground=THEME['button_fg'])
style.map("TButton", background=[("active", THEME['accent_color'])])
style.configure("TFrame", background=THEME['bg_color'])
style.configure("TLabel", background=THEME['bg_color'], foreground=THEME['fg_color'])
# Configure notebook (tabs) style
style.configure("TNotebook", background=THEME['bg_color'], borderwidth=0)
style.configure("TNotebook.Tab", background=THEME['tab_bg'], foreground=THEME['fg_color'],
padding=[12, 8], borderwidth=0)
style.map("TNotebook.Tab",
background=[("selected", THEME['tab_selected'])],
foreground=[("selected", THEME['fg_color'])],
padding=[("selected", [12, 8])]) # Keep same padding when selected
def main():
"""Main entry point."""
# Required for multiprocessing on Windows
multiprocessing.freeze_support()
def create_ui(self):
"""Create the main user interface"""
# Create main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Get directories
app_dir = get_app_dir()
# Left side: Macro list and controls
left_frame = ttk.Frame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Import PySide6 after freeze_support
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
# Sort controls
self._create_sort_controls(left_frame)
# Create notebook for tabs
self.notebook = ttk.Notebook(left_frame)
self.notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.notebook.bind("<<NotebookTabChanged>>", self.on_tab_change)
# Create application
app = QApplication(sys.argv)
app.setApplicationName("MacroPad Server")
app.setOrganizationName("MacroPad")
# Button controls
self._create_macro_buttons(left_frame)
# 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))
# Right side: Server controls
right_frame = ttk.Frame(main_frame)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0))
# Import and create main window
from gui.main_window import MainWindow
window = MainWindow(app_dir)
window.show()
self._create_server_controls(right_frame)
# Version label
version_label = tk.Label(self.root, text=VERSION,
bg=THEME['bg_color'], fg=THEME['fg_color'],
font=('Helvetica', 8))
version_label.pack(side=tk.BOTTOM, anchor=tk.SE, padx=5, pady=(0, 2))
# Initialize display
self.setup_tabs()
self.display_macros()
def _create_sort_controls(self, parent):
"""Create sorting controls"""
sort_frame = ttk.Frame(parent)
sort_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(sort_frame, text="Sort by:").pack(side=tk.LEFT, padx=(0, 5))
self.sort_var = tk.StringVar(value=self.current_sort)
sort_combo = ttk.Combobox(sort_frame, textvariable=self.sort_var,
values=["name", "type", "recent"],
state="readonly", width=10)
sort_combo.pack(side=tk.LEFT, padx=(0, 10))
sort_combo.bind("<<ComboboxSelected>>", self.on_sort_change)
# Tab management button
ttk.Button(sort_frame, text="Manage Tabs",
command=self.manage_tabs).pack(side=tk.RIGHT, padx=(5, 0))
def _create_macro_buttons(self, parent):
"""Create macro management buttons"""
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Button(button_frame, text="Add Macro", command=self.add_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Edit Macro", command=self.edit_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Delete Macro", command=self.delete_macro).pack(side=tk.LEFT, padx=2)
def _create_server_controls(self, parent):
"""Create web server controls"""
server_frame = ttk.Frame(parent)
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 display
self.qr_label = ttk.Label(parent)
self.qr_label.pack(pady=10)
# URL display
self.url_var = tk.StringVar(value="")
self.url_label = ttk.Label(parent, textvariable=self.url_var)
self.url_label.pack(pady=5)
# Browser button
self.browser_button = ttk.Button(parent, text="Open in Browser",
command=self.open_in_browser, state=tk.DISABLED)
self.browser_button.pack(pady=5)
def setup_tabs(self):
"""Initialize tabs based on macro categories"""
# Clear existing tabs
for tab in self.notebook.tabs():
self.notebook.forget(tab)
# Get unique tabs from macro manager
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
frame = ttk.Frame(self.notebook)
self.notebook.add(frame, text=tab_name)
def get_current_tab_name(self):
"""Get the name of the currently selected tab"""
try:
current_tab_id = self.notebook.select()
return self.notebook.tab(current_tab_id, "text")
except:
return "All"
def on_tab_change(self, event=None):
"""Handle tab change event"""
self.current_tab = self.get_current_tab_name()
self.display_macros()
def on_sort_change(self, event=None):
"""Handle sort option change"""
self.current_sort = self.sort_var.get()
self.display_macros()
def display_macros(self):
"""Display macros in the current tab"""
# Get current tab frame
try:
current_tab_id = self.notebook.select()
current_frame = self.notebook.nametowidget(current_tab_id)
except:
return
# Clear previous content
for widget in current_frame.winfo_children():
widget.destroy()
# Create scrollable canvas
canvas = tk.Canvas(current_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(current_frame, orient="vertical", command=canvas.yview)
scrollable_frame = ttk.Frame(canvas)
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)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Get sorted and filtered macros
sorted_macros = self.macro_manager.get_sorted_macros(self.current_sort)
filtered_macros = self.macro_manager.filter_macros_by_tab(sorted_macros, self.current_tab)
# Display macros
for macro_id, macro in filtered_macros:
self._create_macro_button(scrollable_frame, macro_id, macro)
# Display message if no macros
if not filtered_macros:
label = tk.Label(scrollable_frame, text="No macros in this category",
bg=THEME['bg_color'], fg=THEME['fg_color'])
label.pack(pady=20)
def _create_macro_button(self, parent, macro_id, macro):
"""Create a button for a single macro"""
frame = ttk.Frame(parent)
frame.pack(fill="x", pady=5, padx=5)
button = tk.Button(
frame, text=macro["name"],
bg=THEME['button_bg'], fg=THEME['button_fg'],
activebackground=THEME['accent_color'], activeforeground=THEME['button_fg'],
relief=tk.RAISED, bd=2, pady=8,
command=lambda: self.macro_manager.execute_macro(macro_id)
)
# Add image if available
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.macro_manager.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 reference
except Exception as e:
print(f"Error loading image for {macro['name']}: {e}")
button.pack(side=tk.LEFT, fill=tk.X, expand=True)
def manage_tabs(self):
"""Open tab management dialog"""
tab_manager = TabManager(self.root, self.macro_manager)
tab_manager.show()
self.setup_tabs()
self.display_macros()
def add_macro(self):
"""Add a new macro"""
dialog = MacroDialog(self.root, self.macro_manager)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def edit_macro(self):
"""Edit an existing macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Edit")
macro_id = selector.show()
if macro_id:
dialog = MacroDialog(self.root, self.macro_manager, macro_id)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def delete_macro(self):
"""Delete a macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Delete")
macro_id = selector.show()
if macro_id:
macro_name = self.macro_manager.macros[macro_id]["name"]
if messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{macro_name}'?"):
self.macro_manager.delete_macro(macro_id)
self.setup_tabs()
self.display_macros()
def toggle_server(self):
"""Toggle web server on/off"""
if self.server_running:
self.stop_server()
else:
self.start_server()
def start_server(self):
"""Start the web server"""
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 IP address and display info
ip_address = self.get_ip_address()
if ip_address:
url = f"http://{ip_address}:{DEFAULT_PORT}"
url_text = f"Web UI available at:\n{url}"
self.url_var.set(url_text)
self.browser_button.config(state=tk.NORMAL)
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):
"""Stop the web server"""
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)
self.qr_label.config(image="")
def run_web_server(self):
"""Run the web server in a separate thread"""
self.status_var.set(f"Web server running on port {DEFAULT_PORT}")
try:
self.web_server.run()
except Exception as e:
self.status_var.set(f"Web server error: {e}")
self.server_running = False
def get_ip_address(self):
"""Get the primary internal IPv4 address"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
print(f"Error getting IP address: {e}")
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if not ip.startswith("127."):
return ip
for addr_info in socket.getaddrinfo(hostname, None):
potential_ip = addr_info[4][0]
if '.' in potential_ip and not potential_ip.startswith("127."):
return potential_ip
except:
pass
return "127.0.0.1"
def generate_qr_code(self, url):
"""Generate and display QR code for the URL"""
try:
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)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_photoimg = ImageTk.PhotoImage(qr_img)
self.qr_label.config(image=qr_photoimg)
self.qr_label.image = qr_photoimg
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 browser"""
if self.server_running:
webbrowser.open(f"http://localhost:{DEFAULT_PORT}")
def on_minimize(self, event):
"""Handle window minimize event"""
# Only minimize to tray if the window is being iconified, not just unmapped
if event.widget == self.root and self.root.state() == 'iconic':
self.root.withdraw() # Hide window
def create_tray_icon(self):
"""Create system tray icon"""
try:
# Create a simple icon image with M letter
icon_image = Image.new("RGB", (64, 64), THEME['accent_color'])
draw = ImageDraw.Draw(icon_image)
try:
# Try to use a system font
font = ImageFont.truetype("arial.ttf", 40)
except:
try:
# Try other common fonts
font = ImageFont.truetype("calibri.ttf", 40)
except:
# Fall back to default font
font = ImageFont.load_default()
# Draw "MP" in the center
text = "MP"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (64 - text_width) // 2
y = (64 - text_height) // 2
draw.text((x, y), text, fill="white", font=font)
menu = (
pystray.MenuItem('Show', self.show_window),
pystray.MenuItem('Exit', self.exit_app)
)
self.tray_icon = pystray.Icon("macropad", icon_image, "MacroPad Server", menu)
# Run tray icon in a separate thread
tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True)
tray_thread.start()
except Exception as e:
print(f"Error creating tray icon: {e}")
# Tray icon is optional, continue without it
def show_window(self, icon=None, item=None):
"""Show 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):
"""Exit the application"""
self.is_closing = True
self.stop_server()
if self.tray_icon:
try:
self.tray_icon.stop()
except:
pass
try:
self.root.quit()
except:
pass
# Force exit if needed
import os
os._exit(0)
def on_closing(self):
"""Handle window close event - exit the application"""
self.exit_app()
# Run application
sys.exit(app.exec())
if __name__ == "__main__":
root = tk.Tk()
app = MacroPadServer(root)
root.mainloop()
main()

View File

@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.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='main',
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
pyproject.toml Normal file
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"]

View File

@@ -1,8 +0,0 @@
pillow
pyautogui
pystray
flask
waitress
netifaces
qrcode
tkinter

View File

@@ -1,283 +0,0 @@
# UI components and dialogs for the desktop application
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import os
import uuid
from config import THEME
class MacroDialog:
"""Dialog for adding/editing macros"""
def __init__(self, parent, macro_manager, macro_id=None):
self.parent = parent
self.macro_manager = macro_manager
self.macro_id = macro_id
self.dialog = None
self.result = None
def show(self):
"""Show the dialog and return the result"""
self.dialog = tk.Toplevel(self.parent)
self.dialog.title("Edit Macro" if self.macro_id else "Add Macro")
self.dialog.geometry("450x400")
self.dialog.transient(self.parent)
self.dialog.configure(bg=THEME['bg_color'])
self.dialog.grab_set()
# If editing, get existing macro data
if self.macro_id:
macro = self.macro_manager.macros.get(self.macro_id, {})
else:
macro = {}
self._create_widgets(macro)
# Wait for dialog to close
self.dialog.wait_window()
return self.result
def _create_widgets(self, macro):
"""Create the dialog widgets"""
# Name
tk.Label(self.dialog, text="Macro Name:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.name_entry.grid(row=0, column=1, padx=5, pady=5)
self.name_entry.insert(0, macro.get("name", ""))
# Category
tk.Label(self.dialog, text="Category:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.category_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.category_entry.grid(row=1, column=1, padx=5, pady=5)
self.category_entry.insert(0, macro.get("category", ""))
# Type
tk.Label(self.dialog, text="Type:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.type_var = tk.StringVar(value=macro.get("type", "text"))
radio_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color']}
tk.Radiobutton(self.dialog, text="Text", variable=self.type_var, value="text", **radio_style).grid(row=2, column=1, sticky="w")
tk.Radiobutton(self.dialog, text="Application", variable=self.type_var, value="app", **radio_style).grid(row=3, column=1, sticky="w")
# Command/Text
tk.Label(self.dialog, text="Command/Text:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=4, column=0, padx=5, pady=5, sticky="w")
self.command_text = tk.Text(self.dialog, width=30, height=5, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.command_text.grid(row=4, column=1, padx=5, pady=5)
self.command_text.insert("1.0", macro.get("command", ""))
# Modifiers
self._create_modifiers(macro)
# Image
self.image_path = tk.StringVar()
tk.Label(self.dialog, text="Image:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=6, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(self.dialog, textvariable=self.image_path, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
image_entry.grid(row=6, column=1, padx=5, pady=5)
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(self.dialog, text="Browse...", command=self._browse_image, **button_style).grid(row=6, column=2)
# Buttons
tk.Button(self.dialog, text="Save", command=self._save, **button_style).grid(row=7, column=0, padx=5, pady=20)
tk.Button(self.dialog, text="Cancel", command=self._cancel, **button_style).grid(row=7, column=1, padx=5, pady=20)
def _create_modifiers(self, macro):
"""Create modifier checkboxes"""
mod_frame = tk.Frame(self.dialog, bg=THEME['bg_color'])
mod_frame.grid(row=5, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(side=tk.LEFT, padx=5)
modifiers = macro.get("modifiers", {})
self.ctrl_var = tk.BooleanVar(value=modifiers.get("ctrl", False))
self.alt_var = tk.BooleanVar(value=modifiers.get("alt", False))
self.shift_var = tk.BooleanVar(value=modifiers.get("shift", False))
self.enter_var = tk.BooleanVar(value=modifiers.get("enter", False))
checkbox_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color'],
'activebackground': THEME['bg_color'], 'activeforeground': THEME['fg_color']}
tk.Checkbutton(mod_frame, text="Ctrl", variable=self.ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=self.alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=self.shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=self.enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
def _browse_image(self):
"""Browse for image file"""
filename = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])
if filename:
self.image_path.set(filename)
def _save(self):
"""Save the macro"""
name = self.name_entry.get().strip()
if not name:
messagebox.showerror("Error", "Macro name is required")
return
macro_type = self.type_var.get()
command = self.command_text.get("1.0", tk.END).strip()
category = self.category_entry.get().strip()
modifiers = {
"ctrl": self.ctrl_var.get(),
"alt": self.alt_var.get(),
"shift": self.shift_var.get(),
"enter": self.enter_var.get()
}
if self.macro_id:
# Update existing macro
success = self.macro_manager.update_macro(
self.macro_id, name, macro_type, command, category, modifiers, self.image_path.get())
else:
# Add new macro
self.macro_id = self.macro_manager.add_macro(
name, macro_type, command, category, modifiers, self.image_path.get())
success = bool(self.macro_id)
if success:
self.result = self.macro_id
self.dialog.destroy()
else:
messagebox.showerror("Error", "Failed to save macro")
def _cancel(self):
"""Cancel dialog"""
self.result = None
self.dialog.destroy()
class MacroSelector:
"""Dialog for selecting a macro from a list"""
def __init__(self, parent, macro_manager, title="Select Macro"):
self.parent = parent
self.macro_manager = macro_manager
self.title = title
self.result = None
def show(self):
"""Show the selection dialog"""
if not self.macro_manager.macros:
messagebox.showinfo("No Macros", "There are no macros available.")
return None
dialog = tk.Toplevel(self.parent)
dialog.title(self.title)
dialog.geometry("200x340")
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text=f"{self.title}:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=5)
# Listbox
listbox = tk.Listbox(dialog, bg=THEME['highlight_color'], fg=THEME['fg_color'], selectbackground=THEME['accent_color'])
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate listbox
macro_ids = []
for macro_id, macro in self.macro_manager.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_select():
if not listbox.curselection():
messagebox.showwarning("No Selection", f"Please select a macro.")
return
idx = listbox.curselection()[0]
self.result = macro_ids[idx]
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Select", command=on_select, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(pady=5)
dialog.wait_window()
return self.result
class TabManager:
"""Dialog for managing macro categories/tabs"""
def __init__(self, parent, macro_manager):
self.parent = parent
self.macro_manager = macro_manager
def show(self):
"""Show tab management dialog"""
dialog = tk.Toplevel(self.parent)
dialog.title("Manage Tabs")
dialog.geometry("450x400") # Increased width and height
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text="Assign categories to macros for better organization:",
bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=10)
# Create scrollable frame
list_frame = tk.Frame(dialog, bg=THEME['bg_color'])
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
canvas = tk.Canvas(list_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
scrollable_frame = tk.Frame(canvas, bg=THEME['bg_color'])
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)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Category entries for each macro
category_vars = {}
for macro_id, macro in self.macro_manager.macros.items():
frame = tk.Frame(scrollable_frame, bg=THEME['bg_color'])
frame.pack(fill="x", pady=2, padx=5)
tk.Label(frame, text=macro["name"], bg=THEME['bg_color'], fg=THEME['fg_color'],
width=20, anchor="w").pack(side=tk.LEFT)
category_var = tk.StringVar(value=macro.get("category", ""))
category_vars[macro_id] = category_var
entry = tk.Entry(frame, textvariable=category_var, width=15,
bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
entry.pack(side=tk.RIGHT, padx=(5, 0))
# Buttons - use a fixed frame at bottom
button_frame = tk.Frame(dialog, bg=THEME['bg_color'])
button_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=10)
def save_categories():
for macro_id, category_var in category_vars.items():
category = category_var.get().strip()
if category:
self.macro_manager.macros[macro_id]["category"] = category
else:
self.macro_manager.macros[macro_id].pop("category", None)
self.macro_manager.save_macros()
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(button_frame, text="Save", command=save_categories, **button_style).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(side=tk.LEFT, padx=5)
dialog.wait_window()

View File

@@ -1 +1 @@
0.8.5
0.9.0

596
web/css/styles.css Normal file
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;
}

BIN
web/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
web/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

61
web/index.html Normal file
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
web/js/app.js Normal file
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
web/manifest.json Normal file
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
web/service-worker.js Normal file
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('/');
}
})
);
});

View File

@@ -1,99 +1,289 @@
# Web server component for MacroPad
# FastAPI web server for MacroPad
from flask import Flask, render_template_string, request, jsonify, send_file
from waitress import serve
import logging
import os
from web_templates import INDEX_HTML
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:
def __init__(self, macro_manager, app_dir, port=40000):
"""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.app_dir = app_dir
self.port = port
self.app = None
def create_app(self):
"""Create and configure Flask application"""
app = Flask(__name__)
# Disable Flask's logging except for errors
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
@app.route('/')
def index():
return render_template_string(INDEX_HTML)
@app.route('/api/tabs')
def get_tabs():
"""Get all available tabs (similar to setup_tabs logic)"""
tabs = ["All"]
# Add tabs based on macro types and custom categories
unique_types = set()
for macro in self.macro_manager.macros.values():
if macro.get("type"):
unique_types.add(macro["type"].title())
# Check for custom category
if macro.get("category"):
unique_types.add(macro["category"])
for tab_type in sorted(unique_types):
if tab_type not in ["All"]:
tabs.append(tab_type)
return jsonify(tabs)
@app.route('/api/macros')
def get_macros():
return jsonify(self.macro_manager.macros)
@app.route('/api/macros/<tab_name>')
def get_macros_by_tab(tab_name):
"""Filter macros by tab (similar to filter_macros_by_tab logic)"""
if tab_name == "All":
return jsonify(self.macro_manager.macros)
filtered_macros = {}
for macro_id, macro in self.macro_manager.macros.items():
# Check type match
if macro.get("type", "").title() == tab_name:
filtered_macros[macro_id] = macro
# Check custom category match
elif macro.get("category") == tab_name:
filtered_macros[macro_id] = macro
return jsonify(filtered_macros)
@app.route('/api/image/<path:image_path>')
def get_image(image_path):
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:
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():
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.macro_manager.execute_macro(macro_id)
return jsonify({"success": success})
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"""
"""Run the web server (blocking)."""
if not self.app:
self.create_app()
try:
serve(self.app, host='0.0.0.0', port=self.port, threads=4)
except Exception as e:
raise e
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()

View File

@@ -1,292 +0,0 @@
# HTML templates for the web interface
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;
}
.tab-container {
margin-bottom: 20px;
border-bottom: 2px solid #404040;
}
.tab-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 0;
}
.tab-button {
background-color: #404040;
color: #ffffff;
border: none;
border-radius: 8px 8px 0 0;
padding: 10px 15px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
border-bottom: 3px solid transparent;
}
.tab-button:hover {
background-color: #505050;
}
.tab-button.active {
background-color: #007acc;
border-bottom-color: #007acc;
}
.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;
}
.tab-button {
padding: 8px 12px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div class="header-container">
<h1>MacroPad Web Interface</h1>
<button class="refresh-button" onclick="loadTabs()">
<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="tab-container">
<div class="tab-list" id="tab-list">
<!-- Tabs will be loaded here -->
</div>
</div>
<div class="macro-grid" id="macro-grid">
<!-- Macros will be loaded here -->
</div>
<script>
let currentTab = 'All';
let allMacros = {};
document.addEventListener('DOMContentLoaded', function() {
loadTabs();
});
function loadTabs() {
fetch('/api/tabs')
.then(response => response.json())
.then(tabs => {
const tabList = document.getElementById('tab-list');
tabList.innerHTML = '';
tabs.forEach(tab => {
const button = document.createElement('button');
button.className = 'tab-button';
button.textContent = tab;
button.onclick = function() { switchTab(tab); };
if (tab === currentTab) {
button.classList.add('active');
}
tabList.appendChild(button);
});
// Load macros for current tab
loadMacros(currentTab);
})
.catch(error => {
console.error('Error loading tabs:', error);
});
}
function switchTab(tabName) {
currentTab = tabName;
// Update tab button states
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.classList.remove('active');
if (button.textContent === tabName) {
button.classList.add('active');
}
});
// Load macros for selected tab
loadMacros(tabName);
}
function loadMacros(tab = 'All') {
const url = tab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(tab)}`;
fetch(url)
.then(response => response.json())
.then(macros => {
allMacros = macros;
displayMacros(macros);
})
.catch(error => {
console.error('Error loading macros:', error);
});
}
function displayMacros(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 in this category.</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);
}
}
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>
'''