Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62559a59c9 | |||
| e0df32f42b | |||
| d409b64efa | |||
| 2f855bf720 | |||
| 95552ca7c9 | |||
| 2211948e9c | |||
| 8c3e5106d5 | |||
| e9b3108226 | |||
| 1f8ecf6764 | |||
| 0d0786e222 | |||
| fc01f8e995 | |||
| d82ee25916 | |||
| 9f06b45322 | |||
| 5c6c72d928 | |||
| b5e5bed7dd | |||
| 74bd3c2e98 | |||
| 60c3bbcedb | |||
| d07005bde3 | |||
| 0d6f48ca24 | |||
| 5896ec0893 | |||
| 01163891ec | |||
| beba425868 | |||
| 435cc1aca9 | |||
| eba5a2a38e | |||
| 1f26427328 | |||
| 7922910bd8 | |||
| 664d652e9e | |||
| 10971e6a02 | |||
| 17f4bc0c5f | |||
| 44c21e68d8 | |||
| 6f3823eccf | |||
| 4a93f94b8c | |||
| f87dab6bc2 | |||
| 5b6eb33bad | |||
| 7d95d47c73 | |||
| 59254383ad | |||
| 5aed19564c | |||
| 6e76d469c8 | |||
| ff3c7b990c | |||
| 1d7f18018d | |||
| 8e4c32fea4 | |||
| 6974947028 | |||
| 517ee943a9 | |||
| c9d0c9812d | |||
| 3521f777e9 | |||
| 8ce09fcaf6 | |||
| a1a7334772 |
@@ -0,0 +1,160 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
# Windows-only build for the self-hosted org runner labelled "windows-latest".
|
||||||
|
# Produces dist/macropad.exe as a build artifact, and on a version tag (v*)
|
||||||
|
# creates/updates a Gitea release and attaches the exe.
|
||||||
|
#
|
||||||
|
# Notes for self-hosted Windows runners:
|
||||||
|
# - Uses `shell: powershell` (Windows PowerShell 5.1); `pwsh`/PowerShell 7 is
|
||||||
|
# not assumed to be installed.
|
||||||
|
# - actions/setup-python is avoided (its Windows tool-cache install hangs on
|
||||||
|
# self-hosted runners); Python 3.11 is used if present, else silently
|
||||||
|
# installed per-user.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Fast smoke test so a broken runner fails in seconds, not minutes.
|
||||||
|
preflight:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Runner check
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
Write-Host "Runner OK on $env:COMPUTERNAME"
|
||||||
|
Write-Host "OS: $([System.Environment]::OSVersion.VersionString)"
|
||||||
|
Write-Host "PowerShell: $($PSVersionTable.PSVersion)"
|
||||||
|
$py = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($py) { & py -3.11 --version } else { Write-Host "No py launcher; build will self-install Python 3.11." }
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs: [preflight]
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Python 3.11
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
# Persistent, side-effect-free Python for this builder. We use a
|
||||||
|
# standalone CPython from nuget (no registry/PATH changes, idempotent)
|
||||||
|
# cached under LOCALAPPDATA so subsequent runs reuse it. A pre-existing
|
||||||
|
# Python 3.11 on PATH is honoured if present.
|
||||||
|
$pyVersion = "3.11.9"
|
||||||
|
$root = Join-Path $env:LOCALAPPDATA "MacroPadBuild"
|
||||||
|
$python = Join-Path $root "python.$pyVersion\tools\python.exe"
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
# Honour an existing 3.11 on PATH (e.g. if the VM is pre-provisioned).
|
||||||
|
try {
|
||||||
|
$v = & python --version 2>&1
|
||||||
|
if ($LASTEXITCODE -eq 0 -and "$v" -match '3\.11') {
|
||||||
|
$python = (& python -c "import sys; print(sys.executable)").Trim()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
Write-Host "Provisioning standalone Python $pyVersion via nuget..."
|
||||||
|
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||||
|
$nuget = Join-Path $root "nuget.exe"
|
||||||
|
if (-not (Test-Path $nuget)) {
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -OutFile $nuget
|
||||||
|
}
|
||||||
|
& $nuget install python -Version $pyVersion -OutputDirectory $root -Source "https://api.nuget.org/v3/index.json" -NonInteractive
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "nuget install python exited with $LASTEXITCODE" }
|
||||||
|
$python = Join-Path $root "python.$pyVersion\tools\python.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) { throw "Python not found at '$python'" }
|
||||||
|
& $python -m ensurepip --upgrade 2>$null | Out-Null
|
||||||
|
& $python --version
|
||||||
|
# Write to GITHUB_ENV without a BOM (ascii) so the value parses cleanly.
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "PYTHON=$python" -Encoding ascii
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
# Native command failures do NOT trip $ErrorActionPreference, so check
|
||||||
|
# $LASTEXITCODE explicitly — otherwise a broken install ships silently.
|
||||||
|
& $env:PYTHON -m pip install --upgrade pip
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed ($LASTEXITCODE)" }
|
||||||
|
& $env:PYTHON -m pip install -e .
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip install -e . failed ($LASTEXITCODE)" }
|
||||||
|
& $env:PYTHON -m pip install pyinstaller
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip install pyinstaller failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
|
- name: Verify runtime imports
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
# Fail fast if any runtime dependency is missing before we bundle.
|
||||||
|
& $env:PYTHON -c "import PySide6.QtWidgets, fastapi, uvicorn, aiohttp, PIL, pystray, qrcode, pyautogui, pyperclip, websockets, multipart; print('deps OK')"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "dependency import smoke test failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
|
- name: Build Windows executable
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
& $env:PYTHON -m PyInstaller --noconfirm macropad.spec
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
if (-not (Test-Path dist/macropad.exe)) {
|
||||||
|
throw "Build failed: dist/macropad.exe not found"
|
||||||
|
}
|
||||||
|
Get-Item dist/macropad.exe | Format-List Name, Length, LastWriteTime
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
# v3 uses the artifact protocol Gitea supports; v4+ requires a backend
|
||||||
|
# Gitea (reported as GHES) does not provide.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: macropad-windows
|
||||||
|
path: dist/macropad.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish release asset (tags only)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$api = $env:GITHUB_API_URL
|
||||||
|
$repo = $env:GITHUB_REPOSITORY
|
||||||
|
$tag = $env:GITHUB_REF_NAME
|
||||||
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
|
# Create the release for this tag, or fetch it if it already exists.
|
||||||
|
$body = @{ tag_name = $tag; name = $tag; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod -Method Post -Uri "$api/repos/$repo/releases" `
|
||||||
|
-Headers $headers -ContentType 'application/json' -Body $body
|
||||||
|
} catch {
|
||||||
|
$rel = Invoke-RestMethod -Method Get -Uri "$api/repos/$repo/releases/tags/$tag" -Headers $headers
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attach the executable (replace a prior asset of the same name).
|
||||||
|
$existing = $rel.assets | Where-Object { $_.name -eq 'macropad.exe' }
|
||||||
|
if ($existing) {
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers `
|
||||||
|
-Uri "$api/repos/$repo/releases/$($rel.id)/assets/$($existing.id)"
|
||||||
|
}
|
||||||
|
Invoke-RestMethod -Method Post -Headers $headers `
|
||||||
|
-Uri "$api/repos/$repo/releases/$($rel.id)/assets?name=macropad.exe" `
|
||||||
|
-InFile dist/macropad.exe -ContentType 'application/octet-stream'
|
||||||
|
Write-Host "Attached macropad.exe to release $tag"
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# WORKFLOW DISABLED - Pending testing of v0.9.0 modernization
|
|
||||||
# =============================================================================
|
|
||||||
# This workflow is temporarily disabled while testing the new:
|
|
||||||
# - PySide6 GUI (replacing Tkinter)
|
|
||||||
# - FastAPI web server (replacing Flask)
|
|
||||||
# - pyproject.toml build system (replacing requirements.txt)
|
|
||||||
# - PWA web interface
|
|
||||||
#
|
|
||||||
# Uncomment the workflow below once builds are verified locally.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# name: Build and Release
|
|
||||||
#
|
|
||||||
# on:
|
|
||||||
# push:
|
|
||||||
# branches:
|
|
||||||
# - main
|
|
||||||
#
|
|
||||||
# jobs:
|
|
||||||
# create-release:
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
#
|
|
||||||
# - name: Get version
|
|
||||||
# id: get_version
|
|
||||||
# run: |
|
|
||||||
# VERSION=$(cat version.txt)
|
|
||||||
# echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
||||||
#
|
|
||||||
# - name: Create Release
|
|
||||||
# id: create_release
|
|
||||||
# uses: softprops/action-gh-release@v1
|
|
||||||
# with:
|
|
||||||
# tag_name: v${{ env.VERSION }}
|
|
||||||
# name: Release v${{ env.VERSION }}
|
|
||||||
# draft: false
|
|
||||||
# prerelease: false
|
|
||||||
#
|
|
||||||
# build-windows:
|
|
||||||
# needs: [create-release]
|
|
||||||
# runs-on: windows-latest
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
#
|
|
||||||
# - name: Set up Python
|
|
||||||
# uses: actions/setup-python@v4
|
|
||||||
# with:
|
|
||||||
# python-version: '3.11'
|
|
||||||
#
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: |
|
|
||||||
# python -m pip install --upgrade pip
|
|
||||||
# pip install pyinstaller
|
|
||||||
# pip install -e .
|
|
||||||
#
|
|
||||||
# - name: Build executable
|
|
||||||
# run: |
|
|
||||||
# pyinstaller macropad.spec
|
|
||||||
#
|
|
||||||
# - name: Upload Windows artifact
|
|
||||||
# uses: actions/upload-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: macropad-windows
|
|
||||||
# path: dist/macropad.exe
|
|
||||||
#
|
|
||||||
# build-linux:
|
|
||||||
# needs: [create-release]
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
#
|
|
||||||
# - name: Set up Python
|
|
||||||
# uses: actions/setup-python@v4
|
|
||||||
# with:
|
|
||||||
# python-version: '3.11'
|
|
||||||
#
|
|
||||||
# - name: Install system dependencies
|
|
||||||
# run: |
|
|
||||||
# sudo apt-get update
|
|
||||||
# # PySide6 requirements
|
|
||||||
# sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1
|
|
||||||
# # System tray requirements
|
|
||||||
# sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0
|
|
||||||
# sudo apt-get install -y gir1.2-appindicator3-0.1
|
|
||||||
# sudo apt-get install -y libcairo2-dev libgirepository1.0-dev
|
|
||||||
#
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: |
|
|
||||||
# python -m pip install --upgrade pip
|
|
||||||
# pip install pyinstaller
|
|
||||||
# pip install -e .
|
|
||||||
#
|
|
||||||
# - name: Build executable
|
|
||||||
# run: |
|
|
||||||
# pyinstaller macropad_linux.spec
|
|
||||||
#
|
|
||||||
# - name: Upload Linux artifact
|
|
||||||
# uses: actions/upload-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: macropad-linux
|
|
||||||
# path: dist/macropad
|
|
||||||
#
|
|
||||||
# # MacOS build - requires macos runner
|
|
||||||
# # build-macos:
|
|
||||||
# # needs: [create-release]
|
|
||||||
# # runs-on: macos-latest
|
|
||||||
# # steps:
|
|
||||||
# # - name: Checkout code
|
|
||||||
# # uses: actions/checkout@v3
|
|
||||||
# #
|
|
||||||
# # - name: Set up Python
|
|
||||||
# # uses: actions/setup-python@v4
|
|
||||||
# # with:
|
|
||||||
# # python-version: '3.11'
|
|
||||||
# #
|
|
||||||
# # - name: Install dependencies
|
|
||||||
# # run: |
|
|
||||||
# # python -m pip install --upgrade pip
|
|
||||||
# # pip install pyinstaller
|
|
||||||
# # pip install -e .
|
|
||||||
# #
|
|
||||||
# # - name: Build executable
|
|
||||||
# # run: |
|
|
||||||
# # pyinstaller macropad_macos.spec
|
|
||||||
# #
|
|
||||||
# # - name: Upload macOS artifact
|
|
||||||
# # uses: actions/upload-artifact@v3
|
|
||||||
# # with:
|
|
||||||
# # name: macropad-macos
|
|
||||||
# # path: dist/macropad.app
|
|
||||||
#
|
|
||||||
# attach-to-release:
|
|
||||||
# needs: [create-release, build-windows, build-linux]
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
#
|
|
||||||
# - name: Get version
|
|
||||||
# id: get_version
|
|
||||||
# run: |
|
|
||||||
# VERSION=$(cat version.txt)
|
|
||||||
# echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
||||||
#
|
|
||||||
# - name: Download all artifacts
|
|
||||||
# uses: actions/download-artifact@v3
|
|
||||||
#
|
|
||||||
# - name: Attach executables to release
|
|
||||||
# uses: softprops/action-gh-release@v1
|
|
||||||
# with:
|
|
||||||
# tag_name: v${{ env.VERSION }}
|
|
||||||
# files: |
|
|
||||||
# macropad-windows/macropad.exe
|
|
||||||
# macropad-linux/macropad
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -11,6 +11,7 @@ A cross-platform macro management application with desktop and web interfaces. C
|
|||||||
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
|
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
|
||||||
- **Wait**: Insert delays between commands (in milliseconds)
|
- **Wait**: Insert delays between commands (in milliseconds)
|
||||||
- **App**: Launch applications or scripts
|
- **App**: Launch applications or scripts
|
||||||
|
- **Media & System Keys**: Control playback and audio (volume up/down/mute, play/pause, next/previous track, stop) alongside regular keys
|
||||||
- **Custom Images**: Assign images to macros for easy identification
|
- **Custom Images**: Assign images to macros for easy identification
|
||||||
- **Category Management**: Organize macros into custom tabs
|
- **Category Management**: Organize macros into custom tabs
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ A cross-platform macro management application with desktop and web interfaces. C
|
|||||||
- **System Tray**: Minimize to tray, always accessible
|
- **System Tray**: Minimize to tray, always accessible
|
||||||
|
|
||||||
### Additional Features
|
### Additional Features
|
||||||
|
- **Relay Server Support**: Access your macros securely over HTTPS from anywhere
|
||||||
- **QR Code Generation**: Quickly connect mobile devices
|
- **QR Code Generation**: Quickly connect mobile devices
|
||||||
- **Real-time Sync**: WebSocket updates across all connected devices
|
- **Real-time Sync**: WebSocket updates across all connected devices
|
||||||
- **Offline Support**: PWA caches for offline macro viewing
|
- **Offline Support**: PWA caches for offline macro viewing
|
||||||
@@ -37,8 +39,8 @@ A cross-platform macro management application with desktop and web interfaces. C
|
|||||||
- PyAutoGUI (Keyboard automation)
|
- PyAutoGUI (Keyboard automation)
|
||||||
- Pillow (Image processing)
|
- Pillow (Image processing)
|
||||||
- pystray (System tray)
|
- pystray (System tray)
|
||||||
- netifaces (Network detection)
|
|
||||||
- qrcode (QR code generation)
|
- qrcode (QR code generation)
|
||||||
|
- aiohttp (Relay server client)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -117,15 +119,18 @@ A macro that types a username, waits, presses Tab, types a password, and presses
|
|||||||
### Web Interface (PWA)
|
### Web Interface (PWA)
|
||||||
|
|
||||||
1. Start the application (web server starts automatically on port 40000)
|
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`)
|
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000/?token=...`)
|
||||||
3. Open this URL on any device on your network
|
3. Open this URL on any device on your network
|
||||||
4. **Install as PWA**:
|
4. **Install as PWA**:
|
||||||
- Mobile: Tap browser menu → "Add to Home Screen"
|
- Mobile: Tap browser menu → "Add to Home Screen"
|
||||||
- Desktop: Click install icon in address bar
|
- Desktop: Click install icon in address bar
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The local web server now requires an access token. A per-install token is auto-generated and stored in `settings.json`, and is embedded in the URL shown in the toolbar and in the QR code — so copying the URL or scanning the QR just works. Anyone with that URL can control your macros, so treat it like a password. By default the server also binds to your LAN; set `web.allow_lan` to `false` in settings to restrict access to localhost only.
|
||||||
|
|
||||||
The web interface provides full macro management:
|
The web interface provides full macro management:
|
||||||
- View and execute macros
|
- View and execute macros
|
||||||
- Create and edit macros with command builder
|
- Create, edit, and delete macros directly from the web app — tap the **+** button to open the command-sequence builder (no desktop app required)
|
||||||
- Organize into categories
|
- Organize into categories
|
||||||
- Real-time sync across devices
|
- Real-time sync across devices
|
||||||
|
|
||||||
@@ -136,6 +141,30 @@ The web interface provides full macro management:
|
|||||||
- **Show**: Restore window
|
- **Show**: Restore window
|
||||||
- **Quit**: Exit application
|
- **Quit**: Exit application
|
||||||
|
|
||||||
|
### Relay Server (Remote Access)
|
||||||
|
|
||||||
|
Access your macros from outside your local network using a relay server:
|
||||||
|
|
||||||
|
1. Click **Settings** (gear icon) in the toolbar
|
||||||
|
2. Check **Enable Relay Server**
|
||||||
|
3. Enter your relay server URL and password
|
||||||
|
4. Click **Save**
|
||||||
|
|
||||||
|
Once connected, a relay URL will appear in the toolbar. Use this URL from any device with internet access. The relay provides:
|
||||||
|
- Secure HTTPS connection
|
||||||
|
- Macro browsing and execution
|
||||||
|
- PWA installation support
|
||||||
|
- Wake lock and fullscreen mode
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The relay web client is **execute-only**: remote devices can browse and trigger macros, but macro creation/editing is intentionally limited to the local network (the desktop app and the local web interface). This keeps macro authoring — which defines what runs on your computer — off the public internet.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The relay password is stored in your OS keyring when one is available, falling back to the settings file (written with restricted `0600` permissions) otherwise.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions. The self-hosted relay now enforces login rate-limiting/lockout, keeps credentials out of URLs, and requires setting sane environment values — see the relay repo's `.env.example`.
|
||||||
|
|
||||||
## Command Types Reference
|
## Command Types Reference
|
||||||
|
|
||||||
| Type | Description | Parameters |
|
| Type | Description | Parameters |
|
||||||
@@ -144,10 +173,13 @@ The web interface provides full macro management:
|
|||||||
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
|
| `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"]) |
|
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
|
||||||
| `wait` | Delays execution | `ms`: Milliseconds to wait |
|
| `wait` | Delays execution | `ms`: Milliseconds to wait |
|
||||||
| `app` | Launches application | `command`: Shell command to execute |
|
| `app` | Launches an application | `command`: Program and arguments to run (tokenized and executed directly — no shell) |
|
||||||
|
|
||||||
## Example Application Commands
|
## Example Application Commands
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> `app` commands are tokenized and the program is executed **directly**, without a shell. This closes a remote-code-execution risk, but it means shell features are **not** supported: command chaining (`;`, `&&`, `|`), output redirection (`>`), environment-variable expansion (`$VAR`, `%VAR%`), and shell built-ins will not work. Launching a program with arguments works exactly as shown below.
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
```bash
|
```bash
|
||||||
# Steam game
|
# Steam game
|
||||||
@@ -213,6 +245,7 @@ MP-Server/
|
|||||||
├── config.py # Configuration constants
|
├── config.py # Configuration constants
|
||||||
├── macro_manager.py # Macro storage and execution
|
├── macro_manager.py # Macro storage and execution
|
||||||
├── web_server.py # FastAPI web server
|
├── web_server.py # FastAPI web server
|
||||||
|
├── relay_client.py # Relay server WebSocket client
|
||||||
├── pyproject.toml # Dependencies and build config
|
├── pyproject.toml # Dependencies and build config
|
||||||
├── gui/ # PySide6 desktop interface
|
├── gui/ # PySide6 desktop interface
|
||||||
│ ├── main_window.py
|
│ ├── main_window.py
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Configuration and constants for MacroPad Server
|
# Configuration and constants for MacroPad Server
|
||||||
|
|
||||||
VERSION = "0.9.0"
|
VERSION = "1.1.1"
|
||||||
DEFAULT_PORT = 40000
|
DEFAULT_PORT = 40000
|
||||||
|
SETTINGS_FILE = "settings.json"
|
||||||
|
|
||||||
# UI Theme colors
|
# UI Theme colors
|
||||||
THEME = {
|
THEME = {
|
||||||
@@ -12,7 +13,10 @@ THEME = {
|
|||||||
'button_bg': "#505050",
|
'button_bg': "#505050",
|
||||||
'button_fg': "#ffffff",
|
'button_fg': "#ffffff",
|
||||||
'tab_bg': "#404040",
|
'tab_bg': "#404040",
|
||||||
'tab_selected': "#007acc"
|
'tab_selected': "#007acc",
|
||||||
|
'accent_hover': "#0096ff",
|
||||||
|
'danger_color': "#dc3545",
|
||||||
|
'danger_hover': "#c82333"
|
||||||
}
|
}
|
||||||
|
|
||||||
# File extensions for images
|
# File extensions for images
|
||||||
|
|||||||
+45
-18
@@ -1,19 +1,30 @@
|
|||||||
# Macro editor dialog with command builder (PySide6)
|
# Macro editor dialog with command builder (PySide6)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||||
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
|
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
|
||||||
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
|
QComboBox, QMessageBox, QFileDialog, QWidget,
|
||||||
QGroupBox, QScrollArea, QCompleter
|
QGroupBox, QScrollArea
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QPixmap, QIcon
|
from PySide6.QtGui import QPixmap
|
||||||
|
|
||||||
from config import THEME, IMAGE_EXTENSIONS
|
from config import THEME, IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
# Selectable single keys offered in the key picker. Includes common media/system
|
||||||
|
# keys pyautogui supports, a near-zero-cost win for a media/stream deck.
|
||||||
|
KEY_CHOICES = [
|
||||||
|
"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",
|
||||||
|
# Media / system keys
|
||||||
|
"VolumeUp", "VolumeDown", "VolumeMute",
|
||||||
|
"PlayPause", "NextTrack", "PrevTrack", "Stop",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CommandItem(QWidget):
|
class CommandItem(QWidget):
|
||||||
"""Widget representing a single command in the list."""
|
"""Widget representing a single command in the list."""
|
||||||
@@ -86,7 +97,7 @@ class CommandItem(QWidget):
|
|||||||
del_btn = QPushButton("X")
|
del_btn = QPushButton("X")
|
||||||
del_btn.setStyleSheet(f"""
|
del_btn.setStyleSheet(f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background-color: #dc3545;
|
background-color: {THEME['danger_color']};
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
@@ -94,7 +105,7 @@ class CommandItem(QWidget):
|
|||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background-color: #c82333;
|
background-color: {THEME['danger_hover']};
|
||||||
}}
|
}}
|
||||||
""")
|
""")
|
||||||
del_btn.setFixedSize(30, 28)
|
del_btn.setFixedSize(30, 28)
|
||||||
@@ -212,9 +223,7 @@ class CommandBuilder(QWidget):
|
|||||||
|
|
||||||
elif cmd_type == "key":
|
elif cmd_type == "key":
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
|
keys = list(KEY_CHOICES)
|
||||||
"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)
|
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
|
||||||
if not ok or not key:
|
if not ok or not key:
|
||||||
return
|
return
|
||||||
@@ -243,7 +252,15 @@ class CommandBuilder(QWidget):
|
|||||||
|
|
||||||
elif cmd_type == "app":
|
elif cmd_type == "app":
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
cmd, ok = QInputDialog.getText(self, "App Command", "Enter application command:")
|
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:
|
if not ok or not cmd:
|
||||||
return
|
return
|
||||||
command["command"] = cmd
|
command["command"] = cmd
|
||||||
@@ -287,9 +304,7 @@ class CommandBuilder(QWidget):
|
|||||||
cmd["value"] = text
|
cmd["value"] = text
|
||||||
|
|
||||||
elif cmd_type == "key":
|
elif cmd_type == "key":
|
||||||
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
|
keys = list(KEY_CHOICES)
|
||||||
"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]
|
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
|
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)
|
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
|
||||||
@@ -314,7 +329,13 @@ class CommandBuilder(QWidget):
|
|||||||
|
|
||||||
elif cmd_type == "app":
|
elif cmd_type == "app":
|
||||||
text, ok = QInputDialog.getText(
|
text, ok = QInputDialog.getText(
|
||||||
self, "Edit App", "Enter application command:",
|
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", "")
|
text=cmd.get("command", "")
|
||||||
)
|
)
|
||||||
if ok and text:
|
if ok and text:
|
||||||
@@ -497,7 +518,7 @@ class MacroEditorDialog(QDialog):
|
|||||||
delete_btn = QPushButton("Delete")
|
delete_btn = QPushButton("Delete")
|
||||||
delete_btn.setStyleSheet(f"""
|
delete_btn.setStyleSheet(f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background-color: #dc3545;
|
background-color: {THEME['danger_color']};
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
@@ -505,7 +526,7 @@ class MacroEditorDialog(QDialog):
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background-color: #c82333;
|
background-color: {THEME['danger_hover']};
|
||||||
}}
|
}}
|
||||||
""")
|
""")
|
||||||
delete_btn.clicked.connect(self.delete_macro)
|
delete_btn.clicked.connect(self.delete_macro)
|
||||||
@@ -540,7 +561,7 @@ class MacroEditorDialog(QDialog):
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background-color: #0096ff;
|
background-color: {THEME['accent_hover']};
|
||||||
}}
|
}}
|
||||||
""")
|
""")
|
||||||
save_btn.clicked.connect(self.save_macro)
|
save_btn.clicked.connect(self.save_macro)
|
||||||
@@ -620,9 +641,15 @@ class MacroEditorDialog(QDialog):
|
|||||||
|
|
||||||
def delete_macro(self):
|
def delete_macro(self):
|
||||||
"""Delete the current macro."""
|
"""Delete the current macro."""
|
||||||
|
macro_name = self.name_input.text().strip()
|
||||||
|
prompt = (
|
||||||
|
f"Are you sure you want to delete \"{macro_name}\"?"
|
||||||
|
if macro_name else
|
||||||
|
"Are you sure you want to delete this macro?"
|
||||||
|
)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Delete Macro",
|
self, "Delete Macro",
|
||||||
"Are you sure you want to delete this macro?",
|
prompt,
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
|
|||||||
+387
-36
@@ -5,6 +5,10 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Windows startup management
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(relative_path):
|
def get_resource_path(relative_path):
|
||||||
"""Get the path to a bundled resource file."""
|
"""Get the path to a bundled resource file."""
|
||||||
@@ -17,15 +21,16 @@ def get_resource_path(relative_path):
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QLabel, QPushButton, QTabWidget, QGridLayout,
|
QLabel, QPushButton, QTabWidget, QGridLayout,
|
||||||
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
|
QScrollArea, QMenu, QStatusBar,
|
||||||
QMessageBox, QApplication, QSystemTrayIcon, QStyle
|
QMessageBox, QApplication, QSystemTrayIcon, QStyle
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Signal, QTimer, QSize
|
from PySide6.QtCore import Qt, Signal, QTimer, QEvent
|
||||||
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
|
from PySide6.QtGui import QIcon, QPixmap, QAction
|
||||||
|
|
||||||
from config import VERSION, THEME, DEFAULT_PORT
|
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
|
||||||
from macro_manager import MacroManager
|
from macro_manager import MacroManager
|
||||||
from web_server import WebServer
|
from web_server import WebServer
|
||||||
|
from .settings_manager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
class MacroButton(QPushButton):
|
class MacroButton(QPushButton):
|
||||||
@@ -35,7 +40,7 @@ class MacroButton(QPushButton):
|
|||||||
edit_requested = Signal(str)
|
edit_requested = Signal(str)
|
||||||
delete_requested = Signal(str)
|
delete_requested = Signal(str)
|
||||||
|
|
||||||
def __init__(self, macro_id: str, macro: dict, parent=None):
|
def __init__(self, macro_id: str, macro: dict, app_dir: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.macro_id = macro_id
|
self.macro_id = macro_id
|
||||||
self.macro = macro
|
self.macro = macro
|
||||||
@@ -70,7 +75,9 @@ class MacroButton(QPushButton):
|
|||||||
image_label.setAlignment(Qt.AlignCenter)
|
image_label.setAlignment(Qt.AlignCenter)
|
||||||
|
|
||||||
if macro.get("image_path"):
|
if macro.get("image_path"):
|
||||||
pixmap = QPixmap(macro["image_path"])
|
# Resolve relative image path against app directory
|
||||||
|
image_path = os.path.join(app_dir, macro["image_path"])
|
||||||
|
pixmap = QPixmap(image_path)
|
||||||
if not pixmap.isNull():
|
if not pixmap.isNull():
|
||||||
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||||
image_label.setPixmap(pixmap)
|
image_label.setPixmap(pixmap)
|
||||||
@@ -116,6 +123,12 @@ class MainWindow(QMainWindow):
|
|||||||
"""Main application window."""
|
"""Main application window."""
|
||||||
|
|
||||||
macros_changed = Signal()
|
macros_changed = Signal()
|
||||||
|
# Signals for thread-safe relay status updates
|
||||||
|
relay_session_received = Signal(str)
|
||||||
|
relay_connected_signal = Signal()
|
||||||
|
relay_disconnected_signal = Signal()
|
||||||
|
# Report macro execution result back to the GUI thread (success flag).
|
||||||
|
macro_execution_finished = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, app_dir: str):
|
def __init__(self, app_dir: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -123,6 +136,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_tab = "All"
|
self.current_tab = "All"
|
||||||
self.sort_by = "name"
|
self.sort_by = "name"
|
||||||
|
|
||||||
|
# Initialize settings manager
|
||||||
|
settings_file = os.path.join(app_dir, SETTINGS_FILE)
|
||||||
|
self.settings_manager = SettingsManager(settings_file)
|
||||||
|
|
||||||
# Initialize macro manager
|
# Initialize macro manager
|
||||||
data_file = os.path.join(app_dir, "macros.json")
|
data_file = os.path.join(app_dir, "macros.json")
|
||||||
images_dir = os.path.join(app_dir, "macro_images")
|
images_dir = os.path.join(app_dir, "macro_images")
|
||||||
@@ -131,9 +148,17 @@ class MainWindow(QMainWindow):
|
|||||||
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
|
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
|
||||||
|
|
||||||
# Initialize web server
|
# Initialize web server
|
||||||
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
|
token = self.settings_manager.get_web_auth_token()
|
||||||
|
host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1"
|
||||||
|
self.web_server = WebServer(
|
||||||
|
self.macro_manager, app_dir, DEFAULT_PORT,
|
||||||
|
auth_token=token, host=host
|
||||||
|
)
|
||||||
self.server_thread = None
|
self.server_thread = None
|
||||||
|
|
||||||
|
# Relay client (initialized later if enabled)
|
||||||
|
self.relay_client = None
|
||||||
|
|
||||||
# Setup UI
|
# Setup UI
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.setup_menu()
|
self.setup_menu()
|
||||||
@@ -142,8 +167,23 @@ class MainWindow(QMainWindow):
|
|||||||
# Start web server
|
# Start web server
|
||||||
self.start_server()
|
self.start_server()
|
||||||
|
|
||||||
|
# Start relay client if enabled
|
||||||
|
if self.settings_manager.get_relay_enabled():
|
||||||
|
self.start_relay_client()
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.macros_changed.connect(self.refresh_macros)
|
self.macros_changed.connect(self.refresh_macros)
|
||||||
|
self.relay_session_received.connect(self._handle_relay_session)
|
||||||
|
self.relay_connected_signal.connect(lambda: self._update_relay_status(True))
|
||||||
|
self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False))
|
||||||
|
self.macro_execution_finished.connect(self._on_macro_execution_finished)
|
||||||
|
|
||||||
|
# Debounce timer for resize-driven grid rebuilds.
|
||||||
|
self._resize_timer = QTimer(self)
|
||||||
|
self._resize_timer.setSingleShot(True)
|
||||||
|
self._resize_timer.setInterval(150)
|
||||||
|
self._resize_timer.timeout.connect(self._apply_resize)
|
||||||
|
self._last_col_count = None
|
||||||
|
|
||||||
# Load initial data
|
# Load initial data
|
||||||
self.refresh_tabs()
|
self.refresh_tabs()
|
||||||
@@ -151,7 +191,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
"""Setup the main UI components."""
|
"""Setup the main UI components."""
|
||||||
self.setWindowTitle(f"MacroPad Server v{VERSION}")
|
self.setWindowTitle("MacroPad Server")
|
||||||
self.setMinimumSize(600, 400)
|
self.setMinimumSize(600, 400)
|
||||||
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
|
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
|
||||||
|
|
||||||
@@ -179,7 +219,7 @@ class MainWindow(QMainWindow):
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background-color: #0096ff;
|
background-color: {THEME['accent_hover']};
|
||||||
}}
|
}}
|
||||||
""")
|
""")
|
||||||
add_btn.clicked.connect(self.add_macro)
|
add_btn.clicked.connect(self.add_macro)
|
||||||
@@ -187,9 +227,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
toolbar_layout.addStretch()
|
toolbar_layout.addStretch()
|
||||||
|
|
||||||
# IP address label
|
# Clickable IP address label (click to copy)
|
||||||
self.ip_label = QLabel()
|
self.ip_label = QPushButton()
|
||||||
self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};")
|
self.ip_label.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.ip_label.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
self.ip_label.clicked.connect(self.copy_url_to_clipboard)
|
||||||
self.update_ip_label()
|
self.update_ip_label()
|
||||||
toolbar_layout.addWidget(self.ip_label)
|
toolbar_layout.addWidget(self.ip_label)
|
||||||
|
|
||||||
@@ -275,11 +324,28 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
|
|
||||||
|
# Windows startup option (only on Windows)
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
self.startup_action = QAction("Start on Windows Startup", self)
|
||||||
|
self.startup_action.setCheckable(True)
|
||||||
|
self.startup_action.setChecked(self.get_startup_enabled())
|
||||||
|
self.startup_action.triggered.connect(self.toggle_startup)
|
||||||
|
file_menu.addAction(self.startup_action)
|
||||||
|
file_menu.addSeparator()
|
||||||
|
|
||||||
quit_action = QAction("Quit", self)
|
quit_action = QAction("Quit", self)
|
||||||
quit_action.setShortcut("Ctrl+Q")
|
quit_action.setShortcut("Ctrl+Q")
|
||||||
quit_action.triggered.connect(self.close)
|
quit_action.triggered.connect(self.close)
|
||||||
file_menu.addAction(quit_action)
|
file_menu.addAction(quit_action)
|
||||||
|
|
||||||
|
# Edit menu
|
||||||
|
edit_menu = menubar.addMenu("Edit")
|
||||||
|
|
||||||
|
settings_action = QAction("Settings...", self)
|
||||||
|
settings_action.setShortcut("Ctrl+,")
|
||||||
|
settings_action.triggered.connect(self.show_settings)
|
||||||
|
edit_menu.addAction(settings_action)
|
||||||
|
|
||||||
# View menu
|
# View menu
|
||||||
view_menu = menubar.addMenu("View")
|
view_menu = menubar.addMenu("View")
|
||||||
|
|
||||||
@@ -318,7 +384,7 @@ class MainWindow(QMainWindow):
|
|||||||
# Tray menu
|
# Tray menu
|
||||||
tray_menu = QMenu()
|
tray_menu = QMenu()
|
||||||
show_action = tray_menu.addAction("Show")
|
show_action = tray_menu.addAction("Show")
|
||||||
show_action.triggered.connect(self.show)
|
show_action.triggered.connect(self.restore_window)
|
||||||
quit_action = tray_menu.addAction("Quit")
|
quit_action = tray_menu.addAction("Quit")
|
||||||
quit_action.triggered.connect(self.close)
|
quit_action.triggered.connect(self.close)
|
||||||
|
|
||||||
@@ -329,7 +395,14 @@ class MainWindow(QMainWindow):
|
|||||||
def on_tray_activated(self, reason):
|
def on_tray_activated(self, reason):
|
||||||
"""Handle tray icon activation."""
|
"""Handle tray icon activation."""
|
||||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||||
|
self.restore_window()
|
||||||
|
|
||||||
|
def restore_window(self):
|
||||||
|
"""Restore and bring window to front."""
|
||||||
|
# Clear minimized state and show
|
||||||
|
self.setWindowState(Qt.WindowNoState)
|
||||||
self.show()
|
self.show()
|
||||||
|
self.raise_()
|
||||||
self.activateWindow()
|
self.activateWindow()
|
||||||
|
|
||||||
def start_server(self):
|
def start_server(self):
|
||||||
@@ -379,19 +452,90 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def update_ip_label(self):
|
def update_ip_label(self):
|
||||||
"""Update the IP address label."""
|
"""Update the IP address label."""
|
||||||
try:
|
# Check if relay is connected and has a session ID
|
||||||
import netifaces
|
relay_connected = self.relay_client and self.relay_client.is_connected()
|
||||||
for iface in netifaces.interfaces():
|
session_id = self.settings_manager.get_relay_session_id()
|
||||||
addrs = netifaces.ifaddresses(iface)
|
|
||||||
if netifaces.AF_INET in addrs:
|
if relay_connected and session_id:
|
||||||
for addr in addrs[netifaces.AF_INET]:
|
relay_url = self.settings_manager.get_relay_url()
|
||||||
ip = addr.get('addr', '')
|
# Convert wss:// to https:// for display
|
||||||
if ip and not ip.startswith('127.'):
|
base_url = relay_url.replace('wss://', 'https://').replace('ws://', 'http://')
|
||||||
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
|
base_url = base_url.replace('/desktop', '').rstrip('/')
|
||||||
|
full_url = f"{base_url}/{session_id}"
|
||||||
|
self.ip_label.setText(full_url)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Fall back to local IP. Append the auth token as a query param so the
|
||||||
|
# QR/copied URL lets a LAN device authenticate against the API.
|
||||||
|
token = self.settings_manager.get_web_auth_token()
|
||||||
|
token_qs = f"?token={token}" if token else ""
|
||||||
|
ip = self._detect_lan_ip()
|
||||||
|
if ip:
|
||||||
|
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
|
||||||
|
return
|
||||||
|
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_lan_ip():
|
||||||
|
"""Best-effort primary LAN IPv4, with no third-party dependency.
|
||||||
|
|
||||||
|
Uses a UDP socket to discover which local interface would be used to
|
||||||
|
reach the internet (no packets are actually sent), then falls back to
|
||||||
|
resolving the hostname. Returns None if only loopback is available.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
if ip and not ip.startswith("127."):
|
||||||
|
return ip
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
|
try:
|
||||||
|
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||||||
|
if not ip.startswith("127."):
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def copy_url_to_clipboard(self):
|
||||||
|
"""Copy the web interface URL to clipboard."""
|
||||||
|
url = self.ip_label.text()
|
||||||
|
if url == "Copied!":
|
||||||
|
return # Already showing copied feedback
|
||||||
|
clipboard = QApplication.clipboard()
|
||||||
|
clipboard.setText(url)
|
||||||
|
|
||||||
|
# Show "Copied!" feedback with dimmed style
|
||||||
|
self.ip_label.setText("Copied!")
|
||||||
|
self.ip_label.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Restore original URL after delay
|
||||||
|
QTimer.singleShot(1500, self._restore_url_label)
|
||||||
|
|
||||||
|
def _restore_url_label(self):
|
||||||
|
"""Restore URL label after copy feedback."""
|
||||||
|
self.ip_label.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
self.update_ip_label()
|
||||||
|
|
||||||
def refresh_tabs(self):
|
def refresh_tabs(self):
|
||||||
"""Refresh the tab widget."""
|
"""Refresh the tab widget."""
|
||||||
@@ -441,9 +585,10 @@ class MainWindow(QMainWindow):
|
|||||||
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
|
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
|
||||||
|
|
||||||
# Add macro buttons
|
# Add macro buttons
|
||||||
cols = max(1, (self.width() - 40) // 130)
|
cols = self._compute_columns()
|
||||||
|
self._last_col_count = cols
|
||||||
for i, (macro_id, macro) in enumerate(filtered):
|
for i, (macro_id, macro) in enumerate(filtered):
|
||||||
btn = MacroButton(macro_id, macro)
|
btn = MacroButton(macro_id, macro, self.app_dir)
|
||||||
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
|
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
|
||||||
btn.edit_requested.connect(self.edit_macro)
|
btn.edit_requested.connect(self.edit_macro)
|
||||||
btn.delete_requested.connect(self.delete_macro)
|
btn.delete_requested.connect(self.delete_macro)
|
||||||
@@ -454,8 +599,25 @@ class MainWindow(QMainWindow):
|
|||||||
self.refresh_macros()
|
self.refresh_macros()
|
||||||
|
|
||||||
def execute_macro(self, macro_id: str):
|
def execute_macro(self, macro_id: str):
|
||||||
"""Execute a macro."""
|
"""Execute a macro off the GUI thread so the UI doesn't freeze.
|
||||||
|
|
||||||
|
pyautogui presses and wait steps are blocking; running them on the Qt
|
||||||
|
main thread would freeze the window. Run in a worker thread and report
|
||||||
|
the result back via a signal (thread-safe).
|
||||||
|
"""
|
||||||
|
self.status_bar.showMessage("Executing macro...", 2000)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
success = self.macro_manager.execute_macro(macro_id)
|
success = self.macro_manager.execute_macro(macro_id)
|
||||||
|
except Exception:
|
||||||
|
success = False
|
||||||
|
self.macro_execution_finished.emit(success)
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_macro_execution_finished(self, success: bool):
|
||||||
|
"""Report macro execution result on the GUI thread."""
|
||||||
if success:
|
if success:
|
||||||
self.status_bar.showMessage("Macro executed", 2000)
|
self.status_bar.showMessage("Macro executed", 2000)
|
||||||
else:
|
else:
|
||||||
@@ -479,9 +641,16 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def delete_macro(self, macro_id: str):
|
def delete_macro(self, macro_id: str):
|
||||||
"""Delete a macro with confirmation."""
|
"""Delete a macro with confirmation."""
|
||||||
|
macro = self.macro_manager.get_macro(macro_id)
|
||||||
|
macro_name = macro.get("name", "") if macro else ""
|
||||||
|
prompt = (
|
||||||
|
f"Are you sure you want to delete \"{macro_name}\"?"
|
||||||
|
if macro_name else
|
||||||
|
"Are you sure you want to delete this macro?"
|
||||||
|
)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Delete Macro",
|
self, "Delete Macro",
|
||||||
"Are you sure you want to delete this macro?",
|
prompt,
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@@ -540,16 +709,170 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def show_about(self):
|
def show_about(self):
|
||||||
"""Show about dialog."""
|
"""Show about dialog."""
|
||||||
QMessageBox.about(
|
about_box = QMessageBox(self)
|
||||||
self, "About MacroPad Server",
|
about_box.setWindowTitle("About MacroPad Server")
|
||||||
f"MacroPad Server v{VERSION}\n\n"
|
about_box.setTextFormat(Qt.RichText)
|
||||||
"A cross-platform macro management application\n"
|
about_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
"with desktop and web interfaces.\n\n"
|
about_box.setText(
|
||||||
"PWA-enabled for mobile devices."
|
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 show_settings(self):
|
||||||
|
"""Show settings dialog."""
|
||||||
|
from .settings_dialog import SettingsDialog
|
||||||
|
dialog = SettingsDialog(self.settings_manager, parent=self)
|
||||||
|
dialog.relay_settings_changed.connect(self.on_relay_settings_changed)
|
||||||
|
dialog.exec_()
|
||||||
|
|
||||||
|
def on_relay_settings_changed(self):
|
||||||
|
"""Handle relay settings changes."""
|
||||||
|
# Stop existing relay client if running
|
||||||
|
self.stop_relay_client()
|
||||||
|
|
||||||
|
# Start new relay client if enabled
|
||||||
|
if self.settings_manager.get_relay_enabled():
|
||||||
|
self.start_relay_client()
|
||||||
|
|
||||||
|
# Update IP label to show relay URL if connected
|
||||||
|
self.update_ip_label()
|
||||||
|
|
||||||
|
def start_relay_client(self):
|
||||||
|
"""Start the relay client in a background thread."""
|
||||||
|
try:
|
||||||
|
from relay_client import RelayClient
|
||||||
|
except ImportError:
|
||||||
|
self.status_bar.showMessage("Relay client not available", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
url = self.settings_manager.get_relay_url()
|
||||||
|
password = self.settings_manager.get_relay_password()
|
||||||
|
session_id = self.settings_manager.get_relay_session_id()
|
||||||
|
|
||||||
|
if not url or not password:
|
||||||
|
self.status_bar.showMessage("Relay not configured", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.relay_client = RelayClient(
|
||||||
|
relay_url=url,
|
||||||
|
password=password,
|
||||||
|
session_id=session_id,
|
||||||
|
local_port=DEFAULT_PORT,
|
||||||
|
on_connected=self.on_relay_connected,
|
||||||
|
on_disconnected=self.on_relay_disconnected,
|
||||||
|
on_session_id=self.on_relay_session_id,
|
||||||
|
local_auth_token=self.settings_manager.get_web_auth_token()
|
||||||
|
)
|
||||||
|
self.relay_client.start()
|
||||||
|
self.status_bar.showMessage("Connecting to relay server...")
|
||||||
|
|
||||||
|
def stop_relay_client(self):
|
||||||
|
"""Stop the relay client."""
|
||||||
|
if self.relay_client:
|
||||||
|
self.relay_client.stop()
|
||||||
|
self.relay_client = None
|
||||||
|
self.status_bar.showMessage("Relay disconnected", 2000)
|
||||||
|
|
||||||
|
def on_relay_connected(self):
|
||||||
|
"""Handle relay connection established (called from background thread)."""
|
||||||
|
self.relay_connected_signal.emit()
|
||||||
|
|
||||||
|
def on_relay_disconnected(self):
|
||||||
|
"""Handle relay disconnection (called from background thread)."""
|
||||||
|
self.relay_disconnected_signal.emit()
|
||||||
|
|
||||||
|
def on_relay_session_id(self, session_id: str):
|
||||||
|
"""Handle receiving session ID from relay (called from background thread)."""
|
||||||
|
self.relay_session_received.emit(session_id)
|
||||||
|
|
||||||
|
def _handle_relay_session(self, session_id: str):
|
||||||
|
"""Handle relay session on main thread."""
|
||||||
|
if not self.settings_manager.set_relay_session_id(session_id):
|
||||||
|
self.status_bar.showMessage("Warning: failed to save relay session", 3000)
|
||||||
|
self.update_ip_label()
|
||||||
|
|
||||||
|
def _update_relay_status(self, connected: bool):
|
||||||
|
"""Update UI for relay status (called on main thread)."""
|
||||||
|
if connected:
|
||||||
|
self.status_bar.showMessage("Connected to relay server")
|
||||||
|
else:
|
||||||
|
self.status_bar.showMessage("Relay disconnected - reconnecting...")
|
||||||
|
self.update_ip_label()
|
||||||
|
|
||||||
|
# Windows startup management
|
||||||
|
def get_startup_enabled(self) -> bool:
|
||||||
|
"""Check if app is set to start on Windows startup."""
|
||||||
|
if sys.platform != 'win32':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
key = winreg.OpenKey(
|
||||||
|
winreg.HKEY_CURRENT_USER,
|
||||||
|
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||||
|
0, winreg.KEY_READ
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
winreg.QueryValueEx(key, "MacroPad Server")
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_startup_enabled(self, enabled: bool):
|
||||||
|
"""Enable or disable starting on Windows startup."""
|
||||||
|
if sys.platform != 'win32':
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = winreg.OpenKey(
|
||||||
|
winreg.HKEY_CURRENT_USER,
|
||||||
|
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||||
|
0, winreg.KEY_SET_VALUE
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if enabled:
|
||||||
|
# Get the executable path
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as compiled executable
|
||||||
|
exe_path = sys.executable
|
||||||
|
else:
|
||||||
|
# Running as script - use pythonw to avoid console
|
||||||
|
exe_path = f'"{sys.executable}" "{os.path.abspath(sys.argv[0])}"'
|
||||||
|
|
||||||
|
winreg.SetValueEx(key, "MacroPad Server", 0, winreg.REG_SZ, exe_path)
|
||||||
|
self.status_bar.showMessage("Added to Windows startup", 3000)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
winreg.DeleteValue(key, "MacroPad Server")
|
||||||
|
self.status_bar.showMessage("Removed from Windows startup", 3000)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to update startup settings: {e}")
|
||||||
|
|
||||||
|
def toggle_startup(self):
|
||||||
|
"""Toggle the startup setting."""
|
||||||
|
current = self.get_startup_enabled()
|
||||||
|
self.set_startup_enabled(not current)
|
||||||
|
# Update menu checkmark
|
||||||
|
if hasattr(self, 'startup_action'):
|
||||||
|
self.startup_action.setChecked(not current)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
"""Handle window close."""
|
"""Handle window close."""
|
||||||
|
# Stop the relay client
|
||||||
|
self.stop_relay_client()
|
||||||
|
|
||||||
# Stop the web server
|
# Stop the web server
|
||||||
self.stop_server()
|
self.stop_server()
|
||||||
|
|
||||||
@@ -558,7 +881,35 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def _compute_columns(self) -> int:
|
||||||
"""Handle window resize."""
|
"""Compute how many macro columns fit at the current width."""
|
||||||
super().resizeEvent(event)
|
return max(1, (self.width() - 40) // 130)
|
||||||
|
|
||||||
|
def _apply_resize(self):
|
||||||
|
"""Rebuild the grid only if the column count actually changed."""
|
||||||
|
if self._compute_columns() != self._last_col_count:
|
||||||
self.refresh_macros()
|
self.refresh_macros()
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
"""Handle window resize (debounced to coalesce rapid resize events)."""
|
||||||
|
super().resizeEvent(event)
|
||||||
|
# Coalesce a burst of resize events; only rebuild after things settle
|
||||||
|
# and only when the computed column count changed.
|
||||||
|
self._resize_timer.start()
|
||||||
|
|
||||||
|
def changeEvent(self, event):
|
||||||
|
"""Handle window state changes - minimize to tray."""
|
||||||
|
if event.type() == QEvent.Type.WindowStateChange:
|
||||||
|
if (self.windowState() & Qt.WindowMinimized
|
||||||
|
and self.settings_manager.get_minimize_to_tray()):
|
||||||
|
# Hide instead of minimize (goes to tray)
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
self.tray_icon.showMessage(
|
||||||
|
"MacroPad Server",
|
||||||
|
"Running in system tray. Double-click to restore.",
|
||||||
|
QSystemTrayIcon.Information,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
return
|
||||||
|
super().changeEvent(event)
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# Settings Dialog for MacroPad Server
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget,
|
||||||
|
QWidget, QLabel, QLineEdit, QCheckBox, QPushButton,
|
||||||
|
QGroupBox, QFormLayout, QMessageBox
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
|
||||||
|
from config import THEME
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(QDialog):
|
||||||
|
"""Settings dialog for application preferences."""
|
||||||
|
|
||||||
|
relay_settings_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, settings_manager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.settings_manager = settings_manager
|
||||||
|
self.setup_ui()
|
||||||
|
self.load_settings()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
"""Setup the dialog UI."""
|
||||||
|
self.setWindowTitle("Settings")
|
||||||
|
self.setMinimumSize(500, 400)
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QDialog {{
|
||||||
|
background-color: {THEME['bg_color']};
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
}}
|
||||||
|
QTabWidget::pane {{
|
||||||
|
border: 1px solid {THEME['highlight_color']};
|
||||||
|
background: {THEME['bg_color']};
|
||||||
|
}}
|
||||||
|
QTabBar::tab {{
|
||||||
|
background: {THEME['tab_bg']};
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}}
|
||||||
|
QTabBar::tab:selected {{
|
||||||
|
background: {THEME['tab_selected']};
|
||||||
|
}}
|
||||||
|
QGroupBox {{
|
||||||
|
border: 1px solid {THEME['highlight_color']};
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
}}
|
||||||
|
QGroupBox::title {{
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 10px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}}
|
||||||
|
QLabel {{
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
}}
|
||||||
|
QLineEdit {{
|
||||||
|
background-color: {THEME['highlight_color']};
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
border: 1px solid {THEME['button_bg']};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
}}
|
||||||
|
QLineEdit:focus {{
|
||||||
|
border-color: {THEME['accent_color']};
|
||||||
|
}}
|
||||||
|
QLineEdit:read-only {{
|
||||||
|
background-color: {THEME['bg_color']};
|
||||||
|
}}
|
||||||
|
QCheckBox {{
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator {{
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}}
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {THEME['button_bg']};
|
||||||
|
color: {THEME['fg_color']};
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: {THEME['highlight_color']};
|
||||||
|
}}
|
||||||
|
QPushButton:pressed {{
|
||||||
|
background-color: {THEME['accent_color']};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Tab widget
|
||||||
|
self.tab_widget = QTabWidget()
|
||||||
|
|
||||||
|
# General tab
|
||||||
|
general_tab = self.create_general_tab()
|
||||||
|
self.tab_widget.addTab(general_tab, "General")
|
||||||
|
|
||||||
|
# Relay Server tab
|
||||||
|
relay_tab = self.create_relay_tab()
|
||||||
|
self.tab_widget.addTab(relay_tab, "Relay Server")
|
||||||
|
|
||||||
|
layout.addWidget(self.tab_widget)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
button_layout.addStretch()
|
||||||
|
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
|
||||||
|
save_btn = QPushButton("Save")
|
||||||
|
save_btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {THEME['accent_color']};
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: {THEME['accent_hover']};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
save_btn.clicked.connect(self.save_settings)
|
||||||
|
button_layout.addWidget(save_btn)
|
||||||
|
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
def create_general_tab(self) -> QWidget:
|
||||||
|
"""Create the general settings tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# Behavior group
|
||||||
|
behavior_group = QGroupBox("Behavior")
|
||||||
|
behavior_layout = QVBoxLayout(behavior_group)
|
||||||
|
|
||||||
|
self.minimize_to_tray_cb = QCheckBox("Minimize to system tray")
|
||||||
|
self.minimize_to_tray_cb.setToolTip(
|
||||||
|
"When enabled, minimizing the window will hide it to the system tray\n"
|
||||||
|
"instead of the taskbar. Double-click the tray icon to restore."
|
||||||
|
)
|
||||||
|
behavior_layout.addWidget(self.minimize_to_tray_cb)
|
||||||
|
|
||||||
|
layout.addWidget(behavior_group)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def create_relay_tab(self) -> QWidget:
|
||||||
|
"""Create the relay server settings tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# Info label
|
||||||
|
info_label = QLabel(
|
||||||
|
"The relay server allows you to access MacroPad from anywhere\n"
|
||||||
|
"via a secure HTTPS connection. Your macros will be accessible\n"
|
||||||
|
"through a unique URL that you can share with your devices."
|
||||||
|
)
|
||||||
|
info_label.setWordWrap(True)
|
||||||
|
info_label.setStyleSheet(f"color: #aaa; margin-bottom: 10px;")
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
# Connection group
|
||||||
|
connection_group = QGroupBox("Connection")
|
||||||
|
connection_layout = QFormLayout(connection_group)
|
||||||
|
|
||||||
|
self.relay_enabled_cb = QCheckBox("Enable relay server")
|
||||||
|
self.relay_enabled_cb.stateChanged.connect(self.on_relay_enabled_changed)
|
||||||
|
connection_layout.addRow("", self.relay_enabled_cb)
|
||||||
|
|
||||||
|
self.relay_url_edit = QLineEdit()
|
||||||
|
self.relay_url_edit.setPlaceholderText("wss://relay.example.com")
|
||||||
|
connection_layout.addRow("Server URL:", self.relay_url_edit)
|
||||||
|
|
||||||
|
layout.addWidget(connection_group)
|
||||||
|
|
||||||
|
# Authentication group
|
||||||
|
auth_group = QGroupBox("Authentication")
|
||||||
|
auth_layout = QFormLayout(auth_group)
|
||||||
|
|
||||||
|
self.relay_password_edit = QLineEdit()
|
||||||
|
self.relay_password_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.relay_password_edit.setPlaceholderText("Required - minimum 8 characters")
|
||||||
|
auth_layout.addRow("Password:", self.relay_password_edit)
|
||||||
|
|
||||||
|
# Show password toggle
|
||||||
|
show_password_cb = QCheckBox("Show password")
|
||||||
|
show_password_cb.stateChanged.connect(
|
||||||
|
lambda state: self.relay_password_edit.setEchoMode(
|
||||||
|
QLineEdit.Normal if state else QLineEdit.Password
|
||||||
|
)
|
||||||
|
)
|
||||||
|
auth_layout.addRow("", show_password_cb)
|
||||||
|
|
||||||
|
layout.addWidget(auth_group)
|
||||||
|
|
||||||
|
# Status group
|
||||||
|
status_group = QGroupBox("Status")
|
||||||
|
status_layout = QFormLayout(status_group)
|
||||||
|
|
||||||
|
self.relay_session_id_edit = QLineEdit()
|
||||||
|
self.relay_session_id_edit.setReadOnly(True)
|
||||||
|
self.relay_session_id_edit.setPlaceholderText("Not connected")
|
||||||
|
status_layout.addRow("Session ID:", self.relay_session_id_edit)
|
||||||
|
|
||||||
|
self.relay_full_url_edit = QLineEdit()
|
||||||
|
self.relay_full_url_edit.setReadOnly(True)
|
||||||
|
self.relay_full_url_edit.setPlaceholderText("Connect to see your URL")
|
||||||
|
status_layout.addRow("Your URL:", self.relay_full_url_edit)
|
||||||
|
|
||||||
|
# Regenerate button
|
||||||
|
regen_btn = QPushButton("Generate New URL")
|
||||||
|
regen_btn.setToolTip("Generate a new unique URL (invalidates the old one)")
|
||||||
|
regen_btn.clicked.connect(self.regenerate_session_id)
|
||||||
|
status_layout.addRow("", regen_btn)
|
||||||
|
|
||||||
|
layout.addWidget(status_group)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def load_settings(self):
|
||||||
|
"""Load current settings into the UI."""
|
||||||
|
# General
|
||||||
|
self.minimize_to_tray_cb.setChecked(
|
||||||
|
self.settings_manager.get_minimize_to_tray()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relay
|
||||||
|
self.relay_enabled_cb.setChecked(
|
||||||
|
self.settings_manager.get_relay_enabled()
|
||||||
|
)
|
||||||
|
self.relay_url_edit.setText(
|
||||||
|
self.settings_manager.get_relay_url()
|
||||||
|
)
|
||||||
|
self.relay_password_edit.setText(
|
||||||
|
self.settings_manager.get_relay_password()
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = self.settings_manager.get_relay_session_id()
|
||||||
|
if session_id:
|
||||||
|
self.relay_session_id_edit.setText(session_id)
|
||||||
|
base_url = self.relay_url_edit.text().replace('wss://', 'https://').replace('ws://', 'http://')
|
||||||
|
base_url = base_url.replace('/desktop', '')
|
||||||
|
self.relay_full_url_edit.setText(f"{base_url}/{session_id}")
|
||||||
|
|
||||||
|
self.on_relay_enabled_changed()
|
||||||
|
|
||||||
|
def on_relay_enabled_changed(self):
|
||||||
|
"""Handle relay enabled checkbox change."""
|
||||||
|
enabled = self.relay_enabled_cb.isChecked()
|
||||||
|
self.relay_url_edit.setEnabled(enabled)
|
||||||
|
self.relay_password_edit.setEnabled(enabled)
|
||||||
|
|
||||||
|
def validate_settings(self) -> bool:
|
||||||
|
"""Validate settings before saving."""
|
||||||
|
if self.relay_enabled_cb.isChecked():
|
||||||
|
url = self.relay_url_edit.text().strip()
|
||||||
|
password = self.relay_password_edit.text()
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Validation Error",
|
||||||
|
"Relay server URL is required when relay is enabled."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not url.startswith(('ws://', 'wss://')):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Validation Error",
|
||||||
|
"Relay server URL must start with ws:// or wss://"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Validation Error",
|
||||||
|
"Password must be at least 8 characters."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_settings(self):
|
||||||
|
"""Save settings and close dialog."""
|
||||||
|
if not self.validate_settings():
|
||||||
|
return
|
||||||
|
|
||||||
|
# General
|
||||||
|
self.settings_manager.set(
|
||||||
|
'minimize_to_tray',
|
||||||
|
self.minimize_to_tray_cb.isChecked()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relay - check if settings changed
|
||||||
|
old_enabled = self.settings_manager.get_relay_enabled()
|
||||||
|
old_url = self.settings_manager.get_relay_url()
|
||||||
|
old_password = self.settings_manager.get_relay_password()
|
||||||
|
|
||||||
|
new_enabled = self.relay_enabled_cb.isChecked()
|
||||||
|
new_url = self.relay_url_edit.text().strip()
|
||||||
|
new_password = self.relay_password_edit.text()
|
||||||
|
|
||||||
|
relay_changed = (
|
||||||
|
old_enabled != new_enabled or
|
||||||
|
old_url != new_url or
|
||||||
|
old_password != new_password
|
||||||
|
)
|
||||||
|
|
||||||
|
self.settings_manager.set('relay.enabled', new_enabled)
|
||||||
|
self.settings_manager.set('relay.server_url', new_url)
|
||||||
|
|
||||||
|
# Store the password via the keyring-aware setter (also persists the
|
||||||
|
# rest of the settings). Avoid writing the plaintext password to JSON.
|
||||||
|
ok = self.settings_manager.set_relay_password(new_password)
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Save Warning",
|
||||||
|
"Settings could not be fully saved to disk."
|
||||||
|
)
|
||||||
|
|
||||||
|
if relay_changed:
|
||||||
|
self.relay_settings_changed.emit()
|
||||||
|
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def regenerate_session_id(self):
|
||||||
|
"""Clear session ID to force regeneration on next connect."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Regenerate URL",
|
||||||
|
"This will generate a new URL. The old URL will stop working.\n\n"
|
||||||
|
"Are you sure you want to continue?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.settings_manager.set('relay.session_id', None)
|
||||||
|
self.relay_session_id_edit.setText("")
|
||||||
|
self.relay_full_url_edit.setText("")
|
||||||
|
self.settings_manager.save()
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "URL Regenerated",
|
||||||
|
"A new URL will be generated when you reconnect to the relay server."
|
||||||
|
)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Settings Manager for MacroPad Server
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# Optional OS keyring for storing the relay password at rest. If the library or
|
||||||
|
# a working backend is unavailable we fall back to the JSON file (now chmod 0600).
|
||||||
|
try:
|
||||||
|
import keyring as _keyring
|
||||||
|
except Exception: # ImportError or any backend import-time failure
|
||||||
|
_keyring = None
|
||||||
|
|
||||||
|
KEYRING_SERVICE = "MacroPad"
|
||||||
|
KEYRING_USER = "relay"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"relay": {
|
||||||
|
"enabled": False,
|
||||||
|
"server_url": "wss://relay.macropad.example.com",
|
||||||
|
"session_id": None,
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"auth_token": "",
|
||||||
|
"allow_lan": True
|
||||||
|
},
|
||||||
|
"minimize_to_tray": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsManager:
|
||||||
|
"""Manages application settings with JSON persistence."""
|
||||||
|
|
||||||
|
def __init__(self, settings_file: str):
|
||||||
|
self.settings_file = settings_file
|
||||||
|
self.settings = {}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
"""Load settings from file."""
|
||||||
|
if os.path.exists(self.settings_file):
|
||||||
|
try:
|
||||||
|
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||||
|
self.settings = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
self.settings = {}
|
||||||
|
|
||||||
|
# Merge with defaults to ensure all keys exist
|
||||||
|
self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings)
|
||||||
|
|
||||||
|
# Ensure a web auth token exists (this is the security boundary for API access)
|
||||||
|
if not self.get('web.auth_token'):
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
self.set('web.auth_token', token)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Save settings to file."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
||||||
|
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.settings, f, indent=2)
|
||||||
|
# Best-effort restrict permissions (protects stored relay password + token)
|
||||||
|
try:
|
||||||
|
os.chmod(self.settings_file, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except IOError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _merge_defaults(self, defaults: dict, current: dict) -> dict:
|
||||||
|
"""Merge current settings with defaults, keeping current values."""
|
||||||
|
result = defaults.copy()
|
||||||
|
for key, value in current.items():
|
||||||
|
if key in result:
|
||||||
|
if isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = self._merge_defaults(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a setting value by key (supports dot notation)."""
|
||||||
|
keys = key.split('.')
|
||||||
|
value = self.settings
|
||||||
|
try:
|
||||||
|
for k in keys:
|
||||||
|
value = value[k]
|
||||||
|
return value
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any):
|
||||||
|
"""Set a setting value by key (supports dot notation)."""
|
||||||
|
keys = key.split('.')
|
||||||
|
target = self.settings
|
||||||
|
for k in keys[:-1]:
|
||||||
|
if k not in target:
|
||||||
|
target[k] = {}
|
||||||
|
target = target[k]
|
||||||
|
target[keys[-1]] = value
|
||||||
|
|
||||||
|
def get_relay_enabled(self) -> bool:
|
||||||
|
"""Check if relay server is enabled."""
|
||||||
|
return self.get('relay.enabled', False)
|
||||||
|
|
||||||
|
def get_relay_url(self) -> str:
|
||||||
|
"""Get the relay server URL."""
|
||||||
|
return self.get('relay.server_url', '')
|
||||||
|
|
||||||
|
def get_relay_session_id(self) -> Optional[str]:
|
||||||
|
"""Get the stored relay session ID."""
|
||||||
|
return self.get('relay.session_id')
|
||||||
|
|
||||||
|
def get_relay_password(self) -> str:
|
||||||
|
"""Get the relay password, preferring the OS keyring when available."""
|
||||||
|
if _keyring is not None:
|
||||||
|
try:
|
||||||
|
value = _keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
# Keyring backend missing/broken: fall back to JSON.
|
||||||
|
pass
|
||||||
|
return self.get('relay.password', '')
|
||||||
|
|
||||||
|
def set_relay_password(self, password: str) -> bool:
|
||||||
|
"""Store the relay password, preferring the OS keyring when available.
|
||||||
|
|
||||||
|
When keyring works, the plaintext password is kept out of the JSON file.
|
||||||
|
Never crashes if the keyring backend is missing; falls back to JSON.
|
||||||
|
Returns True on successful persistence.
|
||||||
|
"""
|
||||||
|
password = password or ""
|
||||||
|
stored_in_keyring = False
|
||||||
|
if _keyring is not None:
|
||||||
|
try:
|
||||||
|
_keyring.set_password(KEYRING_SERVICE, KEYRING_USER, password)
|
||||||
|
stored_in_keyring = True
|
||||||
|
except Exception:
|
||||||
|
stored_in_keyring = False
|
||||||
|
|
||||||
|
if stored_in_keyring:
|
||||||
|
# Don't persist plaintext in the JSON when the keyring holds it.
|
||||||
|
self.set('relay.password', "")
|
||||||
|
else:
|
||||||
|
self.set('relay.password', password)
|
||||||
|
return self.save()
|
||||||
|
|
||||||
|
def set_relay_session_id(self, session_id: str) -> bool:
|
||||||
|
"""Store the relay session ID. Returns True on successful save."""
|
||||||
|
self.set('relay.session_id', session_id)
|
||||||
|
return self.save()
|
||||||
|
|
||||||
|
def get_web_auth_token(self) -> str:
|
||||||
|
"""Get the web API auth token."""
|
||||||
|
return self.get('web.auth_token', '')
|
||||||
|
|
||||||
|
def get_web_allow_lan(self) -> bool:
|
||||||
|
"""Check if LAN (0.0.0.0) binding is allowed."""
|
||||||
|
return self.get('web.allow_lan', True)
|
||||||
|
|
||||||
|
def get_minimize_to_tray(self) -> bool:
|
||||||
|
"""Check if minimize to tray is enabled."""
|
||||||
|
return self.get('minimize_to_tray', True)
|
||||||
+160
-37
@@ -3,6 +3,9 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import pyautogui
|
import pyautogui
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -11,18 +14,42 @@ from PIL import Image
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# --- Payload validation for execution ---------------------------------------
|
||||||
|
# Allowlist of key names pyautogui knows how to press. Anything not in this set
|
||||||
|
# is rejected before being handed to pyautogui so arbitrary strings can't be
|
||||||
|
# passed through. Populated from pyautogui when available.
|
||||||
|
VALID_KEYS = frozenset(getattr(pyautogui, "KEYBOARD_KEYS", []) or [])
|
||||||
|
|
||||||
|
# Upper bound (milliseconds) for a single wait step so a macro can't hang the
|
||||||
|
# executor forever.
|
||||||
|
MAX_WAIT_MS = 60000
|
||||||
|
|
||||||
|
# Only persist last_used to disk at most this often (seconds) to avoid rewriting
|
||||||
|
# the whole JSON file on every single execution.
|
||||||
|
LAST_USED_SAVE_INTERVAL = 5.0
|
||||||
|
|
||||||
|
|
||||||
class MacroManager:
|
class MacroManager:
|
||||||
"""Manages macro storage, migration, and execution with command sequences."""
|
"""Manages macro storage, migration, and execution with command sequences.
|
||||||
|
|
||||||
|
Thread-safe: all reads/writes of ``self.macros`` and all disk writes are
|
||||||
|
guarded by ``self._lock``. Public methods acquire the lock and delegate to
|
||||||
|
``*_locked`` internal helpers so we never re-acquire a non-reentrant lock.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, data_file: str, images_dir: str, app_dir: str):
|
def __init__(self, data_file: str, images_dir: str, app_dir: str):
|
||||||
self.data_file = data_file
|
self.data_file = data_file
|
||||||
self.images_dir = images_dir
|
self.images_dir = images_dir
|
||||||
self.app_dir = app_dir
|
self.app_dir = app_dir
|
||||||
self.macros = {}
|
self.macros = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# Timestamp of the last last_used-triggered save (for debouncing).
|
||||||
|
self._last_used_save_time = 0.0
|
||||||
self.load_macros()
|
self.load_macros()
|
||||||
|
|
||||||
def load_macros(self):
|
def load_macros(self):
|
||||||
"""Load macros from JSON file and migrate if needed."""
|
"""Load macros from JSON file and migrate if needed."""
|
||||||
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
if os.path.exists(self.data_file):
|
if os.path.exists(self.data_file):
|
||||||
with open(self.data_file, "r") as file:
|
with open(self.data_file, "r") as file:
|
||||||
@@ -36,7 +63,7 @@ class MacroManager:
|
|||||||
migrated = True
|
migrated = True
|
||||||
|
|
||||||
if migrated:
|
if migrated:
|
||||||
self.save_macros()
|
self._save_macros_locked()
|
||||||
print("Migrated macros to new command sequence format")
|
print("Migrated macros to new command sequence format")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -91,28 +118,56 @@ class MacroManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def save_macros(self):
|
def save_macros(self):
|
||||||
"""Save macros to JSON file."""
|
"""Save macros to JSON file (thread-safe, atomic)."""
|
||||||
|
with self._lock:
|
||||||
|
self._save_macros_locked()
|
||||||
|
|
||||||
|
def _save_macros_locked(self):
|
||||||
|
"""Atomically write macros to disk. Caller must hold ``self._lock``.
|
||||||
|
|
||||||
|
Writes to a temp file in the same directory then os.replace() over the
|
||||||
|
target so a crash mid-write can't leave a truncated/corrupt JSON file.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(self.data_file, "w") as file:
|
target_dir = os.path.dirname(self.data_file) or "."
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
prefix=".macros-", suffix=".tmp", dir=target_dir
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as file:
|
||||||
json.dump(self.macros, file, indent=4)
|
json.dump(self.macros, file, indent=4)
|
||||||
|
file.flush()
|
||||||
|
os.fsync(file.fileno())
|
||||||
|
os.replace(tmp_path, self.data_file)
|
||||||
|
except Exception:
|
||||||
|
# Clean up the temp file on any failure
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving macros: {e}")
|
print(f"Error saving macros: {e}")
|
||||||
|
|
||||||
def get_sorted_macros(self, sort_by: str = "name"):
|
def get_sorted_macros(self, sort_by: str = "name"):
|
||||||
"""Get macros sorted by specified criteria."""
|
"""Get macros sorted by specified criteria."""
|
||||||
|
with self._lock:
|
||||||
macro_list = list(self.macros.items())
|
macro_list = list(self.macros.items())
|
||||||
|
|
||||||
if sort_by == "name":
|
if sort_by == "name":
|
||||||
macro_list.sort(key=lambda x: x[1]["name"].lower())
|
macro_list.sort(key=lambda x: x[1].get("name", "").lower())
|
||||||
elif sort_by == "type":
|
elif sort_by == "type":
|
||||||
# Sort by first command type in sequence
|
# Sort by first command type in sequence
|
||||||
def get_first_type(macro):
|
def get_first_type(macro):
|
||||||
cmds = macro.get("commands", [])
|
cmds = macro.get("commands", [])
|
||||||
return cmds[0].get("type", "") if cmds else ""
|
return cmds[0].get("type", "") if cmds else ""
|
||||||
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
|
macro_list.sort(
|
||||||
|
key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower())
|
||||||
|
)
|
||||||
elif sort_by == "recent":
|
elif sort_by == "recent":
|
||||||
macro_list.sort(
|
macro_list.sort(
|
||||||
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
|
key=lambda x: (x[1].get("last_used", 0), x[1].get("name", "").lower()),
|
||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +196,7 @@ class MacroManager:
|
|||||||
tabs = ["All"]
|
tabs = ["All"]
|
||||||
categories = set()
|
categories = set()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
for macro in self.macros.values():
|
for macro in self.macros.values():
|
||||||
if macro.get("category"):
|
if macro.get("category"):
|
||||||
categories.add(macro["category"])
|
categories.add(macro["category"])
|
||||||
@@ -161,7 +217,7 @@ class MacroManager:
|
|||||||
"""Add a new macro with command sequence."""
|
"""Add a new macro with command sequence."""
|
||||||
macro_id = str(uuid.uuid4())
|
macro_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Process image if provided
|
# Process image if provided (file IO; does not touch self.macros)
|
||||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||||
|
|
||||||
# Create macro data (deep copy commands to avoid reference issues)
|
# Create macro data (deep copy commands to avoid reference issues)
|
||||||
@@ -174,8 +230,9 @@ class MacroManager:
|
|||||||
"last_used": 0
|
"last_used": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
self.macros[macro_id] = macro_data
|
self.macros[macro_id] = macro_data
|
||||||
self.save_macros()
|
self._save_macros_locked()
|
||||||
return macro_id
|
return macro_id
|
||||||
|
|
||||||
def update_macro(
|
def update_macro(
|
||||||
@@ -187,28 +244,32 @@ class MacroManager:
|
|||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Update an existing macro."""
|
"""Update an existing macro."""
|
||||||
|
with self._lock:
|
||||||
if macro_id not in self.macros:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
existing_image = self.macros[macro_id].get("image_path", "")
|
||||||
|
existing_last_used = self.macros[macro_id].get("last_used", 0)
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
# Keep old image or update with new one (file IO outside the lock)
|
||||||
|
|
||||||
# Keep old image or update with new one
|
|
||||||
if image_path is not None:
|
if image_path is not None:
|
||||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||||
else:
|
else:
|
||||||
image_path_reference = macro.get("image_path", "")
|
image_path_reference = existing_image
|
||||||
|
|
||||||
# Update macro data (deep copy commands to avoid reference issues)
|
new_data = {
|
||||||
self.macros[macro_id] = {
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": "sequence",
|
"type": "sequence",
|
||||||
"commands": copy.deepcopy(commands),
|
"commands": copy.deepcopy(commands),
|
||||||
"category": category,
|
"category": category,
|
||||||
"image_path": image_path_reference,
|
"image_path": image_path_reference,
|
||||||
"last_used": macro.get("last_used", 0)
|
"last_used": existing_last_used
|
||||||
}
|
}
|
||||||
|
|
||||||
self.save_macros()
|
with self._lock:
|
||||||
|
if macro_id not in self.macros:
|
||||||
|
return False
|
||||||
|
self.macros[macro_id] = new_data
|
||||||
|
self._save_macros_locked()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _process_image(self, image_path: str) -> str:
|
def _process_image(self, image_path: str) -> str:
|
||||||
@@ -233,37 +294,50 @@ class MacroManager:
|
|||||||
|
|
||||||
def delete_macro(self, macro_id: str) -> bool:
|
def delete_macro(self, macro_id: str) -> bool:
|
||||||
"""Delete a macro."""
|
"""Delete a macro."""
|
||||||
|
with self._lock:
|
||||||
if macro_id not in self.macros:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
macro = self.macros[macro_id]
|
||||||
|
image_path = macro.get("image_path")
|
||||||
|
del self.macros[macro_id]
|
||||||
|
self._save_macros_locked()
|
||||||
|
|
||||||
# Delete associated image file
|
# Delete associated image file (outside the lock; pure file IO)
|
||||||
if macro.get("image_path"):
|
if image_path:
|
||||||
try:
|
try:
|
||||||
img_path = os.path.join(self.app_dir, macro["image_path"])
|
img_path = os.path.join(self.app_dir, image_path)
|
||||||
if os.path.exists(img_path):
|
if os.path.exists(img_path):
|
||||||
os.remove(img_path)
|
os.remove(img_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing image file: {e}")
|
print(f"Error removing image file: {e}")
|
||||||
|
|
||||||
del self.macros[macro_id]
|
|
||||||
self.save_macros()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute_macro(self, macro_id: str) -> bool:
|
def execute_macro(self, macro_id: str) -> bool:
|
||||||
"""Execute a macro by ID."""
|
"""Execute a macro by ID.
|
||||||
|
|
||||||
|
The macro's commands are copied under the lock, then executed *outside*
|
||||||
|
the lock so long-running wait/keyboard steps don't block other threads
|
||||||
|
(web server, GUI). last_used is updated in memory and only persisted at
|
||||||
|
most every LAST_USED_SAVE_INTERVAL seconds to avoid rewriting the whole
|
||||||
|
file on every press.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
if macro_id not in self.macros:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
macro = self.macros[macro_id]
|
||||||
|
commands = copy.deepcopy(macro.get("commands", []))
|
||||||
|
|
||||||
# Update last_used timestamp
|
# Update last_used in memory; debounce the disk write.
|
||||||
self.macros[macro_id]["last_used"] = time.time()
|
now = time.time()
|
||||||
self.save_macros()
|
macro["last_used"] = now
|
||||||
|
if now - self._last_used_save_time >= LAST_USED_SAVE_INTERVAL:
|
||||||
|
self._last_used_save_time = now
|
||||||
|
self._save_macros_locked()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
commands = macro.get("commands", [])
|
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
self._execute_command(cmd)
|
self._execute_command(cmd)
|
||||||
return True
|
return True
|
||||||
@@ -271,6 +345,22 @@ class MacroManager:
|
|||||||
print(f"Error executing macro: {e}")
|
print(f"Error executing macro: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _valid_keys(keys) -> list:
|
||||||
|
"""Filter a list of key names to those pyautogui recognises."""
|
||||||
|
result = []
|
||||||
|
for k in keys:
|
||||||
|
if not isinstance(k, str):
|
||||||
|
continue
|
||||||
|
name = k.strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if VALID_KEYS and name not in VALID_KEYS:
|
||||||
|
print(f"Skipping invalid key: {name!r}")
|
||||||
|
continue
|
||||||
|
result.append(name)
|
||||||
|
return result
|
||||||
|
|
||||||
def _execute_command(self, cmd: dict):
|
def _execute_command(self, cmd: dict):
|
||||||
"""Execute a single command from a sequence."""
|
"""Execute a single command from a sequence."""
|
||||||
cmd_type = cmd.get("type", "")
|
cmd_type = cmd.get("type", "")
|
||||||
@@ -293,28 +383,58 @@ class MacroManager:
|
|||||||
pyautogui.typewrite(value, interval=0.02)
|
pyautogui.typewrite(value, interval=0.02)
|
||||||
|
|
||||||
elif cmd_type == "key":
|
elif cmd_type == "key":
|
||||||
# Press a single key
|
# Press a single key (validated against pyautogui's known keys)
|
||||||
key = cmd.get("value", "")
|
key = cmd.get("value", "")
|
||||||
if key:
|
valid = self._valid_keys([key]) if key else []
|
||||||
pyautogui.press(key)
|
if valid:
|
||||||
|
pyautogui.press(valid[0])
|
||||||
|
|
||||||
elif cmd_type == "hotkey":
|
elif cmd_type == "hotkey":
|
||||||
# Press key combination
|
# Press key combination
|
||||||
keys = cmd.get("keys", [])
|
keys = cmd.get("keys", [])
|
||||||
if keys:
|
if keys:
|
||||||
pyautogui.hotkey(*keys)
|
# Ensure keys is a list, not a string
|
||||||
|
if isinstance(keys, str):
|
||||||
|
keys = [k.strip().lower() for k in keys.split(",")]
|
||||||
|
valid = self._valid_keys(keys)
|
||||||
|
# Only fire if every requested key was valid; a partial hotkey
|
||||||
|
# is worse than nothing.
|
||||||
|
if valid and len(valid) == len([k for k in keys if str(k).strip()]):
|
||||||
|
# Small delay before hotkey for reliability on Windows
|
||||||
|
time.sleep(0.05)
|
||||||
|
pyautogui.hotkey(*valid, interval=0.05)
|
||||||
|
|
||||||
elif cmd_type == "wait":
|
elif cmd_type == "wait":
|
||||||
# Delay in milliseconds
|
# Delay in milliseconds (bounded, non-negative)
|
||||||
ms = cmd.get("ms", 0)
|
try:
|
||||||
|
ms = int(cmd.get("ms", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ms = 0
|
||||||
if ms > 0:
|
if ms > 0:
|
||||||
|
ms = min(ms, MAX_WAIT_MS)
|
||||||
time.sleep(ms / 1000.0)
|
time.sleep(ms / 1000.0)
|
||||||
|
|
||||||
elif cmd_type == "app":
|
elif cmd_type == "app":
|
||||||
# Launch application
|
# Launch application.
|
||||||
|
# SECURITY: never use shell=True — that would allow shell-metacharacter
|
||||||
|
# injection (; && | $() chaining, redirection). Both branches below run
|
||||||
|
# without a shell, so the command can only launch a program with args.
|
||||||
command = cmd.get("command", "")
|
command = cmd.get("command", "")
|
||||||
if command:
|
if command:
|
||||||
subprocess.Popen(command, shell=True)
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows: pass the string so CreateProcess parses it
|
||||||
|
# (correctly handling quoted paths like "C:\Program
|
||||||
|
# Files\app.exe"). shell=False means no cmd.exe, so no
|
||||||
|
# metacharacter chaining.
|
||||||
|
subprocess.Popen(command)
|
||||||
|
else:
|
||||||
|
# POSIX: split into an argv list; no shell involved.
|
||||||
|
args = shlex.split(command)
|
||||||
|
if args:
|
||||||
|
subprocess.Popen(args)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error launching app command: {e}")
|
||||||
|
|
||||||
# Legacy API compatibility methods
|
# Legacy API compatibility methods
|
||||||
def add_macro_legacy(
|
def add_macro_legacy(
|
||||||
@@ -352,8 +472,11 @@ class MacroManager:
|
|||||||
|
|
||||||
def get_macro(self, macro_id: str) -> Optional[dict]:
|
def get_macro(self, macro_id: str) -> Optional[dict]:
|
||||||
"""Get a macro by ID."""
|
"""Get a macro by ID."""
|
||||||
return self.macros.get(macro_id)
|
with self._lock:
|
||||||
|
macro = self.macros.get(macro_id)
|
||||||
|
return copy.deepcopy(macro) if macro is not None else None
|
||||||
|
|
||||||
def get_all_macros(self) -> dict:
|
def get_all_macros(self) -> dict:
|
||||||
"""Get all macros."""
|
"""Get all macros."""
|
||||||
return self.macros.copy()
|
with self._lock:
|
||||||
|
return copy.deepcopy(self.macros)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# MacroPad Relay Server Configuration
|
||||||
|
# Copy this file to .env and adjust values as needed
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Security
|
||||||
|
BCRYPT_ROUNDS=10
|
||||||
|
|
||||||
|
# Session ID length (default: 12 characters)
|
||||||
|
SESSION_ID_LENGTH=12
|
||||||
|
|
||||||
|
# Minimum password length enforced on session creation
|
||||||
|
MIN_PASSWORD_LENGTH=6
|
||||||
|
|
||||||
|
# Maximum number of concurrent sessions
|
||||||
|
MAX_SESSIONS=1000
|
||||||
|
|
||||||
|
# Prune sessions unused for this many days
|
||||||
|
SESSION_TTL_DAYS=90
|
||||||
|
|
||||||
|
# HTTP rate limiting (15 minute window, 300 requests per window)
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX=300
|
||||||
|
|
||||||
|
# WebSocket upgrade rate limiting (per IP)
|
||||||
|
WS_UPGRADE_WINDOW_MS=60000
|
||||||
|
WS_UPGRADE_MAX=60
|
||||||
|
|
||||||
|
# Auth brute-force protection
|
||||||
|
AUTH_MAX_SOCKET_FAILURES=5
|
||||||
|
AUTH_LOCKOUT_MAX_ATTEMPTS=10
|
||||||
|
AUTH_LOCKOUT_WINDOW_MS=900000
|
||||||
|
AUTH_LOCKOUT_DURATION_MS=900000
|
||||||
|
|
||||||
|
# Allowed origins for CORS + WebSocket (comma-separated). Empty = same-host only.
|
||||||
|
# ALLOWED_ORIGINS=https://macropad.example.com
|
||||||
|
|
||||||
|
# Trust reverse proxy (number of hops, or true/false). Default: 1
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
|
# WebSocket timeouts (in milliseconds)
|
||||||
|
PING_INTERVAL=30000
|
||||||
|
REQUEST_TIMEOUT=30000
|
||||||
|
|
||||||
|
# Logging (error, warn, info, debug)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Data directory (default: ./data)
|
||||||
|
# DATA_DIR=/path/to/data
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/sessions.json
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
error.log
|
||||||
|
combined.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# MacroPad Relay Server - Deployment Guide
|
||||||
|
|
||||||
|
## Cloud Node Container Deployment
|
||||||
|
|
||||||
|
For AnHonestHost cloud-node-container deployment:
|
||||||
|
|
||||||
|
### 1. Build Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jknapp/code/macropad/macropad-relay
|
||||||
|
npm install
|
||||||
|
npm run build # required: the build is no longer triggered automatically on install
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the `postinstall` build hook was removed. Always run `npm run build`
|
||||||
|
> explicitly (locally or in CI) after `npm install` to produce `dist/`.
|
||||||
|
|
||||||
|
### 2. Prepare Deployment Package
|
||||||
|
|
||||||
|
The build outputs to `dist/` with public files copied. Upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload built files to your node container app directory
|
||||||
|
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' \
|
||||||
|
dist/ package.json public/ \
|
||||||
|
user@YOUR_SERVER:/path/to/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. On Server
|
||||||
|
|
||||||
|
The cloud-node-container will automatically:
|
||||||
|
- Install dependencies from package.json
|
||||||
|
- Start the app using PM2
|
||||||
|
- Configure the process from package.json settings
|
||||||
|
|
||||||
|
### 4. Create Data Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /path/to/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure on Server
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── index.js # Main entry (compiled)
|
||||||
|
├── config.js
|
||||||
|
├── server.js
|
||||||
|
├── services/
|
||||||
|
├── handlers/
|
||||||
|
├── utils/
|
||||||
|
├── public/
|
||||||
|
│ ├── login.html
|
||||||
|
│ └── app.html
|
||||||
|
├── data/
|
||||||
|
│ └── sessions.json # Created automatically
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update After Code Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On local machine:
|
||||||
|
cd /home/jknapp/code/macropad/macropad-relay
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' --exclude 'data' \
|
||||||
|
dist/ package.json public/ \
|
||||||
|
user@YOUR_SERVER:/path/to/app/
|
||||||
|
|
||||||
|
# On server - restart via your container's control panel or:
|
||||||
|
pm2 restart macropad-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Set these in your container configuration:
|
||||||
|
|
||||||
|
- `PORT` - Server port (default: 3000)
|
||||||
|
- `DATA_DIR` - Data storage path (default: ./data)
|
||||||
|
- `NODE_ENV` - production or development
|
||||||
|
- `LOG_LEVEL` - info, debug, error
|
||||||
|
- `ALLOWED_ORIGINS` - Comma-separated list of allowed browser origins for the
|
||||||
|
WebSocket Origin check (e.g. `https://macropad.example.com`). **Set this to
|
||||||
|
your public origin(s) when running behind a reverse proxy** (see note below).
|
||||||
|
|
||||||
|
> **Reverse proxy note (`ALLOWED_ORIGINS`):** When `ALLOWED_ORIGINS` is unset,
|
||||||
|
> the WebSocket upgrade Origin check falls back to comparing the browser's
|
||||||
|
> `Origin` host against the raw `Host` header seen by the app. Behind a reverse
|
||||||
|
> proxy the internal `Host` (e.g. `localhost:3000`) often differs from the
|
||||||
|
> public origin the browser sends (e.g. `https://macropad.example.com`), so
|
||||||
|
> legitimate browser WebSocket upgrades get **rejected**. Set `ALLOWED_ORIGINS`
|
||||||
|
> to your public origin(s) to fix this. (Forwarding the public host via
|
||||||
|
> `proxy_set_header Host $host;`, as in the Nginx example below, also helps, but
|
||||||
|
> setting `ALLOWED_ORIGINS` explicitly is the reliable fix.)
|
||||||
|
|
||||||
|
## Test It Works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test health endpoint
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Should return (counts only - no session ids are exposed):
|
||||||
|
# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx/Reverse Proxy (for HTTPS)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket timeout (24 hours)
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
```bash
|
||||||
|
pm2 logs macropad-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check sessions:**
|
||||||
|
```bash
|
||||||
|
cat /path/to/app/data/sessions.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Port in use:**
|
||||||
|
```bash
|
||||||
|
lsof -i :3000
|
||||||
|
```
|
||||||
Generated
+2484
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "macropad-relay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Relay server for MacroPad remote access",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "nodemon --exec ts-node src/index.ts",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"keywords": ["macropad", "relay", "websocket", "proxy"],
|
||||||
|
"author": "ShadowDao",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.16",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/ws": "^8.5.9",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<!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">
|
||||||
|
|
||||||
|
<title>MacroPad</title>
|
||||||
|
|
||||||
|
<!-- PWA manifest will be dynamically set -->
|
||||||
|
<link rel="manifest" id="manifest-link">
|
||||||
|
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--fg-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 { font-size: 1.5rem; }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover { background: var(--button-hover); }
|
||||||
|
|
||||||
|
.header-btn.icon-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wake-lock-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wake-lock-status:hover { background: var(--button-bg); }
|
||||||
|
.wake-lock-status.active .wake-icon { color: var(--success-color); }
|
||||||
|
.wake-lock-status.unsupported { opacity: 0.3; cursor: default; }
|
||||||
|
.wake-lock-status.unsupported .wake-icon { color: #888; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.wake-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #888;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: var(--tab-bg);
|
||||||
|
color: var(--fg-color);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { background: var(--button-hover); }
|
||||||
|
.tab.active { background: var(--tab-selected); }
|
||||||
|
|
||||||
|
.macro-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #888;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--highlight-color);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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); } }
|
||||||
|
|
||||||
|
.offline-banner {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner.visible { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="offline-banner" id="offline-banner">
|
||||||
|
Desktop is offline - waiting for reconnection...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<h1>MacroPad</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="connection-status">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>Connecting...</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" title="Toggle fullscreen">⛶</button>
|
||||||
|
<button class="header-btn" id="refresh-btn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" id="tabs-container"></nav>
|
||||||
|
|
||||||
|
<main class="macro-grid" id="macro-grid">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// MacroPad App for Relay Mode (externalized so the CSP can drop
|
||||||
|
// 'unsafe-inline' for scripts). All macro/tab/id/name values are rendered
|
||||||
|
// with safe DOM APIs (createElement/textContent) and never via innerHTML.
|
||||||
|
class MacroPadApp {
|
||||||
|
constructor() {
|
||||||
|
this.macros = {};
|
||||||
|
this.tabs = [];
|
||||||
|
this.currentTab = 'All';
|
||||||
|
this.ws = null;
|
||||||
|
this.desktopConnected = false;
|
||||||
|
this.wsAuthenticated = false;
|
||||||
|
|
||||||
|
// Reconnect backoff. Reset only after a successful AUTH (not on
|
||||||
|
// socket open): a server that accepts the socket and then closes it
|
||||||
|
// would otherwise reset the backoff on every open and defeat it.
|
||||||
|
this.baseReconnectDelay = 3000;
|
||||||
|
this.maxReconnectDelay = 30000;
|
||||||
|
this.reconnectDelay = this.baseReconnectDelay;
|
||||||
|
|
||||||
|
// Get session ID from URL
|
||||||
|
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
|
||||||
|
this.sessionId = pathMatch ? pathMatch[1] : null;
|
||||||
|
|
||||||
|
// Get password from storage (never from the URL).
|
||||||
|
this.password = sessionStorage.getItem(`macropad_${this.sessionId}`)
|
||||||
|
|| localStorage.getItem(`macropad_${this.sessionId}`);
|
||||||
|
|
||||||
|
if (this.password) {
|
||||||
|
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.wakeLock = null;
|
||||||
|
this.wakeLockEnabled = false;
|
||||||
|
|
||||||
|
this.setupPWA();
|
||||||
|
this.setupWebSocket();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-MacroPad-Password': this.password || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTabs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/${this.sessionId}/api/tabs`, {
|
||||||
|
headers: this.getApiHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 401) return this.handleAuthError();
|
||||||
|
if (response.status === 503) return this.handleDesktopOffline();
|
||||||
|
const data = await response.json();
|
||||||
|
this.tabs = data.tabs || [];
|
||||||
|
this.renderTabs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tabs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMacros() {
|
||||||
|
try {
|
||||||
|
const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
||||||
|
const response = await fetch(`/${this.sessionId}${path}`, {
|
||||||
|
headers: this.getApiHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 401) return this.handleAuthError();
|
||||||
|
if (response.status === 503) return this.handleDesktopOffline();
|
||||||
|
const data = await response.json();
|
||||||
|
this.macros = data.macros || {};
|
||||||
|
this.renderMacros();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading macros:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeMacro(macroId) {
|
||||||
|
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
||||||
|
if (card) card.classList.add('executing');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/${this.sessionId}/api/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getApiHeaders(),
|
||||||
|
body: JSON.stringify({ macro_id: macroId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('Execution failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => card?.classList.remove('executing'), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAuthError() {
|
||||||
|
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
||||||
|
localStorage.removeItem(`macropad_${this.sessionId}`);
|
||||||
|
window.location.href = `/${this.sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDesktopOffline() {
|
||||||
|
this.desktopConnected = false;
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
document.getElementById('offline-banner').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.handleMessage(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.wsAuthenticated = false;
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
// Reconnect using the current backoff, then grow it (capped) for
|
||||||
|
// the next attempt. The backoff is only reset on a successful
|
||||||
|
// auth response, so accept-then-close cycles keep backing off.
|
||||||
|
const delay = this.reconnectDelay;
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||||
|
setTimeout(() => this.setupWebSocket(), delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => this.updateConnectionStatus(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'auth_required':
|
||||||
|
if (this.password) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'auth', password: this.password }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'auth_response':
|
||||||
|
if (data.success) {
|
||||||
|
this.wsAuthenticated = true;
|
||||||
|
// Reset the reconnect backoff only after a confirmed,
|
||||||
|
// successful auth (not merely on socket open).
|
||||||
|
this.reconnectDelay = this.baseReconnectDelay;
|
||||||
|
this.updateConnectionStatus(this.desktopConnected);
|
||||||
|
} else {
|
||||||
|
this.handleAuthError();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'desktop_status':
|
||||||
|
this.desktopConnected = data.status === 'connected';
|
||||||
|
this.updateConnectionStatus(this.desktopConnected);
|
||||||
|
document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected);
|
||||||
|
if (this.desktopConnected) {
|
||||||
|
this.loadTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs() {
|
||||||
|
const container = document.getElementById('tabs-container');
|
||||||
|
container.replaceChildren();
|
||||||
|
for (const tab of this.tabs) {
|
||||||
|
const name = typeof tab === 'string' ? tab : String(tab);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = name === this.currentTab ? 'tab active' : 'tab';
|
||||||
|
btn.setAttribute('data-tab', name);
|
||||||
|
btn.textContent = name;
|
||||||
|
container.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMacros() {
|
||||||
|
const container = document.getElementById('macro-grid');
|
||||||
|
const entries = Object.entries(this.macros);
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = 'No macros found';
|
||||||
|
empty.appendChild(p);
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, macro] of entries) {
|
||||||
|
const name = macro && typeof macro.name === 'string' ? macro.name : '';
|
||||||
|
const hasImage = !!(macro && macro.image_path);
|
||||||
|
const firstChar = name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'macro-card';
|
||||||
|
card.setAttribute('data-macro-id', id);
|
||||||
|
card.addEventListener('click', () => this.executeMacro(id));
|
||||||
|
|
||||||
|
if (hasImage) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'macro-image';
|
||||||
|
img.style.display = 'none';
|
||||||
|
card.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'macro-image-placeholder';
|
||||||
|
placeholder.textContent = firstChar;
|
||||||
|
card.appendChild(placeholder);
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'macro-name';
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
card.appendChild(nameSpan);
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load macro images via authenticated fetch (header, not URL).
|
||||||
|
this.loadMacroImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch each macro image with the password header and display it
|
||||||
|
// as a blob object URL so the credential never appears in a URL.
|
||||||
|
async loadMacroImages() {
|
||||||
|
for (const [id, macro] of Object.entries(this.macros)) {
|
||||||
|
if (!macro.image_path) continue;
|
||||||
|
const card = document.querySelector(`[data-macro-id="${id}"]`);
|
||||||
|
if (!card) continue;
|
||||||
|
const img = card.querySelector('.macro-image');
|
||||||
|
if (!img) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/${this.sessionId}/api/image/${macro.image_path}`,
|
||||||
|
{ headers: this.getApiHeaders() }
|
||||||
|
);
|
||||||
|
if (!response.ok) continue; // keep placeholder
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
img.onload = () => URL.revokeObjectURL(objectUrl);
|
||||||
|
img.src = objectUrl;
|
||||||
|
img.style.display = '';
|
||||||
|
const placeholder = img.nextElementSibling;
|
||||||
|
if (placeholder) placeholder.style.display = 'none';
|
||||||
|
} catch (error) {
|
||||||
|
// Leave the placeholder visible on failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
document.getElementById('tabs-container').addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('tab')) {
|
||||||
|
this.currentTab = e.target.dataset.tab;
|
||||||
|
this.renderTabs();
|
||||||
|
this.loadMacros();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
||||||
|
if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener('click', () => this.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
async setupWakeLock() {
|
||||||
|
const status = document.getElementById('wake-lock-status');
|
||||||
|
|
||||||
|
if (!('wakeLock' in navigator)) {
|
||||||
|
console.log('Wake Lock API not supported');
|
||||||
|
if (status) {
|
||||||
|
status.classList.add('unsupported');
|
||||||
|
status.title = 'Wake lock not available (requires HTTPS)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
status.style.cursor = 'pointer';
|
||||||
|
status.addEventListener('click', () => this.toggleWakeLock());
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.requestWakeLock();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
|
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
|
||||||
|
await this.requestWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleWakeLock() {
|
||||||
|
if (this.wakeLock) {
|
||||||
|
await this.wakeLock.release();
|
||||||
|
this.wakeLock = null;
|
||||||
|
this.wakeLockEnabled = false;
|
||||||
|
this.updateWakeLockStatus(false);
|
||||||
|
this.showToast('Screen can now sleep', 'info');
|
||||||
|
} else {
|
||||||
|
this.wakeLockEnabled = true;
|
||||||
|
await this.requestWakeLock();
|
||||||
|
if (this.wakeLock) {
|
||||||
|
this.showToast('Screen will stay awake', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestWakeLock() {
|
||||||
|
try {
|
||||||
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
this.wakeLockEnabled = true;
|
||||||
|
this.updateWakeLockStatus(true);
|
||||||
|
|
||||||
|
this.wakeLock.addEventListener('release', () => {
|
||||||
|
this.updateWakeLockStatus(false);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Wake Lock error:', err);
|
||||||
|
this.updateWakeLockStatus(false);
|
||||||
|
const status = document.getElementById('wake-lock-status');
|
||||||
|
if (status && !status.classList.contains('unsupported')) {
|
||||||
|
status.title = 'Wake lock failed: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWakeLockStatus(active) {
|
||||||
|
const status = document.getElementById('wake-lock-status');
|
||||||
|
if (status) {
|
||||||
|
status.classList.toggle('active', active);
|
||||||
|
if (!status.classList.contains('unsupported')) {
|
||||||
|
status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PWA manifest setup
|
||||||
|
setupPWA() {
|
||||||
|
// Create dynamic manifest for this session
|
||||||
|
const manifest = {
|
||||||
|
name: 'MacroPad',
|
||||||
|
short_name: 'MacroPad',
|
||||||
|
description: 'Remote macro control',
|
||||||
|
start_url: `/${this.sessionId}/app`,
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#2e2e2e',
|
||||||
|
theme_color: '#007acc',
|
||||||
|
icons: [
|
||||||
|
{ src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
|
||||||
|
const manifestUrl = URL.createObjectURL(blob);
|
||||||
|
document.getElementById('manifest-link').setAttribute('href', manifestUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
app = new MacroPadApp();
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MacroPad Relay</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta a {
|
||||||
|
display: inline-block;
|
||||||
|
background: #4a9eff;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta a:hover {
|
||||||
|
background: #3a8eef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #4a9eff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">⌨</div>
|
||||||
|
<h1>MacroPad Relay</h1>
|
||||||
|
<p class="tagline">Secure remote access to your MacroPad</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>What is this?</h2>
|
||||||
|
<p>
|
||||||
|
MacroPad Relay enables secure remote access to your MacroPad desktop application
|
||||||
|
from anywhere. Control your macros from your phone or tablet over HTTPS,
|
||||||
|
even when you're away from your local network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card features">
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-icon">🔒</span>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>Secure Connection</h3>
|
||||||
|
<p>Password-protected sessions with encrypted WebSocket communication.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-icon">🌐</span>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>Access Anywhere</h3>
|
||||||
|
<p>Use your macros from any device with a web browser.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-icon">⚡</span>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>Real-time Sync</h3>
|
||||||
|
<p>Changes sync instantly between your desktop and mobile devices.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">Learn More</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="footer">
|
||||||
|
Part of the <a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">MacroPad</a> project
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MacroPad - Login</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
color: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background-color: #3e3e3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #505050;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #007acc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #0096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #505050;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: rgba(220, 53, 69, 0.2);
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
color: #dc3545;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.disconnected {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
margin: 0;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>MacroPad</h1>
|
||||||
|
<p class="subtitle">Enter password to access your macros</p>
|
||||||
|
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" placeholder="Enter your password" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="remember">
|
||||||
|
<label for="remember">Remember on this device</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submitBtn">Connect</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="status" id="status">Checking connection...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// MacroPad login page logic (externalized so the CSP can drop 'unsafe-inline'
|
||||||
|
// for scripts).
|
||||||
|
const sessionId = window.location.pathname.split('/')[1];
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const rememberCheckbox = document.getElementById('remember');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorDiv = document.getElementById('error');
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
|
||||||
|
let desktopConnected = false;
|
||||||
|
|
||||||
|
// Check for saved password (session first, then "remembered" store)
|
||||||
|
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`)
|
||||||
|
|| localStorage.getItem(`macropad_${sessionId}`);
|
||||||
|
if (savedPassword) {
|
||||||
|
passwordInput.value = savedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket to check desktop status
|
||||||
|
function checkStatus() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'desktop_status') {
|
||||||
|
desktopConnected = data.status === 'connected';
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
statusDiv.textContent = 'Connection error';
|
||||||
|
statusDiv.className = 'status disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setTimeout(checkStatus, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
if (desktopConnected) {
|
||||||
|
statusDiv.textContent = 'Desktop connected';
|
||||||
|
statusDiv.className = 'status connected';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = 'Desktop not connected';
|
||||||
|
statusDiv.className = 'status disconnected';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorDiv.style.display = 'none';
|
||||||
|
|
||||||
|
const password = passwordInput.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test password with a simple API call
|
||||||
|
const response = await fetch(`/${sessionId}/api/tabs`, {
|
||||||
|
headers: {
|
||||||
|
'X-MacroPad-Password': password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Hand the credential to the app via storage (never the URL).
|
||||||
|
sessionStorage.setItem(`macropad_${sessionId}`, password);
|
||||||
|
if (rememberCheckbox.checked) {
|
||||||
|
localStorage.setItem(`macropad_${sessionId}`, password);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(`macropad_${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the PWA (credential is read from storage).
|
||||||
|
window.location.href = `/${sessionId}/app`;
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
errorDiv.textContent = data.error || 'Invalid password';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = 'Connection failed';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Configuration for MacroPad Relay Server
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
function parseTrustProxy(): boolean | number | string {
|
||||||
|
const v = process.env.TRUST_PROXY ?? '1';
|
||||||
|
if (v === 'false') return false;
|
||||||
|
if (v === 'true') return true;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? v : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
host: process.env.HOST || '0.0.0.0',
|
||||||
|
|
||||||
|
// Data storage
|
||||||
|
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
|
||||||
|
|
||||||
|
// Session settings
|
||||||
|
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '12', 10),
|
||||||
|
|
||||||
|
// Maximum number of sessions that may exist at once
|
||||||
|
maxSessions: parseInt(process.env.MAX_SESSIONS || '1000', 10),
|
||||||
|
|
||||||
|
// Minimum password length enforced when creating a new session
|
||||||
|
minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH || '6', 10),
|
||||||
|
|
||||||
|
// Sessions unused for this many days are pruned
|
||||||
|
sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS || '90', 10),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
|
||||||
|
|
||||||
|
// Rate limiting (HTTP)
|
||||||
|
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||||
|
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
|
||||||
|
|
||||||
|
// WebSocket upgrade rate limiting (per IP)
|
||||||
|
wsUpgradeWindowMs: parseInt(process.env.WS_UPGRADE_WINDOW_MS || '60000', 10), // 1 minute
|
||||||
|
wsUpgradeMax: parseInt(process.env.WS_UPGRADE_MAX || '60', 10), // max upgrades per IP per window
|
||||||
|
|
||||||
|
// Auth brute-force protection
|
||||||
|
authMaxSocketFailures: parseInt(process.env.AUTH_MAX_SOCKET_FAILURES || '5', 10),
|
||||||
|
authLockoutMaxAttempts: parseInt(process.env.AUTH_LOCKOUT_MAX_ATTEMPTS || '10', 10),
|
||||||
|
authLockoutWindowMs: parseInt(process.env.AUTH_LOCKOUT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||||
|
authLockoutDurationMs: parseInt(process.env.AUTH_LOCKOUT_DURATION_MS || '900000', 10), // 15 minutes
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
|
||||||
|
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
|
||||||
|
|
||||||
|
// Origin / CORS allowlist (comma-separated). Empty = same-host only.
|
||||||
|
allowedOrigins: (process.env.ALLOWED_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter((o) => o.length > 0),
|
||||||
|
|
||||||
|
// Trust the X-Forwarded-* headers from a reverse proxy
|
||||||
|
trustProxy: parseTrustProxy(),
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// API Proxy Handler - proxies REST requests to desktop apps
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ConnectionManager } from '../services/ConnectionManager';
|
||||||
|
import { SessionManager } from '../services/SessionManager';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export function createApiProxy(
|
||||||
|
connectionManager: ConnectionManager,
|
||||||
|
sessionManager: SessionManager
|
||||||
|
) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Credentials must be supplied via the header (never the URL/query,
|
||||||
|
// which leaks into logs, history and referrers).
|
||||||
|
const password = req.headers['x-macropad-password'] as string;
|
||||||
|
|
||||||
|
// Uniform auth failure. A missing session, a missing password, and a
|
||||||
|
// wrong password all return the SAME status + generic message so a
|
||||||
|
// caller cannot infer whether a given session id exists (no
|
||||||
|
// enumeration oracle). validatePassword() safely returns false for an
|
||||||
|
// unknown session id.
|
||||||
|
if (!password || !(await sessionManager.validatePassword(sessionId, password))) {
|
||||||
|
return res.status(401).json({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only AFTER successful auth do we reveal desktop connectivity, so the
|
||||||
|
// 503 "not connected" signal cannot itself be used to probe which
|
||||||
|
// session ids exist.
|
||||||
|
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||||
|
if (!desktop) {
|
||||||
|
return res.status(503).json({ error: 'Desktop not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the API path (remove the session ID prefix)
|
||||||
|
const apiPath = req.path.replace(`/${sessionId}`, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await connectionManager.forwardApiRequest(
|
||||||
|
sessionId,
|
||||||
|
req.method,
|
||||||
|
apiPath,
|
||||||
|
req.body,
|
||||||
|
filterHeaders(req.headers)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle binary responses (images)
|
||||||
|
if (response.body?.base64 && response.body?.contentType) {
|
||||||
|
const buffer = Buffer.from(response.body.base64, 'base64');
|
||||||
|
res.set('Content-Type', response.body.contentType);
|
||||||
|
res.send(buffer);
|
||||||
|
} else {
|
||||||
|
res.status(response.status).json(response.body);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`API proxy error for ${sessionId}:`, error);
|
||||||
|
|
||||||
|
if (error.message === 'Request timeout') {
|
||||||
|
return res.status(504).json({ error: 'Desktop request timeout' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') {
|
||||||
|
return res.status(503).json({ error: 'Desktop not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Proxy error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHeaders(headers: any): Record<string, string> {
|
||||||
|
// Only forward relevant headers
|
||||||
|
const allowed = ['content-type', 'accept', 'accept-language'];
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (headers[key]) {
|
||||||
|
filtered[key] = headers[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Desktop WebSocket Handler
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { ConnectionManager } from '../services/ConnectionManager';
|
||||||
|
import { SessionManager } from '../services/SessionManager';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
|
||||||
|
|
||||||
|
// Close code 1008 = policy violation (used for auth abuse).
|
||||||
|
const WS_POLICY_VIOLATION = 1008;
|
||||||
|
|
||||||
|
interface AuthMessage {
|
||||||
|
type: 'auth';
|
||||||
|
sessionId: string | null;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponseMessage {
|
||||||
|
type: 'api_response';
|
||||||
|
requestId: string;
|
||||||
|
status: number;
|
||||||
|
body: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsBroadcastMessage {
|
||||||
|
type: 'ws_broadcast';
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PongMessage {
|
||||||
|
type: 'pong';
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | PongMessage;
|
||||||
|
|
||||||
|
export function handleDesktopConnection(
|
||||||
|
socket: WebSocket,
|
||||||
|
connectionManager: ConnectionManager,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
clientIp: string
|
||||||
|
): void {
|
||||||
|
let authenticatedSessionId: string | null = null;
|
||||||
|
// Track failed auth attempts on this individual socket.
|
||||||
|
let socketAuthFailures = 0;
|
||||||
|
|
||||||
|
socket.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const message: DesktopMessage = JSON.parse(data.toString());
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'auth': {
|
||||||
|
const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => {
|
||||||
|
authenticatedSessionId = sessionId;
|
||||||
|
});
|
||||||
|
if (failed) {
|
||||||
|
socketAuthFailures++;
|
||||||
|
if (socketAuthFailures >= config.authMaxSocketFailures) {
|
||||||
|
logger.warn('Desktop exceeded auth attempts, closing socket');
|
||||||
|
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
socketAuthFailures = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'api_response':
|
||||||
|
if (authenticatedSessionId) {
|
||||||
|
handleApiResponse(message, authenticatedSessionId, connectionManager);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ws_broadcast':
|
||||||
|
if (authenticatedSessionId) {
|
||||||
|
handleWsBroadcast(message, authenticatedSessionId, connectionManager);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
if (authenticatedSessionId) {
|
||||||
|
connectionManager.updateDesktopPing(authenticatedSessionId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown message type from desktop:', (message as any).type);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling desktop message:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (authenticatedSessionId) {
|
||||||
|
connectionManager.disconnectDesktop(authenticatedSessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
logger.error('Desktop WebSocket error:', error);
|
||||||
|
if (authenticatedSessionId) {
|
||||||
|
connectionManager.disconnectDesktop(authenticatedSessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the auth attempt failed (so the caller can count it).
|
||||||
|
async function handleAuth(
|
||||||
|
socket: WebSocket,
|
||||||
|
message: AuthMessage,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
connectionManager: ConnectionManager,
|
||||||
|
clientIp: string,
|
||||||
|
setSessionId: (id: string) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let sessionId = message.sessionId;
|
||||||
|
let session;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
// Namespace the brute-force lockout key to the desktop keyspace and
|
||||||
|
// include the client IP. This keeps the desktop's lockout state entirely
|
||||||
|
// separate from the web-client keyspace, so a web-auth flood can never
|
||||||
|
// lock the desktop out of (re)authenticating.
|
||||||
|
const throttleKey = `desktop:${sessionId}:${clientIp}`;
|
||||||
|
|
||||||
|
// Reject early if this key is currently locked out.
|
||||||
|
const lockRemaining = getLockoutRemaining(throttleKey);
|
||||||
|
if (lockRemaining > 0) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: 'Too many failed attempts, try again later',
|
||||||
|
retryAfterMs: lockRemaining
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate existing session
|
||||||
|
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
||||||
|
if (!valid) {
|
||||||
|
recordFailure(throttleKey);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid session ID or password'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
recordSuccess(throttleKey);
|
||||||
|
session = sessionManager.getSession(sessionId);
|
||||||
|
} else {
|
||||||
|
// Create new session (may throw on limit / short password)
|
||||||
|
session = await sessionManager.createSession(message.password);
|
||||||
|
sessionId = session.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !sessionId) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create session'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to connection manager
|
||||||
|
connectionManager.addDesktopConnection(sessionId, socket);
|
||||||
|
setSessionId(sessionId);
|
||||||
|
|
||||||
|
// Send success response
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: true,
|
||||||
|
sessionId: sessionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`Desktop authenticated: ${sessionId}`);
|
||||||
|
return false;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Desktop auth error:', error);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: error?.message || 'Authentication failed'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiResponse(
|
||||||
|
message: ApiResponseMessage,
|
||||||
|
sessionId: string,
|
||||||
|
connectionManager: ConnectionManager
|
||||||
|
): void {
|
||||||
|
connectionManager.handleApiResponse(sessionId, message.requestId, message.status, message.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsBroadcast(
|
||||||
|
message: WsBroadcastMessage,
|
||||||
|
sessionId: string,
|
||||||
|
connectionManager: ConnectionManager
|
||||||
|
): void {
|
||||||
|
// Forward the broadcast to all web clients for this session
|
||||||
|
connectionManager.broadcastToWebClients(sessionId, message.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Web Client WebSocket Handler
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
|
||||||
|
import { SessionManager } from '../services/SessionManager';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
|
||||||
|
|
||||||
|
// Close code 1008 = policy violation (used for auth abuse).
|
||||||
|
const WS_POLICY_VIOLATION = 1008;
|
||||||
|
|
||||||
|
interface AuthMessage {
|
||||||
|
type: 'auth';
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PingMessage {
|
||||||
|
type: 'ping';
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebClientMessage = AuthMessage | PingMessage | any;
|
||||||
|
|
||||||
|
export function handleWebClientConnection(
|
||||||
|
socket: WebSocket,
|
||||||
|
sessionId: string,
|
||||||
|
connectionManager: ConnectionManager,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
clientIp: string
|
||||||
|
): void {
|
||||||
|
// Namespace the brute-force lockout key to the web keyspace and include the
|
||||||
|
// client IP. This prevents one abusive IP (which knows the shareable session
|
||||||
|
// id) from locking out other web clients or the desktop for this session.
|
||||||
|
const throttleKey = `web:${sessionId}:${clientIp}`;
|
||||||
|
|
||||||
|
// Do NOT reveal whether the session exists. A non-existent session id and
|
||||||
|
// an existing one must be indistinguishable to an unauthenticated client so
|
||||||
|
// session ids cannot be enumerated: we always run the same handshake and let
|
||||||
|
// the (uniform) auth response be the only signal. validatePassword() safely
|
||||||
|
// returns false for an unknown session id, and desktop connectivity is only
|
||||||
|
// disclosed AFTER a successful auth (see the 'auth' case below).
|
||||||
|
const client = connectionManager.addWebClient(sessionId, socket, false);
|
||||||
|
|
||||||
|
// Track failed auth attempts on this individual socket.
|
||||||
|
let socketAuthFailures = 0;
|
||||||
|
|
||||||
|
// Request authentication.
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_required'
|
||||||
|
}));
|
||||||
|
|
||||||
|
socket.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const message: WebClientMessage = JSON.parse(data.toString());
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'auth': {
|
||||||
|
const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
|
||||||
|
if (failed) {
|
||||||
|
socketAuthFailures++;
|
||||||
|
if (socketAuthFailures >= config.authMaxSocketFailures) {
|
||||||
|
logger.warn(`Web client exceeded auth attempts for session ${sessionId}, closing socket`);
|
||||||
|
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
socketAuthFailures = 0;
|
||||||
|
// Authenticated: only now do we disclose desktop connectivity.
|
||||||
|
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'desktop_status',
|
||||||
|
status: desktop ? 'connected' : 'disconnected'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
socket.send(JSON.stringify({ type: 'pong' }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Forward other messages to desktop if authenticated
|
||||||
|
if (client.authenticated) {
|
||||||
|
forwardToDesktop(message, sessionId, connectionManager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling web client message:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
connectionManager.removeWebClient(sessionId, client);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
logger.error('Web client WebSocket error:', error);
|
||||||
|
connectionManager.removeWebClient(sessionId, client);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the auth attempt failed (so the caller can count it).
|
||||||
|
async function handleAuth(
|
||||||
|
socket: WebSocket,
|
||||||
|
client: WebClientConnection,
|
||||||
|
message: AuthMessage,
|
||||||
|
sessionId: string,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
throttleKey: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Reject early if this key is currently locked out.
|
||||||
|
const lockRemaining = getLockoutRemaining(throttleKey);
|
||||||
|
if (lockRemaining > 0) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: 'Too many failed attempts, try again later',
|
||||||
|
retryAfterMs: lockRemaining
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
recordSuccess(throttleKey);
|
||||||
|
client.authenticated = true;
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: true
|
||||||
|
}));
|
||||||
|
logger.debug(`Web client authenticated for session: ${sessionId}`);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
recordFailure(throttleKey);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'auth_response',
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication failed'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardToDesktop(
|
||||||
|
message: any,
|
||||||
|
sessionId: string,
|
||||||
|
connectionManager: ConnectionManager
|
||||||
|
): void {
|
||||||
|
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
||||||
|
if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
|
||||||
|
desktop.socket.send(JSON.stringify({
|
||||||
|
type: 'ws_message',
|
||||||
|
data: message
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// MacroPad Relay Server Entry Point
|
||||||
|
|
||||||
|
import { createServer } from './server';
|
||||||
|
import { config } from './config';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
logger.info('Starting MacroPad Relay Server...');
|
||||||
|
|
||||||
|
const { server } = createServer();
|
||||||
|
|
||||||
|
server.listen(config.port, config.host, () => {
|
||||||
|
logger.info(`Server running on http://${config.host}:${config.port}`);
|
||||||
|
logger.info(`Environment: ${config.nodeEnv}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Express + WebSocket Server Setup
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import http from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { config } from './config';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
import { SessionManager } from './services/SessionManager';
|
||||||
|
import { ConnectionManager } from './services/ConnectionManager';
|
||||||
|
import { handleDesktopConnection } from './handlers/desktopHandler';
|
||||||
|
import { handleWebClientConnection } from './handlers/webClientHandler';
|
||||||
|
import { createApiProxy } from './handlers/apiProxy';
|
||||||
|
|
||||||
|
// Resolve the client IP for an upgrade request, honouring the proxy's
|
||||||
|
// X-Forwarded-For first hop when we trust the proxy.
|
||||||
|
function getClientIp(request: http.IncomingMessage): string {
|
||||||
|
if (config.trustProxy) {
|
||||||
|
const xff = request.headers['x-forwarded-for'];
|
||||||
|
if (typeof xff === 'string' && xff.length > 0) {
|
||||||
|
return xff.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(xff) && xff.length > 0) {
|
||||||
|
return xff[0].split(',')[0].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.socket.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory sliding-window rate limiter for WS upgrades, keyed by IP.
|
||||||
|
function createUpgradeRateLimiter() {
|
||||||
|
const hits = new Map<string, number[]>();
|
||||||
|
|
||||||
|
const cleanup = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - config.wsUpgradeWindowMs;
|
||||||
|
for (const [ip, times] of hits) {
|
||||||
|
const recent = times.filter((t) => t > cutoff);
|
||||||
|
if (recent.length === 0) {
|
||||||
|
hits.delete(ip);
|
||||||
|
} else {
|
||||||
|
hits.set(ip, recent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.wsUpgradeWindowMs);
|
||||||
|
if (typeof cleanup.unref === 'function') cleanup.unref();
|
||||||
|
|
||||||
|
return function allow(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - config.wsUpgradeWindowMs;
|
||||||
|
const times = (hits.get(ip) || []).filter((t) => t > cutoff);
|
||||||
|
times.push(now);
|
||||||
|
hits.set(ip, times);
|
||||||
|
return times.length <= config.wsUpgradeMax;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the Origin header for a WS upgrade against the allowlist.
|
||||||
|
// Native (desktop) clients send no Origin and are permitted.
|
||||||
|
function isOriginAllowed(origin: string | undefined, host: string | undefined): boolean {
|
||||||
|
if (!origin) return true; // non-browser clients (e.g. desktop app)
|
||||||
|
|
||||||
|
if (config.allowedOrigins.length > 0) {
|
||||||
|
return config.allowedOrigins.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: allow only same-host origins.
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer() {
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Trust the reverse proxy so client IPs (rate limiting) are accurate.
|
||||||
|
app.set('trust proxy', config.trustProxy);
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
const sessionManager = new SessionManager();
|
||||||
|
const connectionManager = new ConnectionManager();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(helmet({
|
||||||
|
// Page scripts are served as external files (/static/app.js,
|
||||||
|
// /static/login.js) with no inline handlers, so scripts are restricted to
|
||||||
|
// 'self' (no 'unsafe-inline'). The pages still use inline <style> blocks,
|
||||||
|
// so style-src keeps 'unsafe-inline'. Images may be data:/blob: (macro
|
||||||
|
// images are loaded as blob object URLs) and WS connections need ws:/wss:.
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
|
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||||
|
manifestSrc: ["'self'", 'blob:'],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Restrict CORS to the configured origins. When none are configured we
|
||||||
|
// disallow cross-origin requests (the first-party app is same-origin).
|
||||||
|
const corsOptions: cors.CorsOptions =
|
||||||
|
config.allowedOrigins.length > 0
|
||||||
|
? { origin: config.allowedOrigins }
|
||||||
|
: { origin: false };
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: config.rateLimitWindowMs,
|
||||||
|
max: config.rateLimitMax,
|
||||||
|
message: { error: 'Too many requests, please try again later' }
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
// Static files - check both locations (dev vs production)
|
||||||
|
const publicPath = fs.existsSync(path.join(__dirname, 'public'))
|
||||||
|
? path.join(__dirname, 'public')
|
||||||
|
: path.join(__dirname, '..', 'public');
|
||||||
|
app.use('/static', express.static(publicPath));
|
||||||
|
|
||||||
|
// Health check - counts only, never leak session ids.
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
const stats = connectionManager.getStats();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
desktops: stats.desktopConnections,
|
||||||
|
webClients: stats.webClients,
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ping endpoint for container health checks
|
||||||
|
app.get('/ping', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index page
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(publicPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login page for session. Served uniformly regardless of whether the
|
||||||
|
// session exists so validity cannot be probed via this endpoint.
|
||||||
|
app.get('/:sessionId', (req, res) => {
|
||||||
|
res.sendFile(path.join(publicPath, 'login.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// PWA app page (served uniformly; auth is enforced by the API/WS layer).
|
||||||
|
app.get('/:sessionId/app', (req, res) => {
|
||||||
|
res.sendFile(path.join(publicPath, 'app.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// API proxy routes
|
||||||
|
const apiProxy = createApiProxy(connectionManager, sessionManager);
|
||||||
|
app.all('/:sessionId/api/*', apiProxy);
|
||||||
|
|
||||||
|
// WebSocket server
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
const allowUpgrade = createUpgradeRateLimiter();
|
||||||
|
|
||||||
|
// Handle HTTP upgrade for WebSocket
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
// IP-based upgrade rate limiting (WS upgrades bypass Express middleware).
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!allowUpgrade(ip)) {
|
||||||
|
logger.warn(`WS upgrade rate limit exceeded for ${ip}`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin validation.
|
||||||
|
if (!isOriginAllowed(request.headers.origin, request.headers.host)) {
|
||||||
|
logger.warn(`WS upgrade rejected: bad origin ${request.headers.origin}`);
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// Desktop connection: /desktop
|
||||||
|
if (pathname === '/desktop') {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
handleDesktopConnection(ws, connectionManager, sessionManager, ip);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web client connection: /:sessionId/ws
|
||||||
|
const webClientMatch = pathname.match(/^\/([a-zA-Z0-9]+)\/ws$/);
|
||||||
|
if (webClientMatch) {
|
||||||
|
const sessionId = webClientMatch[1];
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager, ip);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid path
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = () => {
|
||||||
|
logger.info('Shutting down...');
|
||||||
|
connectionManager.shutdown();
|
||||||
|
sessionManager.shutdown();
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
return { app, server, sessionManager, connectionManager };
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Connection Manager - manages desktop and web client connections
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { generateRequestId } from '../utils/idGenerator';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export interface PendingRequest {
|
||||||
|
resolve: (response: any) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopConnection {
|
||||||
|
sessionId: string;
|
||||||
|
socket: WebSocket;
|
||||||
|
authenticated: boolean;
|
||||||
|
connectedAt: Date;
|
||||||
|
lastPing: Date;
|
||||||
|
pendingRequests: Map<string, PendingRequest>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebClientConnection {
|
||||||
|
socket: WebSocket;
|
||||||
|
sessionId: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionManager {
|
||||||
|
// Desktop connections: sessionId -> connection
|
||||||
|
private desktopConnections: Map<string, DesktopConnection> = new Map();
|
||||||
|
|
||||||
|
// Web clients: sessionId -> set of connections
|
||||||
|
private webClients: Map<string, Set<WebClientConnection>> = new Map();
|
||||||
|
|
||||||
|
// Ping interval handle
|
||||||
|
private pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startPingInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPingInterval(): void {
|
||||||
|
this.pingInterval = setInterval(() => {
|
||||||
|
this.pingDesktops();
|
||||||
|
}, config.pingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pingDesktops(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, desktop] of this.desktopConnections) {
|
||||||
|
// Check if desktop hasn't responded in too long
|
||||||
|
if (now - desktop.lastPing.getTime() > config.pingInterval * 2) {
|
||||||
|
logger.warn(`Desktop ${sessionId} not responding, disconnecting`);
|
||||||
|
this.disconnectDesktop(sessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ping
|
||||||
|
try {
|
||||||
|
desktop.socket.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to ping desktop ${sessionId}:`, error);
|
||||||
|
this.disconnectDesktop(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop connection methods
|
||||||
|
|
||||||
|
addDesktopConnection(sessionId: string, socket: WebSocket): DesktopConnection {
|
||||||
|
// Close existing connection if any
|
||||||
|
const existing = this.desktopConnections.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
existing.socket.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: DesktopConnection = {
|
||||||
|
sessionId,
|
||||||
|
socket,
|
||||||
|
authenticated: true,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
lastPing: new Date(),
|
||||||
|
pendingRequests: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.desktopConnections.set(sessionId, connection);
|
||||||
|
logger.info(`Desktop connected: ${sessionId}`);
|
||||||
|
|
||||||
|
// Notify web clients
|
||||||
|
this.broadcastToWebClients(sessionId, {
|
||||||
|
type: 'desktop_status',
|
||||||
|
status: 'connected'
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectDesktop(sessionId: string): void {
|
||||||
|
const connection = this.desktopConnections.get(sessionId);
|
||||||
|
if (connection) {
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [requestId, pending] of connection.pendingRequests) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.reject(new Error('Desktop disconnected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
connection.socket.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
this.desktopConnections.delete(sessionId);
|
||||||
|
logger.info(`Desktop disconnected: ${sessionId}`);
|
||||||
|
|
||||||
|
// Notify web clients
|
||||||
|
this.broadcastToWebClients(sessionId, {
|
||||||
|
type: 'desktop_status',
|
||||||
|
status: 'disconnected'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDesktopBySessionId(sessionId: string): DesktopConnection | undefined {
|
||||||
|
return this.desktopConnections.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDesktopPing(sessionId: string): void {
|
||||||
|
const connection = this.desktopConnections.get(sessionId);
|
||||||
|
if (connection) {
|
||||||
|
connection.lastPing = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web client connection methods
|
||||||
|
|
||||||
|
addWebClient(sessionId: string, socket: WebSocket, authenticated: boolean = false): WebClientConnection {
|
||||||
|
if (!this.webClients.has(sessionId)) {
|
||||||
|
this.webClients.set(sessionId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: WebClientConnection = {
|
||||||
|
socket,
|
||||||
|
sessionId,
|
||||||
|
authenticated
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webClients.get(sessionId)!.add(client);
|
||||||
|
logger.debug(`Web client connected to session: ${sessionId}`);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWebClient(sessionId: string, client: WebClientConnection): void {
|
||||||
|
const clients = this.webClients.get(sessionId);
|
||||||
|
if (clients) {
|
||||||
|
clients.delete(client);
|
||||||
|
if (clients.size === 0) {
|
||||||
|
this.webClients.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(`Web client disconnected from session: ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebClientsBySessionId(sessionId: string): Set<WebClientConnection> {
|
||||||
|
return this.webClients.get(sessionId) || new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToWebClients(sessionId: string, message: object): void {
|
||||||
|
const clients = this.webClients.get(sessionId);
|
||||||
|
if (!clients) return;
|
||||||
|
|
||||||
|
const data = JSON.stringify(message);
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.authenticated && client.socket.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
client.socket.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send to web client:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API request forwarding
|
||||||
|
|
||||||
|
async forwardApiRequest(
|
||||||
|
sessionId: string,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: any,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): Promise<{ status: number; body: any }> {
|
||||||
|
const desktop = this.desktopConnections.get(sessionId);
|
||||||
|
if (!desktop) {
|
||||||
|
throw new Error('Desktop not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
desktop.pendingRequests.delete(requestId);
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
}, config.requestTimeout);
|
||||||
|
|
||||||
|
desktop.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
|
||||||
|
try {
|
||||||
|
desktop.socket.send(JSON.stringify({
|
||||||
|
type: 'api_request',
|
||||||
|
requestId,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body,
|
||||||
|
headers
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
desktop.pendingRequests.delete(requestId);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleApiResponse(sessionId: string, requestId: string, status: number, body: any): void {
|
||||||
|
const desktop = this.desktopConnections.get(sessionId);
|
||||||
|
if (!desktop) return;
|
||||||
|
|
||||||
|
const pending = desktop.pendingRequests.get(requestId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
desktop.pendingRequests.delete(requestId);
|
||||||
|
pending.resolve({ status, body });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.pingInterval) {
|
||||||
|
clearInterval(this.pingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionId] of this.desktopConnections) {
|
||||||
|
this.disconnectDesktop(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sessionId, clients] of this.webClients) {
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
client.socket.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.webClients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
desktopConnections: this.desktopConnections.size,
|
||||||
|
webClients: Array.from(this.webClients.values()).reduce((sum, set) => sum + set.size, 0),
|
||||||
|
sessions: Array.from(this.desktopConnections.keys())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Session Manager - handles session storage and authentication
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { generateSessionId } from '../utils/idGenerator';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
passwordHash: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastConnected: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStore {
|
||||||
|
sessions: Record<string, Session>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush pending (debounced) writes at most this often.
|
||||||
|
const FLUSH_INTERVAL_MS = 60 * 1000;
|
||||||
|
// Prune expired sessions on this cadence.
|
||||||
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private sessionsFile: string;
|
||||||
|
private sessions: Map<string, Session> = new Map();
|
||||||
|
private dirty = false;
|
||||||
|
private flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
private pruneTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
|
||||||
|
this.load();
|
||||||
|
this.pruneExpired();
|
||||||
|
this.startBackgroundTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBackgroundTasks(): void {
|
||||||
|
this.flushTimer = setInterval(() => {
|
||||||
|
if (this.dirty) {
|
||||||
|
this.save();
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref();
|
||||||
|
|
||||||
|
this.pruneTimer = setInterval(() => {
|
||||||
|
this.pruneExpired();
|
||||||
|
}, PRUNE_INTERVAL_MS);
|
||||||
|
if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.sessionsFile)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
|
||||||
|
for (const [id, session] of Object.entries(data.sessions || {})) {
|
||||||
|
this.sessions.set(id, session);
|
||||||
|
}
|
||||||
|
logger.info(`Loaded ${this.sessions.size} sessions`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load sessions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: write to a temp file in the same directory, then rename.
|
||||||
|
private save(): void {
|
||||||
|
try {
|
||||||
|
const store: SessionStore = {
|
||||||
|
sessions: Object.fromEntries(this.sessions)
|
||||||
|
};
|
||||||
|
const dir = path.dirname(this.sessionsFile);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const tmpFile = path.join(
|
||||||
|
dir,
|
||||||
|
`.sessions.${process.pid}.${Date.now()}.tmp`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2));
|
||||||
|
fs.renameSync(tmpFile, this.sessionsFile);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save sessions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune sessions that have been unused past the configured TTL.
|
||||||
|
private pruneExpired(): void {
|
||||||
|
const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000;
|
||||||
|
if (ttlMs <= 0) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let pruned = 0;
|
||||||
|
for (const [id, session] of this.sessions) {
|
||||||
|
const last = Date.parse(session.lastConnected || session.createdAt);
|
||||||
|
if (!Number.isNaN(last) && now - last > ttlMs) {
|
||||||
|
this.sessions.delete(id);
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pruned > 0) {
|
||||||
|
logger.info(`Pruned ${pruned} expired session(s)`);
|
||||||
|
this.save();
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(sessionId: string): Promise<boolean> {
|
||||||
|
return this.sessions.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(password: string): Promise<Session> {
|
||||||
|
// Reject empty / short passwords before doing any hashing.
|
||||||
|
if (!password || password.length < config.minPasswordLength) {
|
||||||
|
throw new Error(
|
||||||
|
`Password must be at least ${config.minPasswordLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap total sessions to prevent unbounded resource exhaustion.
|
||||||
|
if (this.sessions.size >= config.maxSessions) {
|
||||||
|
// Try to reclaim space by pruning expired sessions first.
|
||||||
|
this.pruneExpired();
|
||||||
|
if (this.sessions.size >= config.maxSessions) {
|
||||||
|
throw new Error('Session limit reached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique session ID
|
||||||
|
let id: string;
|
||||||
|
do {
|
||||||
|
id = generateSessionId(config.sessionIdLength);
|
||||||
|
} while (this.sessions.has(id));
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
|
||||||
|
|
||||||
|
// Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only
|
||||||
|
// suspension point between the first cap check and the set() below, so
|
||||||
|
// concurrent createSession calls could each have passed the earlier check
|
||||||
|
// and then filled the map while we were hashing. Re-checking here — with
|
||||||
|
// no await between this check and the synchronous set() — makes the
|
||||||
|
// check-and-insert atomic under Node's single-threaded model, so the cap
|
||||||
|
// cannot be overshot.
|
||||||
|
if (this.sessions.size >= config.maxSessions) {
|
||||||
|
this.pruneExpired();
|
||||||
|
if (this.sessions.size >= config.maxSessions) {
|
||||||
|
throw new Error('Session limit reached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
id,
|
||||||
|
passwordHash,
|
||||||
|
createdAt: now,
|
||||||
|
lastConnected: now
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(id, session);
|
||||||
|
this.save(); // creation is important, persist immediately
|
||||||
|
|
||||||
|
logger.info(`Created new session: ${id}`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePassword(sessionId: string, password: string): Promise<boolean> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, session.passwordHash);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
// Update last-connected time, but only mark dirty (debounced flush)
|
||||||
|
// if it moved by more than a minute to avoid writing on every auth.
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = Date.parse(session.lastConnected);
|
||||||
|
if (Number.isNaN(prev) || now - prev > 60 * 1000) {
|
||||||
|
session.lastConnected = new Date(now).toISOString();
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(sessionId: string): Session | undefined {
|
||||||
|
return this.sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastConnected(sessionId: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.lastConnected = new Date().toISOString();
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(sessionId: string): boolean {
|
||||||
|
const deleted = this.sessions.delete(sessionId);
|
||||||
|
if (deleted) {
|
||||||
|
this.save();
|
||||||
|
logger.info(`Deleted session: ${sessionId}`);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any pending writes and stop background timers.
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearInterval(this.flushTimer);
|
||||||
|
this.flushTimer = null;
|
||||||
|
}
|
||||||
|
if (this.pruneTimer) {
|
||||||
|
clearInterval(this.pruneTimer);
|
||||||
|
this.pruneTimer = null;
|
||||||
|
}
|
||||||
|
if (this.dirty) {
|
||||||
|
this.save();
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Auth brute-force lockout (in-memory).
|
||||||
|
//
|
||||||
|
// Tracks failed authentication attempts keyed by an opaque, namespaced key
|
||||||
|
// (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`).
|
||||||
|
// Namespacing by path and client IP keeps the web and desktop keyspaces
|
||||||
|
// separate so one abusive web client cannot lock out other users or the
|
||||||
|
// desktop for the same session. After `authLockoutMaxAttempts` failures within
|
||||||
|
// `authLockoutWindowMs`, further attempts are rejected until
|
||||||
|
// `authLockoutDurationMs` has elapsed.
|
||||||
|
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
interface AttemptRecord {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
lockedUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = new Map<string, AttemptRecord>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of milliseconds remaining on a lockout for this key,
|
||||||
|
* or 0 if the key is not currently locked.
|
||||||
|
*/
|
||||||
|
export function getLockoutRemaining(key: string): number {
|
||||||
|
const rec = records.get(key);
|
||||||
|
if (!rec) return 0;
|
||||||
|
const now = Date.now();
|
||||||
|
if (rec.lockedUntil > now) {
|
||||||
|
return rec.lockedUntil - now;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed auth attempt. Locks the key out for
|
||||||
|
* `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
|
||||||
|
* within `authLockoutWindowMs`. Callers query the resulting lockout via
|
||||||
|
* getLockoutRemaining(), so this returns nothing.
|
||||||
|
*/
|
||||||
|
export function recordFailure(key: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let rec = records.get(key);
|
||||||
|
|
||||||
|
// Start a fresh window if none exists or the current one has expired.
|
||||||
|
if (!rec || now - rec.windowStart > config.authLockoutWindowMs) {
|
||||||
|
rec = { count: 0, windowStart: now, lockedUntil: 0 };
|
||||||
|
records.set(key, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.count += 1;
|
||||||
|
|
||||||
|
if (rec.count >= config.authLockoutMaxAttempts) {
|
||||||
|
rec.lockedUntil = now + config.authLockoutDurationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear any recorded failures for this key (call on successful auth).
|
||||||
|
*/
|
||||||
|
export function recordSuccess(key: string): void {
|
||||||
|
records.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically drop stale records so the map does not grow unbounded.
|
||||||
|
const cleanup = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, rec] of records) {
|
||||||
|
const windowExpired = now - rec.windowStart > config.authLockoutWindowMs;
|
||||||
|
const notLocked = rec.lockedUntil <= now;
|
||||||
|
if (windowExpired && notLocked) {
|
||||||
|
records.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, Math.max(60000, config.authLockoutWindowMs));
|
||||||
|
|
||||||
|
// Do not keep the process alive solely for this timer.
|
||||||
|
if (typeof cleanup.unref === 'function') {
|
||||||
|
cleanup.unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Unique ID generation utilities
|
||||||
|
|
||||||
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
|
||||||
|
// Largest multiple of 62 that fits in a byte (62 * 4 = 248). Bytes >= 248
|
||||||
|
// are rejected so every character is drawn with uniform probability
|
||||||
|
// (avoids modulo bias from `byte % 62`).
|
||||||
|
const REJECTION_THRESHOLD = Math.floor(256 / BASE62_CHARS.length) * BASE62_CHARS.length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random base62 string of the specified length.
|
||||||
|
* Uses cryptographically secure random bytes with rejection sampling
|
||||||
|
* to eliminate modulo bias.
|
||||||
|
*/
|
||||||
|
export function generateSessionId(length: number = 12): string {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
while (result.length < length) {
|
||||||
|
// Fetch exactly `length` random bytes per pass. Bytes at or above the
|
||||||
|
// rejection threshold are skipped to avoid modulo bias, so a pass may
|
||||||
|
// yield fewer than `length` characters; the outer while then runs another
|
||||||
|
// pass until we have enough. (No over-fetching — each pass draws exactly
|
||||||
|
// `length` bytes.)
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
for (let i = 0; i < bytes.length && result.length < length; i++) {
|
||||||
|
const byte = bytes[i];
|
||||||
|
if (byte >= REJECTION_THRESHOLD) {
|
||||||
|
continue; // reject to avoid bias
|
||||||
|
}
|
||||||
|
result += BASE62_CHARS[byte % BASE62_CHARS.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a UUID v4 for request IDs using the crypto module.
|
||||||
|
*/
|
||||||
|
export function generateRequestId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Logger utility using Winston
|
||||||
|
|
||||||
|
import winston from 'winston';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
level: config.logLevel,
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
logFormat
|
||||||
|
)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add file transport in production
|
||||||
|
if (!config.isDevelopment) {
|
||||||
|
logger.add(new winston.transports.File({
|
||||||
|
filename: 'error.log',
|
||||||
|
level: 'error'
|
||||||
|
}));
|
||||||
|
logger.add(new winston.transports.File({
|
||||||
|
filename: 'combined.log'
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
+4
-2
@@ -45,9 +45,11 @@ a = Analysis(
|
|||||||
'pyautogui',
|
'pyautogui',
|
||||||
'pyperclip',
|
'pyperclip',
|
||||||
'pystray',
|
'pystray',
|
||||||
'netifaces',
|
|
||||||
'websockets',
|
'websockets',
|
||||||
'multipart',
|
'multipart',
|
||||||
|
# Relay client (imported lazily in the GUI, so declare explicitly)
|
||||||
|
'relay_client',
|
||||||
|
'aiohttp',
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -78,5 +80,5 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon='Macro Pad.png',
|
icon='Macro Pad.ico',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ a = Analysis(
|
|||||||
'pyperclip',
|
'pyperclip',
|
||||||
'pystray',
|
'pystray',
|
||||||
'pystray._base',
|
'pystray._base',
|
||||||
'netifaces',
|
|
||||||
'websockets',
|
'websockets',
|
||||||
'multipart',
|
'multipart',
|
||||||
# Linux system tray
|
# Linux system tray
|
||||||
|
|||||||
+1
-2
@@ -45,7 +45,6 @@ a = Analysis(
|
|||||||
'pyautogui',
|
'pyautogui',
|
||||||
'pyperclip',
|
'pyperclip',
|
||||||
'pystray',
|
'pystray',
|
||||||
'netifaces',
|
|
||||||
'websockets',
|
'websockets',
|
||||||
'multipart',
|
'multipart',
|
||||||
],
|
],
|
||||||
@@ -86,7 +85,7 @@ app = BUNDLE(
|
|||||||
icon='Macro Pad.png',
|
icon='Macro Pad.png',
|
||||||
bundle_identifier='com.macropad.server',
|
bundle_identifier='com.macropad.server',
|
||||||
info_plist={
|
info_plist={
|
||||||
'CFBundleShortVersionString': '0.9.0',
|
'CFBundleShortVersionString': '0.9.2',
|
||||||
'CFBundleName': 'MacroPad Server',
|
'CFBundleName': 'MacroPad Server',
|
||||||
'NSHighResolutionCapable': True,
|
'NSHighResolutionCapable': True,
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "macropad-server"
|
name = "macropad-server"
|
||||||
version = "0.9.0"
|
version = "0.9.5"
|
||||||
description = "A cross-platform macro management application with desktop and web interfaces"
|
description = "A cross-platform macro management application with desktop and web interfaces"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -23,12 +23,12 @@ dependencies = [
|
|||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"python-multipart>=0.0.6", # For file uploads
|
"python-multipart>=0.0.6", # For file uploads
|
||||||
# Network utilities
|
|
||||||
"netifaces>=0.11.0",
|
|
||||||
# QR code generation
|
# QR code generation
|
||||||
"qrcode>=7.4.2",
|
"qrcode>=7.4.2",
|
||||||
# Desktop GUI
|
# Desktop GUI
|
||||||
"PySide6>=6.6.0",
|
"PySide6>=6.6.0",
|
||||||
|
# Relay client
|
||||||
|
"aiohttp>=3.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# Relay Client for MacroPad Server
|
||||||
|
# Connects to relay server and forwards API requests to local server
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional, Callable
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
class RelayClient:
|
||||||
|
"""WebSocket client that connects to relay server and proxies requests."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
relay_url: str,
|
||||||
|
password: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
local_port: int = 40000,
|
||||||
|
on_connected: Optional[Callable] = None,
|
||||||
|
on_disconnected: Optional[Callable] = None,
|
||||||
|
on_session_id: Optional[Callable[[str], None]] = None,
|
||||||
|
local_auth_token: str = ""
|
||||||
|
):
|
||||||
|
self.relay_url = relay_url.rstrip('/')
|
||||||
|
if not self.relay_url.endswith('/desktop'):
|
||||||
|
self.relay_url += '/desktop'
|
||||||
|
self.password = password
|
||||||
|
self.session_id = session_id
|
||||||
|
self.local_url = f"http://localhost:{local_port}"
|
||||||
|
self.local_auth_token = local_auth_token
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
self.on_connected = on_connected
|
||||||
|
self.on_disconnected = on_disconnected
|
||||||
|
self.on_session_id = on_session_id
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._ws = None
|
||||||
|
self._session = None
|
||||||
|
self._running = False
|
||||||
|
self._connected = False
|
||||||
|
self._thread = None
|
||||||
|
self._loop = None
|
||||||
|
self._reconnect_delay = 1
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the relay client in a background thread."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run_async_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the relay client."""
|
||||||
|
self._running = False
|
||||||
|
if self._loop and self._loop.is_running():
|
||||||
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Check if connected to relay server."""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def _run_async_loop(self):
|
||||||
|
"""Run the asyncio event loop in the background thread."""
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._loop.run_until_complete(self._connection_loop())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Relay client error: {e}")
|
||||||
|
finally:
|
||||||
|
self._loop.close()
|
||||||
|
|
||||||
|
async def _connection_loop(self):
|
||||||
|
"""Main connection loop with reconnection logic."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._connect_and_run()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Relay connection error: {e}")
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
# Exponential backoff for reconnection
|
||||||
|
await asyncio.sleep(self._reconnect_delay)
|
||||||
|
self._reconnect_delay = min(self._reconnect_delay * 2, 30)
|
||||||
|
|
||||||
|
async def _connect_and_run(self):
|
||||||
|
"""Connect to relay server and handle messages."""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
self._session = session
|
||||||
|
async with session.ws_connect(self.relay_url) as ws:
|
||||||
|
self._ws = ws
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
if not await self._authenticate():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._connected = True
|
||||||
|
self._reconnect_delay = 1 # Reset backoff on successful connect
|
||||||
|
|
||||||
|
if self.on_connected:
|
||||||
|
self.on_connected()
|
||||||
|
|
||||||
|
# Message handling loop
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await self._handle_message(json.loads(msg.data))
|
||||||
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
|
print(f"WebSocket error: {ws.exception()}")
|
||||||
|
break
|
||||||
|
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||||
|
break
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
print(f"Relay connection failed: {e}")
|
||||||
|
finally:
|
||||||
|
self._connected = False
|
||||||
|
self._ws = None
|
||||||
|
self._session = None
|
||||||
|
if self.on_disconnected:
|
||||||
|
self.on_disconnected()
|
||||||
|
|
||||||
|
async def _authenticate(self) -> bool:
|
||||||
|
"""Authenticate with the relay server."""
|
||||||
|
auth_msg = {
|
||||||
|
"type": "auth",
|
||||||
|
"sessionId": self.session_id,
|
||||||
|
"password": self.password
|
||||||
|
}
|
||||||
|
await self._ws.send_json(auth_msg)
|
||||||
|
|
||||||
|
# Wait for auth response
|
||||||
|
response = await self._ws.receive_json()
|
||||||
|
|
||||||
|
if response.get("type") == "auth_response":
|
||||||
|
if response.get("success"):
|
||||||
|
# Mark as connected before callbacks so update_ip_label works
|
||||||
|
self._connected = True
|
||||||
|
new_session_id = response.get("sessionId")
|
||||||
|
# Always update session_id and trigger callback to ensure URL updates
|
||||||
|
if new_session_id:
|
||||||
|
self.session_id = new_session_id
|
||||||
|
if self.on_session_id:
|
||||||
|
self.on_session_id(new_session_id)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Authentication failed: {response.get('error', 'Unknown error')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _handle_message(self, msg: dict):
|
||||||
|
"""Handle a message from the relay server."""
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
|
||||||
|
if msg_type == "api_request":
|
||||||
|
await self._handle_api_request(msg)
|
||||||
|
elif msg_type == "ws_message":
|
||||||
|
# Forward WebSocket message from web client
|
||||||
|
await self._handle_ws_message(msg)
|
||||||
|
elif msg_type == "ping":
|
||||||
|
await self._ws.send_json({"type": "pong"})
|
||||||
|
|
||||||
|
async def _handle_api_request(self, msg: dict):
|
||||||
|
"""Forward API request to local server and send response back."""
|
||||||
|
request_id = msg.get("requestId")
|
||||||
|
method = msg.get("method", "GET").upper()
|
||||||
|
path = msg.get("path", "/")
|
||||||
|
body = msg.get("body")
|
||||||
|
|
||||||
|
# Restrict the relay to API routes only. This prevents the relay from
|
||||||
|
# reaching static/other paths on the local server.
|
||||||
|
if not path.startswith("/api/"):
|
||||||
|
await self._ws.send_json({
|
||||||
|
"type": "api_response",
|
||||||
|
"requestId": request_id,
|
||||||
|
"status": 403,
|
||||||
|
"body": {"error": "forbidden path"}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"{self.local_url}{path}"
|
||||||
|
|
||||||
|
# Build a fresh headers dict. We do NOT forward attacker-supplied
|
||||||
|
# headers from the relay; we inject the local auth token ourselves.
|
||||||
|
sending_body = bool(body) and method in ("POST", "PUT", "PATCH")
|
||||||
|
headers = {"X-MacroPad-Token": self.local_auth_token}
|
||||||
|
if sending_body:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Forward request to local server
|
||||||
|
async with self._session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
json=body if sending_body else None,
|
||||||
|
headers=headers
|
||||||
|
) as response:
|
||||||
|
# Handle binary responses (images)
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
# Base64 encode binary data
|
||||||
|
import base64
|
||||||
|
data = await response.read()
|
||||||
|
response_body = {
|
||||||
|
"base64": base64.b64encode(data).decode("utf-8"),
|
||||||
|
"contentType": content_type
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
response_body = await response.json()
|
||||||
|
except:
|
||||||
|
response_body = {"text": await response.text()}
|
||||||
|
|
||||||
|
await self._ws.send_json({
|
||||||
|
"type": "api_response",
|
||||||
|
"requestId": request_id,
|
||||||
|
"status": response.status,
|
||||||
|
"body": response_body
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self._ws.send_json({
|
||||||
|
"type": "api_response",
|
||||||
|
"requestId": request_id,
|
||||||
|
"status": 500,
|
||||||
|
"body": {"error": str(e)}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _handle_ws_message(self, msg: dict):
|
||||||
|
"""Handle WebSocket message from web client."""
|
||||||
|
data = msg.get("data", {})
|
||||||
|
# For now, we don't need to forward messages from web clients
|
||||||
|
# to the local server because the local server broadcasts changes
|
||||||
|
# The relay will handle broadcasting back to web clients
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def broadcast(self, data: dict):
|
||||||
|
"""Broadcast a message to all connected web clients via relay."""
|
||||||
|
if self._ws and self._connected:
|
||||||
|
await self._ws.send_json({
|
||||||
|
"type": "ws_broadcast",
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.9.0
|
1.1.1
|
||||||
+694
-389
File diff suppressed because it is too large
Load Diff
+49
-11
@@ -2,8 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#007acc">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'">
|
||||||
|
<meta name="theme-color" content="#15161a" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
|
||||||
<meta name="description" content="Remote macro control for your desktop">
|
<meta name="description" content="Remote macro control for your desktop">
|
||||||
|
|
||||||
<!-- PWA / iOS specific -->
|
<!-- PWA / iOS specific -->
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="MacroPad">
|
<meta name="apple-mobile-web-app-title" content="MacroPad">
|
||||||
<meta name="application-name" content="MacroPad">
|
<meta name="application-name" content="MacroPad">
|
||||||
<meta name="msapplication-TileColor" content="#007acc">
|
<meta name="msapplication-TileColor" content="#7c5cff">
|
||||||
<meta name="msapplication-tap-highlight" content="no">
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
|
|
||||||
<title>MacroPad</title>
|
<title>MacroPad</title>
|
||||||
@@ -29,20 +31,20 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>MacroPad</h1>
|
<h1>MacroPad</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="connection-status">
|
<div class="connection-status" id="connection-status" role="status" aria-live="polite">
|
||||||
<div class="status-dot"></div>
|
<div class="status-dot"></div>
|
||||||
<span>Disconnected</span>
|
<span>Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
|
<button type="button" class="wake-lock-status" id="wake-lock-status" title="Screen wake lock" aria-label="Toggle screen wake lock">
|
||||||
<span class="wake-icon">☀</span>
|
<span class="wake-icon" aria-hidden="true">☀</span>
|
||||||
</div>
|
</button>
|
||||||
<button class="header-btn icon-btn" id="fullscreen-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen">⛶</button>
|
<button type="button" class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen" aria-label="Toggle fullscreen">⛶</button>
|
||||||
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
|
<button type="button" class="header-btn secondary" id="refresh-btn">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<nav class="tabs" id="tabs-container">
|
<nav class="tabs" id="tabs-container" role="tablist" aria-label="Macro categories">
|
||||||
<!-- Tabs rendered dynamically -->
|
<!-- Tabs rendered dynamically -->
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -53,8 +55,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Floating action button: create new macro -->
|
||||||
|
<button type="button" class="fab" id="new-macro-btn" aria-label="New macro">+</button>
|
||||||
|
|
||||||
|
<!-- Macro create/edit modal -->
|
||||||
|
<div class="modal" id="macro-modal" role="dialog" aria-modal="true" aria-labelledby="macro-modal-title" hidden>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="macro-modal-title">New Macro</h2>
|
||||||
|
<button type="button" class="modal-close" id="macro-modal-close" aria-label="Close dialog">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="macro-name-input">Name</label>
|
||||||
|
<input type="text" id="macro-name-input" autocomplete="off" placeholder="Macro name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="macro-category-input">Category</label>
|
||||||
|
<input type="text" id="macro-category-input" autocomplete="off" list="macro-category-list" placeholder="Category (optional)">
|
||||||
|
<datalist id="macro-category-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Commands</label>
|
||||||
|
<div class="command-list" id="command-list"></div>
|
||||||
|
<div class="add-command-btns">
|
||||||
|
<button type="button" class="add-command-btn" id="add-command-btn">+ Add step</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-danger" id="delete-macro-btn" hidden>Delete</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-macro-btn">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-macro-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+828
-64
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -6,9 +6,10 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#2e2e2e",
|
"background_color": "#15161a",
|
||||||
"theme_color": "#007acc",
|
"theme_color": "#7c5cff",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
|
"//": "Maskable icons ideally need ~10% transparent padding so they aren't cropped inside the platform safe zone; the current PNGs are reused as-is without new art.",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/static/icons/icon-192.png",
|
"src": "/static/icons/icon-192.png",
|
||||||
|
|||||||
+49
-31
@@ -1,5 +1,5 @@
|
|||||||
// MacroPad PWA Service Worker
|
// MacroPad PWA Service Worker
|
||||||
const CACHE_NAME = 'macropad-v3';
|
const CACHE_NAME = 'macropad-v4';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/static/css/styles.css',
|
'/static/css/styles.css',
|
||||||
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
|
|||||||
'/manifest.json'
|
'/manifest.json'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache assets
|
// Install event - pre-cache the app shell
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME)
|
||||||
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch event - serve from cache, fallback to network
|
// Network-first for navigations + app code so deployed fixes reach clients.
|
||||||
self.addEventListener('fetch', (event) => {
|
async function networkFirst(request) {
|
||||||
const url = new URL(event.request.url);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (err) {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
// Offline fallback for navigations: serve the cached shell.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const shell = await cache.match('/');
|
||||||
|
if (shell) {
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response('Offline', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Offline',
|
||||||
|
headers: { 'Content-Type': 'text/plain' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always fetch API requests from network
|
self.addEventListener('fetch', (event) => {
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
|
const request = event.request;
|
||||||
event.respondWith(fetch(event.request));
|
|
||||||
|
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
|
||||||
|
if (request.method !== 'GET') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other requests, try cache first, then network
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// API requests always go to the network; never serve stale data.
|
||||||
|
// Return a synthetic offline JSON response instead of an unhandled undefined.
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request)
|
fetch(request).catch(() => new Response(
|
||||||
.then((response) => {
|
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
|
||||||
if (response) {
|
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
||||||
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('/');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigations and static app assets: network-first with cache fallback.
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
});
|
});
|
||||||
|
|||||||
+141
-24
@@ -1,19 +1,35 @@
|
|||||||
# FastAPI web server for MacroPad
|
# FastAPI web server for MacroPad
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
|
import hmac
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from PIL import Image
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from config import DEFAULT_PORT, VERSION
|
from config import DEFAULT_PORT, VERSION
|
||||||
|
|
||||||
|
# Max accepted upload size for images (bytes).
|
||||||
|
MAX_IMAGE_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
# Map Pillow image formats to safe file extensions we're willing to persist.
|
||||||
|
_IMAGE_FORMAT_EXT = {
|
||||||
|
"PNG": ".png",
|
||||||
|
"JPEG": ".jpg",
|
||||||
|
"GIF": ".gif",
|
||||||
|
"BMP": ".bmp",
|
||||||
|
"WEBP": ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(relative_path):
|
def get_resource_path(relative_path):
|
||||||
"""Get the path to a bundled resource file."""
|
"""Get the path to a bundled resource file."""
|
||||||
@@ -72,21 +88,28 @@ class ConnectionManager:
|
|||||||
self.active_connections.remove(websocket)
|
self.active_connections.remove(websocket)
|
||||||
|
|
||||||
async def broadcast(self, message: dict):
|
async def broadcast(self, message: dict):
|
||||||
"""Send message to all connected clients."""
|
"""Send message to all connected clients, pruning dead sockets."""
|
||||||
for connection in self.active_connections:
|
dead = []
|
||||||
|
# Iterate over a snapshot so the list isn't mutated during iteration.
|
||||||
|
for connection in list(self.active_connections):
|
||||||
try:
|
try:
|
||||||
await connection.send_json(message)
|
await connection.send_json(message)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
dead.append(connection)
|
||||||
|
for connection in dead:
|
||||||
|
self.disconnect(connection)
|
||||||
|
|
||||||
|
|
||||||
class WebServer:
|
class WebServer:
|
||||||
"""FastAPI-based web server for MacroPad."""
|
"""FastAPI-based web server for MacroPad."""
|
||||||
|
|
||||||
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT):
|
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT,
|
||||||
|
auth_token: str = "", host: str = "127.0.0.1"):
|
||||||
self.macro_manager = macro_manager
|
self.macro_manager = macro_manager
|
||||||
self.app_dir = app_dir
|
self.app_dir = app_dir
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.auth_token = auth_token
|
||||||
|
self.host = host
|
||||||
self.app = None
|
self.app = None
|
||||||
self.manager = ConnectionManager()
|
self.manager = ConnectionManager()
|
||||||
self.server = None
|
self.server = None
|
||||||
@@ -114,6 +137,36 @@ class WebServer:
|
|||||||
if os.path.exists(images_dir):
|
if os.path.exists(images_dir):
|
||||||
app.mount("/images", StaticFiles(directory=images_dir), name="images")
|
app.mount("/images", StaticFiles(directory=images_dir), name="images")
|
||||||
|
|
||||||
|
# Paths served without a token (PWA shell + static assets)
|
||||||
|
public_paths = {"/", "/manifest.json", "/service-worker.js"}
|
||||||
|
|
||||||
|
def _add_security_headers(response):
|
||||||
|
# Anti-clickjacking / MIME-sniffing. frame-ancestors can only be set
|
||||||
|
# via a real response header (it is ignored inside a <meta> CSP), so
|
||||||
|
# we enforce it here rather than in index.html.
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
"""Require a valid auth token for all non-public paths."""
|
||||||
|
path = request.url.path
|
||||||
|
if path in public_paths or path.startswith("/static/"):
|
||||||
|
return _add_security_headers(await call_next(request))
|
||||||
|
|
||||||
|
# Fail-open only when no token is configured (shouldn't happen in prod)
|
||||||
|
if self.auth_token:
|
||||||
|
provided = request.headers.get("X-MacroPad-Token") \
|
||||||
|
or request.query_params.get("token") or ""
|
||||||
|
if not hmac.compare_digest(provided, self.auth_token):
|
||||||
|
return _add_security_headers(
|
||||||
|
JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _add_security_headers(await call_next(request))
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index():
|
async def index():
|
||||||
"""Serve the main PWA page."""
|
"""Serve the main PWA page."""
|
||||||
@@ -167,7 +220,12 @@ class WebServer:
|
|||||||
@app.post("/api/execute")
|
@app.post("/api/execute")
|
||||||
async def execute_macro(request: ExecuteRequest):
|
async def execute_macro(request: ExecuteRequest):
|
||||||
"""Execute a macro by ID."""
|
"""Execute a macro by ID."""
|
||||||
success = self.macro_manager.execute_macro(request.macro_id)
|
# execute_macro is synchronous and may sleep (wait steps); run it in
|
||||||
|
# a thread executor so it doesn't block the event loop / other traffic.
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
success = await loop.run_in_executor(
|
||||||
|
None, self.macro_manager.execute_macro, request.macro_id
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
# Broadcast execution to all connected clients
|
# Broadcast execution to all connected clients
|
||||||
await self.manager.broadcast({
|
await self.manager.broadcast({
|
||||||
@@ -216,38 +274,97 @@ class WebServer:
|
|||||||
|
|
||||||
@app.post("/api/upload-image")
|
@app.post("/api/upload-image")
|
||||||
async def upload_image(file: UploadFile = File(...)):
|
async def upload_image(file: UploadFile = File(...)):
|
||||||
"""Upload an image for a macro."""
|
"""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
|
Hardened: enforces a size cap, validates the bytes are a real image
|
||||||
import tempfile
|
with Pillow (content_type is untrusted), and saves into the app's
|
||||||
import shutil
|
macro_images/ directory under a server-generated uuid name. Returns
|
||||||
|
only a relative reference, never an absolute server path.
|
||||||
|
"""
|
||||||
|
# Read with a hard size cap (reject oversize before buffering it all).
|
||||||
|
data = b""
|
||||||
|
chunk_size = 64 * 1024
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if len(data) > MAX_IMAGE_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Image too large")
|
||||||
|
|
||||||
ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
|
if not data:
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
raise HTTPException(status_code=400, detail="Empty upload")
|
||||||
shutil.copyfileobj(file.file, tmp)
|
|
||||||
return {"path": tmp.name}
|
# Validate it's really an image (don't trust content_type).
|
||||||
|
try:
|
||||||
|
Image.open(io.BytesIO(data)).verify()
|
||||||
|
# verify() leaves the image unusable; reopen to read the format.
|
||||||
|
img_format = (Image.open(io.BytesIO(data)).format or "").upper()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="File is not a valid image")
|
||||||
|
|
||||||
|
ext = _IMAGE_FORMAT_EXT.get(img_format)
|
||||||
|
if not ext:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported image format")
|
||||||
|
|
||||||
|
images_dir = os.path.join(self.app_dir, "macro_images")
|
||||||
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
dest_path = os.path.join(images_dir, filename)
|
||||||
|
rel_path = os.path.join("macro_images", filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(dest_path, "wb") as out:
|
||||||
|
out.write(data)
|
||||||
|
except Exception:
|
||||||
|
# Clean up a partial file on failure.
|
||||||
|
try:
|
||||||
|
if os.path.exists(dest_path):
|
||||||
|
os.remove(dest_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save image")
|
||||||
|
|
||||||
|
# Return only a relative, opaque reference.
|
||||||
|
return {"path": rel_path.replace(os.sep, "/")}
|
||||||
|
|
||||||
@app.get("/api/image/{image_path:path}")
|
@app.get("/api/image/{image_path:path}")
|
||||||
async def get_image(image_path: str):
|
async def get_image(image_path: str):
|
||||||
"""Get macro image (legacy compatibility)."""
|
"""Get macro image (legacy compatibility, path-traversal safe)."""
|
||||||
full_path = os.path.join(self.app_dir, image_path)
|
images_dir = os.path.join(self.app_dir, "macro_images")
|
||||||
if os.path.exists(full_path):
|
requested = os.path.realpath(os.path.join(self.app_dir, image_path))
|
||||||
return FileResponse(full_path)
|
images_real = os.path.realpath(images_dir)
|
||||||
|
# Only serve files that resolve to inside the macro_images directory
|
||||||
|
if (os.path.commonpath([requested, images_real]) == images_real
|
||||||
|
and os.path.isfile(requested)):
|
||||||
|
return FileResponse(requested)
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
"""WebSocket for real-time updates."""
|
"""WebSocket for real-time updates."""
|
||||||
|
# Require a valid token before accepting the connection
|
||||||
|
if self.auth_token:
|
||||||
|
provided = websocket.query_params.get("token") or ""
|
||||||
|
if not hmac.compare_digest(provided, self.auth_token):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
await self.manager.connect(websocket)
|
await self.manager.connect(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
# receive_json raises on malformed input / non-JSON frames;
|
||||||
|
# any such error must still tear the connection down cleanly.
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
# Handle incoming messages if needed
|
# Handle incoming messages if needed
|
||||||
if data.get("type") == "ping":
|
if isinstance(data, dict) and data.get("type") == "ping":
|
||||||
await websocket.send_json({"type": "pong"})
|
await websocket.send_json({"type": "pong"})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
# Malformed input or any other error: don't leak the connection.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
self.manager.disconnect(websocket)
|
self.manager.disconnect(websocket)
|
||||||
|
|
||||||
self.app = app
|
self.app = app
|
||||||
@@ -260,7 +377,7 @@ class WebServer:
|
|||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
self.app,
|
self.app,
|
||||||
host="0.0.0.0",
|
host=self.host,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
log_config=None # Disable default logging config for PyInstaller compatibility
|
log_config=None # Disable default logging config for PyInstaller compatibility
|
||||||
@@ -280,7 +397,7 @@ class WebServer:
|
|||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
self.app,
|
self.app,
|
||||||
host="0.0.0.0",
|
host=self.host,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
log_config=None # Disable default logging config for PyInstaller compatibility
|
log_config=None # Disable default logging config for PyInstaller compatibility
|
||||||
|
|||||||
Reference in New Issue
Block a user