25 Commits

Author SHA1 Message Date
jknapp 62559a59c9 Merge pull request 'fix: app-launch macros on Windows + bump to 1.1.1' (#10) from fix/app-launch-windows into main
Build Windows / preflight (push) Successful in 1s
Build Windows / build-windows (push) Successful in 5m1s
2026-07-18 05:59:35 +00:00
shadowdao e0df32f42b fix: app-launch macros on Windows (shlex quote handling), bump to 1.1.1
When shell=True was removed for security, the command was parsed with
shlex.split(posix=False) on Windows, which keeps the quote characters inside
the tokens — so a quoted path like "C:\Program Files\app.exe" became an argv[0]
containing literal quotes and CreateProcess couldn't find it, so app macros
silently did nothing.

Fix: on Windows pass the command string to Popen (shell=False) and let
CreateProcess parse it (handles quoted paths, still no shell/metacharacter
chaining); on POSIX keep shlex.split. Verified a real launch works and shell
redirection stays blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:59:17 -07:00
jknapp d409b64efa Merge pull request 'fix: drop netifaces (unbuildable), harden build install' (#9) from fix/netifaces-build into main
Build Windows / preflight (push) Successful in 1s
Build Windows / build-windows (push) Successful in 5m47s
2026-07-18 05:40:51 +00:00
shadowdao 2f855bf720 fix: drop netifaces (unbuildable on runner), harden build install
The packaged exe crashed with ModuleNotFoundError: No module named 'PySide6'.
Root cause: `pip install -e .` aborted because netifaces has no wheel for the
build's Python and needs MSVC to compile from source, so NO dependencies were
installed — and the workflow didn't catch it (native-command failures don't
trip $ErrorActionPreference, and the next pip command succeeded).

- Replace netifaces with a dependency-free socket-based LAN IP detection in the
  GUI; remove netifaces from pyproject and all PyInstaller specs.
- Make pip failures fatal (check $LASTEXITCODE) and add a "Verify runtime
  imports" step that fails the build before bundling if any dep is missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:40:34 -07:00
jknapp 95552ca7c9 Merge pull request 'chore: remove dead release.yml, bump to 1.1.0' (#8) from chore/release-1.1.0 into main
Build Windows / preflight (push) Successful in 1s
Build Windows / build-windows (push) Successful in 1m12s
2026-07-18 04:55:10 +00:00
shadowdao 2211948e9c chore: remove dead release.yml, bump version to 1.1.0
- Delete the fully-commented, GitHub-hosted release.yml (superseded by the
  self-hosted build-windows.yml).
- Bump VERSION/version.txt to 1.1.0 for the release covering the security
  hardening, web macro editor, media keys, and UI redesign.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:54:55 -07:00
jknapp 8c3e5106d5 Merge pull request 'CI: working Windows build workflow for self-hosted runner' (#7) from ci/windows-build into main 2026-07-18 04:08:27 +00:00
jknapp e9b3108226 Merge pull request 'Security hardening (P0 RCE + audit follow-through), web macro editor, and redesign' (#6) from security/p0-rce-hardening into main 2026-07-18 04:08:24 +00:00
shadowdao 1f8ecf6764 ci: use upload-artifact@v3 (Gitea artifact backend compatibility)
upload-artifact@v4 uses the @actions/artifact v2 backend, which Gitea (reported
to the action as GHES) does not support. Pin to @v3, which uses the artifact
protocol Gitea implements. The exe itself now builds successfully; this was the
only remaining failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:47:40 -07:00
shadowdao 0d0786e222 ci: provision Python via nuget (idempotent, no registry side effects)
The per-user MSI installer is stateful across runs on a persistent VM: once a
version is registered, a later /quiet install no-ops and never lands at the new
TargetDir, so python.exe went missing. Switch to a standalone CPython from
nuget cached under LOCALAPPDATA — no registry/PATH changes, idempotent, reused
across runs. A pre-existing Python 3.11 on PATH is still honoured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:44:47 -07:00
shadowdao fc01f8e995 ci: ship Windows .ico icon for PyInstaller build
PyInstaller on Windows requires an .ico (or .exe) for the executable icon and
its PNG->ICO auto-conversion did not engage on the runner. Add a multi-size
Macro Pad.ico (16-256px) generated from Macro Pad.png and point macropad.spec
at it. The PNG stays bundled for the runtime window/tray icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:40:06 -07:00
shadowdao d82ee25916 ci: use Windows PowerShell, add preflight job, harden for self-hosted
- The runner has Windows PowerShell 5.1, not pwsh/PowerShell 7 — switch all
  steps from `shell: pwsh` to `shell: powershell`.
- Make the scripts 5.1-safe: force TLS 1.2 for HTTPS, -UseBasicParsing on
  Invoke-WebRequest, and write GITHUB_ENV as ascii (no BOM).
- Add a fast `preflight` job that validates the runner (OS, PowerShell, Python
  availability) in seconds; build-windows now `needs: [preflight]`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:33:54 -07:00
shadowdao 9f06b45322 ci: make Windows build robust on self-hosted runner
actions/setup-python@v5 hangs inside its Windows tool-cache install script on
the self-hosted runner. Replace it with an "Ensure Python 3.11" step that uses
an existing py -3.11 / PATH python if present, otherwise silently installs
Python per-user (no elevation, cannot prompt). Invoke pip/PyInstaller via the
resolved interpreter path, and add a 30-minute job timeout so a hang can no
longer run indefinitely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:53:39 -07:00
shadowdao 5c6c72d928 ci: add Windows-only build workflow for self-hosted runner
Adds .gitea/workflows/build-windows.yml targeting the org's self-hosted
`windows-latest` runner: sets up Python 3.11, installs the project +
PyInstaller, builds dist/macropad.exe, uploads it as an artifact, and on a
v* tag creates/updates a Gitea release and attaches the exe via the Gitea
API. Runs entirely on Windows (no Linux runner required).

Also declare relay_client + aiohttp as PyInstaller hidden imports so the
relay feature (lazily imported in the GUI) is bundled into the exe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:05:54 -07:00
shadowdao b5e5bed7dd security(relay): minor hardening follow-ups from PR review
- Uniform auth responses remove the session-enumeration oracle: unknown
  session and wrong password now return an identical 401 at the API layer and
  an identical WS handshake/close; desktop-status and the 503 "not connected"
  signal are only exposed after successful auth.
- Session-count cap re-checked after bcrypt.hash so concurrent creates can't
  overshoot maxSessions.
- Relay web client reconnect backoff resets only after a successful auth
  response (an accept-then-close server no longer defeats the backoff).
- idGenerator comment corrected to match the rejection-sampling; drop unused
  recordFailure return value.
- DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:13:33 -07:00
shadowdao 74bd3c2e98 security(web): remove creds from image URLs, auth-fail state, tighten CSP
Minor PR-review follow-ups for the local PWA:
- Macro images load via a header-authenticated fetch -> blob object URL in both
  token and relay modes, so no credential ever sits in an image URL; prior
  object URLs are revoked on re-render.
- Local-mode auth failure now shows a clear "Access token required" state and
  stops the reconnect/toast loop (one-shot), instead of retrying forever.
- CSP connect-src tightened from 'self' ws: wss: to 'self' (same-origin WS is
  covered); verified the live WebSocket still connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:12:23 -07:00
shadowdao 60c3bbcedb docs: clarify relay web client is execute-only
Remote (relay) devices browse and trigger macros; macro authoring is
intentionally kept to the local network so what runs on the machine isn't
editable from the public internet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:09:32 -07:00
shadowdao d07005bde3 security(relay): fix lockout DoS and DOM XSS in web client
Addresses two Major findings from PR review:

- Auth lockout no longer griefable: the throttle key is namespaced per path
  and includes the client IP (web:<session>:<ip> vs desktop:<session>:<ip>),
  so a flood of bad web-client auths can neither lock other clients of the
  same session nor block the desktop from (re)authenticating. Per-socket
  failure close and brute-force lockout are preserved.
- Relay web client XSS eliminated: app.html tab/macro rendering rebuilt with
  createElement/textContent (no macro/tab value reaches innerHTML); inline
  scripts/handlers externalized to /static/app.js and /static/login.js so the
  helmet CSP now uses script-src 'self' (dropped 'unsafe-inline' for scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:07:48 -07:00
shadowdao 0d6f48ca24 docs: document auth token, no-shell app commands, web editor, media keys
Update README for the new security model and features: local API access
token embedded in the URL/QR (and web.allow_lan), app commands executing
without a shell, relay password in OS keyring, web-based macro creation,
and media/system keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:39:16 -07:00
shadowdao 5896ec0893 security(web): enforce anti-clickjacking via response headers
frame-ancestors is ignored inside a <meta> CSP, so move clickjacking
protection to real response headers (X-Frame-Options: DENY) and add
X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on all
responses. Drop the ineffective frame-ancestors token from the meta CSP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:37:25 -07:00
shadowdao 01163891ec design(web): "Tactile Deck" redesign with light/dark theming
- Full semantic design-token layer (surfaces, spacing scale, radii, elevation,
  type scale) as the single source of truth; electric-violet signature accent
  instead of default blue.
- Light + dark themes via prefers-color-scheme; muted text now meets WCAG AA
  (replaces low-contrast #aaa/#888).
- Macro cards read as physical keys (raised, hairline highlight, per-macro
  --macro-color wash + placeholder, press/executing feedback, 2-line clamp).
- Proper modal sheet (blurred scrim, sticky header/footer, focus rings),
  thumb-reachable floating action button, 44px tap targets, app-wide
  :focus-visible, and prefers-reduced-motion support.
- manifest + theme-color meta updated to the new palette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:32:04 -07:00
shadowdao beba425868 security+robustness(py/gui): thread-safety, upload hardening, keyring
macro_manager:
- All macro reads/writes guarded by a lock; macros.json written atomically
  (temp + fsync + os.replace). Commands are copied under the lock and executed
  outside it so wait/keyboard steps don't block other threads.
- last_used disk writes debounced (>=5s) instead of a full rewrite per press.
- Validate key/hotkey names against pyautogui.KEYBOARD_KEYS and clamp wait ms;
  invalid input is skipped/logged, not blindly executed.

web_server:
- WebSocket receive loop always disconnects (fixes dead-socket leak); broadcast
  prunes failed sockets.
- POST /api/execute offloaded to a thread executor so a long macro no longer
  blocks the event loop.
- Image upload hardened: 5MB cap (413), real-image validation via Pillow,
  server-generated name under macro_images/, returns a relative path only,
  cleans up on failure.

gui:
- Execute macros off the Qt main thread (signal back to status bar); debounce
  resizeEvent and only rebuild when the column count changes.
- Relay password stored in the OS keyring when available (plaintext JSON blanked;
  graceful fallback if no backend); delete dialogs name the macro; honor the
  minimize-to-tray setting; surface save failures.
- Add media/system keys (volume, play/pause, track nav) to the macro editor.
- THEME gains shared danger/accent-hover colors; drop dead imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:26:22 -07:00
shadowdao 435cc1aca9 security+feat(web): kill DOM XSS, add CSP, in-browser macro editor
Security:
- Eliminate DOM-based XSS: renderMacros/renderTabs rebuilt with
  createElement/textContent/setAttribute + addEventListener. No macro name,
  id, image_path, or category ever reaches innerHTML.
- Remove all inline on*= handlers; add a strict Content-Security-Policy meta
  (script-src 'self', object-src 'none', base-uri 'none', frame-ancestors
  'none').
- Guard JSON.parse on WebSocket frames.

Robustness:
- Service worker: network-first for the app shell + JS/CSS so fixes actually
  ship; synthetic 503 for offline API calls; drop no-op /ws intercept; bump
  cache version.
- WebSocket reconnect: exponential backoff (1s→30s), tear down old socket
  before reconnect, stop on pagehide/beforeunload.
- Tab-switch race fixed with a request-id guard; executing state cleared in
  finally.

Accessibility:
- Macro cards and tabs are real buttons with aria-labels/roles; toast +
  connection status are aria-live; removed user-scalable=no.

Feature — web-based macro creation/editing:
- Floating "+" button + modal command-sequence builder (text/key/hotkey/
  wait/app) with reorder/remove; create via POST, edit via PUT (prefilled
  from GET /api/macro/{id}), delete with a named confirm. Works in both local
  and relay auth modes. Empty state now offers "Create your first macro".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:24:14 -07:00
shadowdao eba5a2a38e security(relay): harden internet-facing relay server
Closes the account-takeover chain and related issues found in audit:

- WS auth brute-force protection: per-session lockout (authThrottle) + per-
  socket failure cap that closes the socket (1008); applied to both web-client
  and desktop auth. Failed auth no longer leaves the socket open for unlimited
  guesses.
- WS upgrades now pass an IP-based rate limiter and Origin allowlist before
  handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so
  limiting keys on the real client IP.
- /health no longer leaks live session IDs (counts only).
- .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps.
- Credentials moved out of URLs into the X-MacroPad-Password header; images
  fetched via header + blob URLs; login stores creds in sessionStorage.
- Session creation bounded (max sessions, min password length) and TTL-pruned;
  session store writes are now atomic (temp+rename) and debounced.
- Session IDs lengthened to 12 chars with rejection sampling (no modulo bias).
- Enable helmet CSP; restrict CORS to configured origins.
- Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep.
- Uniform response for unknown session IDs (removes enumeration oracle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:15:23 -07:00
shadowdao 1f26427328 security(P0): close unauthenticated RCE chain
The local FastAPI server previously exposed macro create/execute over the
network with no authentication while bound to 0.0.0.0, and macro `app`
commands ran via subprocess with shell=True — an unauthenticated RCE for
anyone on the LAN (and, via the relay, the internet).

- Mandatory per-install auth token (secrets.token_urlsafe) required on all
  /api/* routes and the /ws handshake; timing-safe hmac.compare_digest.
  PWA shell + /static assets remain public to bootstrap.
- Token auto-generated and persisted in settings.json (now chmod 0600).
- Bind host is configurable (web.allow_lan); token is the security boundary.
- macro `app` launch no longer uses shell=True — commands are tokenized with
  shlex and exec'd directly, killing ;/&&/|/$() shell chaining.
- Relay client injects the local token and only forwards /api/* paths, and no
  longer forwards attacker-supplied headers to the local server.
- Fixed path traversal in /api/image/{path} (realpath-confined to macro_images).
- GUI embeds the token in the LAN URL/QR; PWA reads it and sends it on API,
  WebSocket, and image requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:14:56 -07:00
38 changed files with 5721 additions and 1359 deletions
+160
View File
@@ -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"
-159
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+18 -6
View File
@@ -11,6 +11,7 @@ A cross-platform macro management application with desktop and web interfaces. C
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
- **Wait**: Insert delays between commands (in milliseconds)
- **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
- **Category Management**: Organize macros into custom tabs
@@ -38,7 +39,6 @@ A cross-platform macro management application with desktop and web interfaces. C
- PyAutoGUI (Keyboard automation)
- Pillow (Image processing)
- pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
- aiohttp (Relay server client)
@@ -119,15 +119,18 @@ A macro that types a username, waits, presses Tab, types a password, and presses
### Web Interface (PWA)
1. Start the application (web server starts automatically on port 40000)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`)
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
4. **Install as PWA**:
- Mobile: Tap browser menu → "Add to Home Screen"
- 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:
- 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
- Real-time sync across devices
@@ -149,12 +152,18 @@ Access your macros from outside your local network using a relay server:
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
- Full macro execution and management
- Macro browsing and execution
- PWA installation support
- Wake lock and fullscreen mode
> [!NOTE]
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions.
> 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
@@ -164,10 +173,13 @@ Once connected, a relay URL will appear in the toolbar. Use this URL from any de
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
| `wait` | Delays execution | `ms`: Milliseconds to wait |
| `app` | Launches application | `command`: Shell command to execute |
| `app` | Launches an application | `command`: Program and arguments to run (tokenized and executed directly — no shell) |
## 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
```bash
# Steam game
+5 -2
View File
@@ -1,6 +1,6 @@
# Configuration and constants for MacroPad Server
VERSION = "1.0.0"
VERSION = "1.1.1"
DEFAULT_PORT = 40000
SETTINGS_FILE = "settings.json"
@@ -13,7 +13,10 @@ THEME = {
'button_bg': "#505050",
'button_fg': "#ffffff",
'tab_bg': "#404040",
'tab_selected': "#007acc"
'tab_selected': "#007acc",
'accent_hover': "#0096ff",
'danger_color': "#dc3545",
'danger_hover': "#c82333"
}
# File extensions for images
+29 -16
View File
@@ -1,19 +1,30 @@
# Macro editor dialog with command builder (PySide6)
import os
from typing import Optional, List, Dict
from typing import Optional, List
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea, QCompleter
QComboBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtGui import QPixmap
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):
"""Widget representing a single command in the list."""
@@ -86,7 +97,7 @@ class CommandItem(QWidget):
del_btn = QPushButton("X")
del_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 6px 8px;
@@ -94,7 +105,7 @@ class CommandItem(QWidget):
min-height: 20px;
}}
QPushButton:hover {{
background-color: #c82333;
background-color: {THEME['danger_hover']};
}}
""")
del_btn.setFixedSize(30, 28)
@@ -212,9 +223,7 @@ class CommandBuilder(QWidget):
elif cmd_type == "key":
from PySide6.QtWidgets import QInputDialog
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
keys = list(KEY_CHOICES)
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
if not ok or not key:
return
@@ -295,9 +304,7 @@ class CommandBuilder(QWidget):
cmd["value"] = text
elif cmd_type == "key":
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
keys = list(KEY_CHOICES)
keys_lower = [k.lower() for k in keys]
current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
@@ -511,7 +518,7 @@ class MacroEditorDialog(QDialog):
delete_btn = QPushButton("Delete")
delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 10px 20px;
@@ -519,7 +526,7 @@ class MacroEditorDialog(QDialog):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #c82333;
background-color: {THEME['danger_hover']};
}}
""")
delete_btn.clicked.connect(self.delete_macro)
@@ -554,7 +561,7 @@ class MacroEditorDialog(QDialog):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_macro)
@@ -634,9 +641,15 @@ class MacroEditorDialog(QDialog):
def delete_macro(self):
"""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(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
+102 -25
View File
@@ -21,11 +21,11 @@ def get_resource_path(relative_path):
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTabWidget, QGridLayout,
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QScrollArea, QMenu, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from PySide6.QtCore import Qt, Signal, QTimer, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
from macro_manager import MacroManager
@@ -127,6 +127,8 @@ class MainWindow(QMainWindow):
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):
super().__init__()
@@ -146,7 +148,12 @@ class MainWindow(QMainWindow):
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
# 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
# Relay client (initialized later if enabled)
@@ -169,6 +176,14 @@ class MainWindow(QMainWindow):
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
self.refresh_tabs()
@@ -204,7 +219,7 @@ class MainWindow(QMainWindow):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
add_btn.clicked.connect(self.add_macro)
@@ -450,20 +465,43 @@ class MainWindow(QMainWindow):
self.ip_label.setText(full_url)
return
# Fall back to local IP
# 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:
import netifaces
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr', '')
if ip and not ip.startswith('127.'):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
return
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:
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."""
@@ -547,7 +585,8 @@ class MainWindow(QMainWindow):
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
# 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):
btn = MacroButton(macro_id, macro, self.app_dir)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
@@ -560,8 +599,25 @@ class MainWindow(QMainWindow):
self.refresh_macros()
def execute_macro(self, macro_id: str):
"""Execute a macro."""
success = self.macro_manager.execute_macro(macro_id)
"""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)
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:
self.status_bar.showMessage("Macro executed", 2000)
else:
@@ -585,9 +641,16 @@ class MainWindow(QMainWindow):
def delete_macro(self, macro_id: str):
"""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(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
@@ -703,7 +766,8 @@ class MainWindow(QMainWindow):
local_port=DEFAULT_PORT,
on_connected=self.on_relay_connected,
on_disconnected=self.on_relay_disconnected,
on_session_id=self.on_relay_session_id
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...")
@@ -729,7 +793,8 @@ class MainWindow(QMainWindow):
def _handle_relay_session(self, session_id: str):
"""Handle relay session on main thread."""
self.settings_manager.set_relay_session_id(session_id)
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):
@@ -816,15 +881,27 @@ class MainWindow(QMainWindow):
event.accept()
def _compute_columns(self) -> int:
"""Compute how many macro columns fit at the current width."""
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()
def resizeEvent(self, event):
"""Handle window resize."""
"""Handle window resize (debounced to coalesce rapid resize events)."""
super().resizeEvent(event)
self.refresh_macros()
# 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:
if (self.windowState() & Qt.WindowMinimized
and self.settings_manager.get_minimize_to_tray()):
# Hide instead of minimize (goes to tray)
event.ignore()
self.hide()
+9 -3
View File
@@ -123,7 +123,7 @@ class SettingsDialog(QDialog):
color: white;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_settings)
@@ -316,9 +316,15 @@ class SettingsDialog(QDialog):
self.settings_manager.set('relay.enabled', new_enabled)
self.settings_manager.set('relay.server_url', new_url)
self.settings_manager.set('relay.password', new_password)
self.settings_manager.save()
# 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()
+69 -4
View File
@@ -2,8 +2,19 @@
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": {
@@ -12,6 +23,10 @@ DEFAULT_SETTINGS = {
"session_id": None,
"password": ""
},
"web": {
"auth_token": "",
"allow_lan": True
},
"minimize_to_tray": True
}
@@ -36,12 +51,23 @@ class SettingsManager:
# 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
@@ -93,13 +119,52 @@ class SettingsManager:
return self.get('relay.session_id')
def get_relay_password(self) -> str:
"""Get the relay password."""
"""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_session_id(self, session_id: str):
"""Store the relay session ID."""
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)
self.save()
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."""
+186 -68
View File
@@ -3,6 +3,9 @@
import copy
import json
import os
import shlex
import tempfile
import threading
import uuid
import pyautogui
import subprocess
@@ -11,37 +14,61 @@ from PIL import Image
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:
"""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):
self.data_file = data_file
self.images_dir = images_dir
self.app_dir = app_dir
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()
def load_macros(self):
"""Load macros from JSON file and migrate if needed."""
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
with self._lock:
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
if migrated:
self.save_macros()
print("Migrated macros to new command sequence format")
if migrated:
self._save_macros_locked()
print("Migrated macros to new command sequence format")
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def _migrate_macro(self, old_macro: dict) -> dict:
"""Convert old macro format to new command sequence format."""
@@ -91,28 +118,56 @@ class MacroManager:
}
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:
with open(self.data_file, "w") as file:
json.dump(self.macros, file, indent=4)
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)
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:
print(f"Error saving macros: {e}")
def get_sorted_macros(self, sort_by: str = "name"):
"""Get macros sorted by specified criteria."""
macro_list = list(self.macros.items())
with self._lock:
macro_list = list(self.macros.items())
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":
# Sort by first command type in sequence
def get_first_type(macro):
cmds = macro.get("commands", [])
return cmds[0].get("type", "") if cmds else ""
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
macro_list.sort(
key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower())
)
elif sort_by == "recent":
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
)
@@ -141,9 +196,10 @@ class MacroManager:
tabs = ["All"]
categories = set()
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
with self._lock:
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
for category in sorted(categories):
if category and category not in tabs:
@@ -161,7 +217,7 @@ class MacroManager:
"""Add a new macro with command sequence."""
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 ""
# Create macro data (deep copy commands to avoid reference issues)
@@ -174,8 +230,9 @@ class MacroManager:
"last_used": 0
}
self.macros[macro_id] = macro_data
self.save_macros()
with self._lock:
self.macros[macro_id] = macro_data
self._save_macros_locked()
return macro_id
def update_macro(
@@ -187,28 +244,32 @@ class MacroManager:
image_path: Optional[str] = None
) -> bool:
"""Update an existing macro."""
if macro_id not in self.macros:
return False
with self._lock:
if macro_id not in self.macros:
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
# Keep old image or update with new one (file IO outside the lock)
if image_path is not None:
image_path_reference = self._process_image(image_path) if image_path else ""
else:
image_path_reference = macro.get("image_path", "")
image_path_reference = existing_image
# Update macro data (deep copy commands to avoid reference issues)
self.macros[macro_id] = {
new_data = {
"name": name,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"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
def _process_image(self, image_path: str) -> str:
@@ -233,37 +294,50 @@ class MacroManager:
def delete_macro(self, macro_id: str) -> bool:
"""Delete a macro."""
if macro_id not in self.macros:
return False
with self._lock:
if macro_id not in self.macros:
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
if macro.get("image_path"):
# Delete associated image file (outside the lock; pure file IO)
if image_path:
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):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[macro_id]
self.save_macros()
return True
def execute_macro(self, macro_id: str) -> bool:
"""Execute a macro by ID."""
if macro_id not in self.macros:
return False
"""Execute a macro by ID.
macro = self.macros[macro_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:
return False
# Update last_used timestamp
self.macros[macro_id]["last_used"] = time.time()
self.save_macros()
macro = self.macros[macro_id]
commands = copy.deepcopy(macro.get("commands", []))
# Update last_used in memory; debounce the disk write.
now = time.time()
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:
commands = macro.get("commands", [])
for cmd in commands:
self._execute_command(cmd)
return True
@@ -271,6 +345,22 @@ class MacroManager:
print(f"Error executing macro: {e}")
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):
"""Execute a single command from a sequence."""
cmd_type = cmd.get("type", "")
@@ -293,10 +383,11 @@ class MacroManager:
pyautogui.typewrite(value, interval=0.02)
elif cmd_type == "key":
# Press a single key
# Press a single key (validated against pyautogui's known keys)
key = cmd.get("value", "")
if key:
pyautogui.press(key)
valid = self._valid_keys([key]) if key else []
if valid:
pyautogui.press(valid[0])
elif cmd_type == "hotkey":
# Press key combination
@@ -305,21 +396,45 @@ class MacroManager:
# Ensure keys is a list, not a string
if isinstance(keys, str):
keys = [k.strip().lower() for k in keys.split(",")]
# Small delay before hotkey for reliability on Windows
time.sleep(0.05)
pyautogui.hotkey(*keys, interval=0.05)
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":
# Delay in milliseconds
ms = cmd.get("ms", 0)
# Delay in milliseconds (bounded, non-negative)
try:
ms = int(cmd.get("ms", 0))
except (TypeError, ValueError):
ms = 0
if ms > 0:
ms = min(ms, MAX_WAIT_MS)
time.sleep(ms / 1000.0)
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", "")
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
def add_macro_legacy(
@@ -357,8 +472,11 @@ class MacroManager:
def get_macro(self, macro_id: str) -> Optional[dict]:
"""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:
"""Get all macros."""
return self.macros.copy()
with self._lock:
return copy.deepcopy(self.macros)
+30 -5
View File
@@ -9,12 +9,37 @@ NODE_ENV=production
# Security
BCRYPT_ROUNDS=10
# Session ID length (default: 6 characters)
SESSION_ID_LENGTH=6
# Session ID length (default: 12 characters)
SESSION_ID_LENGTH=12
# Rate limiting
RATE_LIMIT_WINDOW_MS=90
RATE_LIMIT_MAX=1024
# 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
+19 -3
View File
@@ -9,9 +9,12 @@ For AnHonestHost cloud-node-container deployment:
```bash
cd /home/jknapp/code/macropad/macropad-relay
npm install
npm run build
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:
@@ -77,6 +80,19 @@ Set these in your container configuration:
- `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
@@ -84,8 +100,8 @@ Set these in your container configuration:
# Test health endpoint
curl http://localhost:3000/health
# Should return:
# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]}
# Should return (counts only - no session ids are exposed):
# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23}
```
## Nginx/Reverse Proxy (for HTTPS)
+2484
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -4,7 +4,6 @@
"description": "Relay server for MacroPad remote access",
"main": "dist/index.js",
"scripts": {
"postinstall": "npm run build",
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
@@ -21,7 +20,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"typescript": "^5.3.2",
"uuid": "^9.0.0",
"winston": "^3.11.0",
"ws": "^8.14.2"
},
@@ -29,7 +27,6 @@
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.16",
"@types/express": "^4.17.21",
"@types/uuid": "^9.0.6",
"@types/ws": "^8.5.9",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2"
+3 -342
View File
@@ -264,8 +264,8 @@
<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" onclick="app.toggleFullscreen()" title="Toggle fullscreen"></button>
<button class="header-btn" onclick="app.refresh()">Refresh</button>
<button class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen"></button>
<button class="header-btn" id="refresh-btn">Refresh</button>
</div>
</header>
@@ -277,345 +277,6 @@
<div class="toast-container" id="toast-container"></div>
<script>
// Inline MacroPad App for Relay Mode
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.desktopConnected = false;
this.wsAuthenticated = false;
// Get session ID from URL
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
this.sessionId = pathMatch ? pathMatch[1] : null;
// Get password from URL or sessionStorage
const urlParams = new URLSearchParams(window.location.search);
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
if (this.password) {
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
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}`);
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);
setTimeout(() => this.setupWebSocket(), 3000);
};
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;
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.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}" data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
const entries = Object.entries(this.macros);
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No macros found</p></div>';
return;
}
container.innerHTML = entries.map(([id, macro]) => {
// Include password as query param for image authentication
const imageSrc = macro.image_path
? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc ? `<img src="${imageSrc}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>${firstChar}</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
setupEventListeners() {
document.getElementById('tabs-container').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
}
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();
});
</script>
<script src="/static/app.js"></script>
</body>
</html>
+420
View File
@@ -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();
});
+1 -87
View File
@@ -158,92 +158,6 @@
<p class="status" id="status">Checking connection...</p>
</div>
<script>
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
const savedPassword = sessionStorage.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) {
// Save password if remember is checked
if (rememberCheckbox.checked) {
sessionStorage.setItem(`macropad_${sessionId}`, password);
}
// Redirect to the PWA with password
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
} 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();
</script>
<script src="/static/login.js"></script>
</body>
</html>
+91
View File
@@ -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();
+39 -3
View File
@@ -5,6 +5,14 @@ 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',
@@ -13,19 +21,47 @@ export const config = {
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
// Session settings
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
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
// Rate limiting (HTTP)
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
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',
+14 -18
View File
@@ -12,26 +12,22 @@ export function createApiProxy(
return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId;
// Check session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
// 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' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
if (!password) {
return res.status(401).json({ error: 'Password required' });
}
const valid = await sessionManager.validatePassword(sessionId, password);
if (!valid) {
return res.status(401).json({ error: 'Invalid password' });
}
// Check desktop is connected
// 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' });
+51 -12
View File
@@ -4,6 +4,11 @@ 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';
@@ -32,20 +37,33 @@ type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | Po
export function handleDesktopConnection(
socket: WebSocket,
connectionManager: ConnectionManager,
sessionManager: SessionManager
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':
await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
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) {
@@ -87,32 +105,53 @@ export function handleDesktopConnection(
});
}
// 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<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'
}));
socket.close();
return;
return true;
}
recordSuccess(throttleKey);
session = sessionManager.getSession(sessionId);
} else {
// Create new session
// Create new session (may throw on limit / short password)
session = await sessionManager.createSession(message.password);
sessionId = session.id;
}
@@ -123,8 +162,7 @@ async function handleAuth(
success: false,
error: 'Failed to create session'
}));
socket.close();
return;
return true;
}
// Add to connection manager
@@ -139,14 +177,15 @@ async function handleAuth(
}));
logger.info(`Desktop authenticated: ${sessionId}`);
} catch (error) {
return false;
} catch (error: any) {
logger.error('Desktop auth error:', error);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Authentication failed'
error: error?.message || 'Authentication failed'
}));
socket.close();
return true;
}
}
+59 -24
View File
@@ -4,6 +4,11 @@ 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';
@@ -20,30 +25,26 @@ export function handleWebClientConnection(
socket: WebSocket,
sessionId: string,
connectionManager: ConnectionManager,
sessionManager: SessionManager
sessionManager: SessionManager,
clientIp: string
): void {
// Check if session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
socket.send(JSON.stringify({
type: 'error',
error: 'Session not found'
}));
socket.close();
return;
}
// 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}`;
// Add client (not authenticated yet)
// 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);
// Check if desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
// Request authentication
// Request authentication.
socket.send(JSON.stringify({
type: 'auth_required'
}));
@@ -53,9 +54,25 @@ export function handleWebClientConnection(
const message: WebClientMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth':
await handleAuth(socket, client, message, sessionId, sessionManager);
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' }));
@@ -82,28 +99,46 @@ export function handleWebClientConnection(
});
}
// 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
): Promise<void> {
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: 'Invalid password'
error: 'Authentication failed'
}));
return true;
}
}
+115 -17
View File
@@ -3,7 +3,7 @@
import express from 'express';
import fs from 'fs';
import http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
@@ -17,19 +17,104 @@ 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({
contentSecurityPolicy: false // Allow inline scripts for login page
// 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'"],
},
},
}));
app.use(cors());
// 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
@@ -46,12 +131,14 @@ export function createServer() {
: path.join(__dirname, '..', 'public');
app.use('/static', express.static(publicPath));
// Health check
// Health check - counts only, never leak session ids.
app.get('/health', (req, res) => {
const stats = connectionManager.getStats();
res.json({
status: 'ok',
...stats
desktops: stats.desktopConnections,
webClients: stats.webClients,
uptime: process.uptime()
});
});
@@ -65,21 +152,14 @@ export function createServer() {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Login page for session
// 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) => {
const session = sessionManager.getSession(req.params.sessionId);
if (!session) {
return res.status(404).send('Session not found');
}
res.sendFile(path.join(publicPath, 'login.html'));
});
// PWA app page (after authentication)
// PWA app page (served uniformly; auth is enforced by the API/WS layer).
app.get('/:sessionId/app', (req, res) => {
const session = sessionManager.getSession(req.params.sessionId);
if (!session) {
return res.status(404).send('Session not found');
}
res.sendFile(path.join(publicPath, 'app.html'));
});
@@ -90,15 +170,32 @@ export function createServer() {
// 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);
handleDesktopConnection(ws, connectionManager, sessionManager, ip);
});
return;
}
@@ -108,7 +205,7 @@ export function createServer() {
if (webClientMatch) {
const sessionId = webClientMatch[1];
wss.handleUpgrade(request, socket, head, (ws) => {
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager);
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager, ip);
});
return;
}
@@ -121,6 +218,7 @@ export function createServer() {
const shutdown = () => {
logger.info('Shutting down...');
connectionManager.shutdown();
sessionManager.shutdown();
server.close(() => {
logger.info('Server closed');
process.exit(0);
+114 -7
View File
@@ -18,13 +18,38 @@ 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 {
@@ -41,23 +66,69 @@ export class SessionManager {
}
}
// 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)
};
fs.mkdirSync(path.dirname(this.sessionsFile), { recursive: true });
fs.writeFileSync(this.sessionsFile, JSON.stringify(store, null, 2));
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 {
@@ -65,6 +136,21 @@ export class SessionManager {
} 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 = {
@@ -75,7 +161,7 @@ export class SessionManager {
};
this.sessions.set(id, session);
this.save();
this.save(); // creation is important, persist immediately
logger.info(`Created new session: ${id}`);
return session;
@@ -90,9 +176,14 @@ export class SessionManager {
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// Update last connected time
session.lastConnected = new Date().toISOString();
this.save();
// 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;
@@ -106,7 +197,7 @@ export class SessionManager {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.save();
this.dirty = true;
}
}
@@ -118,4 +209,20 @@ export class SessionManager {
}
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;
}
}
}
+80
View File
@@ -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();
}
+26 -13
View File
@@ -1,31 +1,44 @@
// Unique ID generation utilities
import { randomBytes } from 'crypto';
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 specified length.
* Uses cryptographically secure random bytes.
* 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 = 6): string {
const bytes = randomBytes(length);
export function generateSessionId(length: number = 12): string {
let result = '';
for (let i = 0; i < length; i++) {
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
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.
* Generate a UUID v4 for request IDs using the crypto module.
*/
export function generateRequestId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
return randomUUID();
}
+4 -2
View File
@@ -45,9 +45,11 @@ a = Analysis(
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
# Relay client (imported lazily in the GUI, so declare explicitly)
'relay_client',
'aiohttp',
],
hookspath=[],
hooksconfig={},
@@ -78,5 +80,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='Macro Pad.png',
icon='Macro Pad.ico',
)
-1
View File
@@ -49,7 +49,6 @@ a = Analysis(
'pyperclip',
'pystray',
'pystray._base',
'netifaces',
'websockets',
'multipart',
# Linux system tray
-1
View File
@@ -45,7 +45,6 @@ a = Analysis(
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
],
-2
View File
@@ -23,8 +23,6 @@ dependencies = [
"uvicorn>=0.24.0",
"websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads
# Network utilities
"netifaces>=0.11.0",
# QR code generation
"qrcode>=7.4.2",
# Desktop GUI
+22 -3
View File
@@ -20,7 +20,8 @@ class RelayClient:
local_port: int = 40000,
on_connected: Optional[Callable] = None,
on_disconnected: Optional[Callable] = None,
on_session_id: Optional[Callable[[str], None]] = 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'):
@@ -28,6 +29,7 @@ class RelayClient:
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
@@ -174,16 +176,33 @@ class RelayClient:
method = msg.get("method", "GET").upper()
path = msg.get("path", "/")
body = msg.get("body")
headers = msg.get("headers", {})
# 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 body and method in ("POST", "PUT", "PATCH") else None,
json=body if sending_body else None,
headers=headers
) as response:
# Handle binary responses (images)
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.1
+696 -400
View File
File diff suppressed because it is too large Load Diff
+49 -11
View File
@@ -2,8 +2,10 @@
<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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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">
<!-- 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-title" 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">
<title>MacroPad</title>
@@ -29,20 +31,20 @@
<header class="header">
<h1>MacroPad</h1>
<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>
<span>Disconnected</span>
</div>
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
<span class="wake-icon"></span>
</div>
<button class="header-btn icon-btn" id="fullscreen-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen"></button>
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
<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" aria-hidden="true"></span>
</button>
<button type="button" class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen" aria-label="Toggle fullscreen"></button>
<button type="button" class="header-btn secondary" id="refresh-btn">Refresh</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container">
<nav class="tabs" id="tabs-container" role="tablist" aria-label="Macro categories">
<!-- Tabs rendered dynamically -->
</nav>
@@ -53,8 +55,44 @@
</div>
</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 -->
<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>
</body>
+630 -62
View File
@@ -1,4 +1,14 @@
// MacroPad PWA Application (Execute-only)
// MacroPad PWA Application
// Supported command types for the macro builder. Kept in sync with the
// server-side Command model: {type, value?, keys?, ms?, command?}.
const COMMAND_TYPES = [
{ value: 'text', label: 'Text', placeholder: 'Text to type', numeric: false },
{ value: 'key', label: 'Key', placeholder: 'Key name, e.g. enter', numeric: false },
{ value: 'hotkey', label: 'Hotkey', placeholder: 'Keys, e.g. ctrl, c', numeric: false },
{ value: 'wait', label: 'Wait', placeholder: 'Milliseconds', numeric: true },
{ value: 'app', label: 'App', placeholder: 'App or command to launch', numeric: false }
];
class MacroPadApp {
constructor() {
@@ -12,11 +22,32 @@ class MacroPadApp {
this.relayMode = this.detectRelayMode();
this.sessionId = null;
this.password = null;
this.localToken = null;
this.desktopConnected = true;
this.wsAuthenticated = false;
// WebSocket reconnect state (exponential backoff)
this.shouldReconnect = true;
this.reconnectDelay = 1000;
this.reconnectTimer = null;
// Guards against out-of-order macro fetches
this._macroReqId = 0;
// Blob object URLs created for macro images (revoked on re-render)
this._objectUrls = [];
// Set once a local-mode auth failure has been surfaced, to avoid
// reconnect loops and repeated toasts.
this.localAuthFailed = false;
// Macro editor state (null = create mode)
this.editingMacroId = null;
if (this.relayMode) {
this.initRelayMode();
} else {
this.initLocalMode();
}
this.init();
@@ -51,6 +82,20 @@ class MacroPadApp {
console.log('Relay mode enabled, session:', this.sessionId);
}
initLocalMode() {
// Get token from URL query param or localStorage (local/LAN mode)
const urlParams = new URLSearchParams(window.location.search);
this.localToken = urlParams.get('token') || localStorage.getItem('macropad_local_token');
if (this.localToken) {
// Persist token and strip it from the URL for cleanliness
localStorage.setItem('macropad_local_token', this.localToken);
if (urlParams.has('token')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
}
getApiUrl(path) {
if (this.relayMode && this.sessionId) {
return `/${this.sessionId}${path}`;
@@ -62,15 +107,17 @@ class MacroPadApp {
const headers = { 'Content-Type': 'application/json' };
if (this.relayMode && this.password) {
headers['X-MacroPad-Password'] = this.password;
} else if (!this.relayMode && this.localToken) {
headers['X-MacroPad-Token'] = this.localToken;
}
return headers;
}
async init() {
this.setupEventListeners();
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.setupWakeLock();
this.checkInstallPrompt();
}
@@ -98,6 +145,9 @@ class MacroPadApp {
}
async loadMacros() {
// Only the latest request is allowed to render, so overlapping
// tab-switch fetches can't apply out of order.
const reqId = ++this._macroReqId;
try {
const path = this.currentTab === 'All'
? '/api/macros'
@@ -105,6 +155,7 @@ class MacroPadApp {
const response = await fetch(this.getApiUrl(path), {
headers: this.getApiHeaders()
});
if (reqId !== this._macroReqId) return; // superseded
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
@@ -117,19 +168,21 @@ class MacroPadApp {
throw new Error('Failed to load macros');
}
const data = await response.json();
if (reqId !== this._macroReqId) return; // superseded
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
if (reqId !== this._macroReqId) return;
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const card = this.findCard(macroId);
if (card) card.classList.add('executing');
try {
const response = await fetch(this.getApiUrl('/api/execute'), {
method: 'POST',
headers: this.getApiHeaders(),
@@ -137,28 +190,65 @@ class MacroPadApp {
});
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
return;
}
if (response.status === 503) {
this.handleDesktopDisconnected();
}
throw new Error('Execution failed');
}
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
} finally {
// Always clear the animation state, even on failure.
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
}
}
handleAuthError() {
this.showToast('Authentication failed', 'error');
if (this.relayMode) {
this.showToast('Authentication failed', 'error');
// Clear stored password and redirect to login
sessionStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${this.sessionId}`;
return;
}
// Local (token) mode: a wrong/missing token makes every request 401 and
// the WebSocket close with 1008, which would otherwise reconnect forever
// and spam toasts. Surface a single, clear message and stop retrying.
if (this.localAuthFailed) return;
this.localAuthFailed = true;
this.shouldReconnect = false;
clearTimeout(this.reconnectTimer);
this.teardownSocket();
this.updateConnectionStatus(false, 'Access denied');
this.renderAuthRequired();
}
renderAuthRequired() {
const container = document.getElementById('macro-grid');
if (!container) return;
container.textContent = '';
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const p1 = document.createElement('p');
p1.textContent = 'Access token required';
const p2 = document.createElement('p');
p2.className = 'hint';
p2.textContent = "Open the link from the desktop app's URL or QR code.";
wrap.append(p1, p2);
container.appendChild(wrap);
}
handleDesktopDisconnected() {
@@ -168,7 +258,37 @@ class MacroPadApp {
}
// WebSocket
teardownSocket() {
if (this.ws) {
// Detach handlers so the old socket can't fire reconnect logic.
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onmessage = null;
this.ws.onerror = null;
try {
this.ws.close();
} catch (e) {
/* ignore */
}
this.ws = null;
}
}
scheduleReconnect() {
if (!this.shouldReconnect) return;
clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay;
this.reconnectTimer = setTimeout(() => this.setupWebSocket(), delay);
// Exponential backoff, capped at 30s.
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
setupWebSocket() {
if (!this.shouldReconnect) return;
// Clean up any prior socket before opening a new one.
this.teardownSocket();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl;
@@ -176,12 +296,18 @@ class MacroPadApp {
wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
} else {
wsUrl = `${protocol}//${window.location.host}/ws`;
// Append token as query param (WebSocket can't set headers)
if (this.localToken) {
wsUrl += `?token=${encodeURIComponent(this.localToken)}`;
}
}
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
// Successful connection resets the backoff.
this.reconnectDelay = 1000;
if (!this.relayMode) {
this.updateConnectionStatus(true);
}
@@ -191,19 +317,27 @@ class MacroPadApp {
this.ws.onclose = () => {
this.wsAuthenticated = false;
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
this.scheduleReconnect();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
// Ignore malformed frames.
return;
}
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
// Let onclose own reconnection; just reflect status here.
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
this.scheduleReconnect();
}
}
@@ -212,7 +346,7 @@ class MacroPadApp {
// Relay-specific messages
case 'auth_required':
// Send authentication
if (this.password) {
if (this.password && this.ws) {
this.ws.send(JSON.stringify({
type: 'auth',
password: this.password
@@ -249,13 +383,14 @@ class MacroPadApp {
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
case 'executed': {
const card = this.findCard(data.macro_id);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
case 'pong':
// Keep-alive response
@@ -280,71 +415,491 @@ class MacroPadApp {
}
}
// Rendering
// Deterministic hue from a macro name so the CSS can color placeholders.
hashHue(name) {
const s = String(name || '');
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (hash * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
findCard(macroId) {
const cards = document.querySelectorAll('.macro-card');
for (const card of cards) {
if (card.dataset.macroId === macroId) return card;
}
return null;
}
macroImageUrl(macro) {
if (!macro.image_path) return null;
// No credential ever goes on the URL; auth is via a header on a
// fetch() below, and the image is shown as a blob object URL.
return this.getApiUrl(`/api/image/${macro.image_path}`);
}
// Auth-only headers (no Content-Type) for GET fetches like images.
getAuthHeaders() {
const headers = {};
if (this.relayMode && this.password) {
headers['X-MacroPad-Password'] = this.password;
} else if (!this.relayMode && this.localToken) {
headers['X-MacroPad-Token'] = this.localToken;
}
return headers;
}
// Fetch a macro image with the auth header and display it as a blob
// object URL, so no credential is ever placed on an <img> src.
async loadMacroImage(url, img, placeholder) {
try {
const res = await fetch(url, { headers: this.getAuthHeaders() });
if (!res.ok) throw new Error('image request failed');
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
// Track for best-effort revocation on the next render.
this._objectUrls.push(objectUrl);
img.src = objectUrl;
} catch (e) {
// Fall back to the placeholder if the image can't be loaded.
img.style.display = 'none';
placeholder.style.display = '';
}
}
// Rendering (safe DOM construction only - no user value reaches innerHTML)
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
container.textContent = '';
this.tabs.forEach((tab) => {
const active = tab === this.currentTab;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = active ? 'tab active' : 'tab';
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', active ? 'true' : 'false');
btn.dataset.tab = tab;
btn.textContent = tab;
container.appendChild(btn);
});
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
// Best-effort: revoke object URLs from the previous render to avoid leaks.
if (this._objectUrls && this._objectUrls.length) {
this._objectUrls.forEach((u) => URL.revokeObjectURL(u));
}
this._objectUrls = [];
container.textContent = '';
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<p class="hint">Create macros in the desktop app</p>
</div>
`;
container.appendChild(this.buildEmptyState());
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
let imageSrc = null;
if (macro.image_path) {
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
// Add password as query param for relay mode (img tags can't use headers)
if (this.relayMode && this.password) {
imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`;
} else {
imageSrc = basePath;
}
}
const firstChar = macro.name.charAt(0).toUpperCase();
const fragment = document.createDocumentFragment();
macroEntries.forEach(([id, macro]) => {
fragment.appendChild(this.buildMacroCard(id, macro));
});
container.appendChild(fragment);
}
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
${firstChar}
</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
buildMacroCard(id, macro) {
const name = macro.name || '';
const card = document.createElement('button');
card.type = 'button';
card.className = 'macro-card';
card.dataset.macroId = id;
card.setAttribute('aria-label', `Run ${name}`);
card.style.setProperty('--macro-color', `hsl(${this.hashHue(name)} 55% 45%)`);
const placeholder = document.createElement('span');
placeholder.className = 'macro-image-placeholder';
placeholder.textContent = (name.charAt(0) || '?').toUpperCase();
const imageSrc = this.macroImageUrl(macro);
if (imageSrc) {
const img = document.createElement('img');
img.className = 'macro-image';
img.alt = '';
placeholder.style.display = 'none';
// Fall back to the placeholder if the (blob) image fails to load.
img.addEventListener('error', () => {
img.style.display = 'none';
placeholder.style.display = '';
});
card.appendChild(img);
// Header-authenticated fetch -> blob object URL (no credential on src).
this.loadMacroImage(imageSrc, img, placeholder);
}
card.appendChild(placeholder);
const nameEl = document.createElement('span');
nameEl.className = 'macro-name';
nameEl.textContent = name;
card.appendChild(nameEl);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'macro-edit-btn';
editBtn.setAttribute('aria-label', `Edit ${name}`);
editBtn.textContent = '✎';
editBtn.addEventListener('click', (e) => {
// Don't trigger execute when editing.
e.stopPropagation();
this.openMacroModal(id);
});
card.appendChild(editBtn);
card.addEventListener('click', () => this.executeMacro(id));
return card;
}
buildEmptyState() {
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const p1 = document.createElement('p');
p1.textContent = 'No macros yet';
const p2 = document.createElement('p');
p2.className = 'hint';
p2.textContent = 'Build one right here in your browser.';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-primary';
btn.textContent = 'Create your first macro';
btn.addEventListener('click', () => this.openMacroModal());
wrap.append(p1, p2, btn);
return wrap;
}
// ---- Macro editor (create / edit / delete) ----
populateCategoryOptions() {
const list = document.getElementById('macro-category-list');
if (!list) return;
list.textContent = '';
this.tabs
.filter((t) => t && t !== 'All')
.forEach((t) => {
const opt = document.createElement('option');
opt.value = t;
list.appendChild(opt);
});
}
commandToInputValue(cmd) {
switch (cmd.type) {
case 'hotkey':
return Array.isArray(cmd.keys) ? cmd.keys.join(', ') : (cmd.keys || '');
case 'wait':
return cmd.ms != null ? String(cmd.ms) : '';
case 'app':
return cmd.command || '';
case 'text':
case 'key':
default:
return cmd.value || '';
}
}
applyCommandRowType(select, input) {
const meta = COMMAND_TYPES.find((t) => t.value === select.value) || COMMAND_TYPES[0];
input.type = meta.numeric ? 'number' : 'text';
input.placeholder = meta.placeholder;
if (meta.numeric) {
input.min = '0';
} else {
input.removeAttribute('min');
}
}
addCommandRow(type = 'text', cmd = null) {
const list = document.getElementById('command-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'command-item';
const select = document.createElement('select');
select.className = 'command-type-select';
COMMAND_TYPES.forEach((t) => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
select.appendChild(opt);
});
select.value = COMMAND_TYPES.some((t) => t.value === type) ? type : 'text';
const input = document.createElement('input');
input.className = 'command-value';
input.autocomplete = 'off';
const actions = document.createElement('div');
actions.className = 'command-actions';
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.setAttribute('aria-label', 'Move step up');
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.setAttribute('aria-label', 'Move step down');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'delete';
delBtn.textContent = '✕';
delBtn.setAttribute('aria-label', 'Remove step');
this.applyCommandRowType(select, input);
if (cmd) {
input.value = this.commandToInputValue(cmd);
}
select.addEventListener('change', () => this.applyCommandRowType(select, input));
upBtn.addEventListener('click', () => {
const prev = row.previousElementSibling;
if (prev) list.insertBefore(row, prev);
});
downBtn.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next) list.insertBefore(next, row);
});
delBtn.addEventListener('click', () => row.remove());
actions.append(upBtn, downBtn, delBtn);
row.append(select, input, actions);
list.appendChild(row);
}
renderCommandRows(commands) {
const list = document.getElementById('command-list');
if (!list) return;
list.textContent = '';
commands.forEach((cmd) => this.addCommandRow(cmd.type || 'text', cmd));
}
collectCommands() {
const list = document.getElementById('command-list');
if (!list) return [];
const commands = [];
list.querySelectorAll('.command-item').forEach((row) => {
const type = row.querySelector('.command-type-select').value;
const raw = row.querySelector('.command-value').value.trim();
switch (type) {
case 'hotkey': {
// Store keys as a list (split on commas/whitespace).
const keys = raw.split(/[\s,]+/).filter(Boolean);
commands.push({ type: 'hotkey', keys });
break;
}
case 'wait': {
const ms = parseInt(raw, 10);
commands.push({ type: 'wait', ms: Number.isFinite(ms) ? ms : 0 });
break;
}
case 'app':
commands.push({ type: 'app', command: raw });
break;
case 'key':
commands.push({ type: 'key', value: raw });
break;
case 'text':
default:
commands.push({ type: 'text', value: raw });
break;
}
});
return commands;
}
async openMacroModal(macroId = null) {
this.editingMacroId = macroId || null;
const modal = document.getElementById('macro-modal');
const title = document.getElementById('macro-modal-title');
const nameInput = document.getElementById('macro-name-input');
const categoryInput = document.getElementById('macro-category-input');
const deleteBtn = document.getElementById('delete-macro-btn');
if (!modal) return;
this.populateCategoryOptions();
nameInput.value = '';
categoryInput.value = '';
this.renderCommandRows([]);
if (macroId) {
title.textContent = 'Edit Macro';
if (deleteBtn) deleteBtn.hidden = false;
try {
const res = await fetch(
this.getApiUrl(`/api/macro/${encodeURIComponent(macroId)}`),
{ headers: this.getApiHeaders() }
);
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Failed to load macro');
}
const data = await res.json();
const macro = data.macro || {};
nameInput.value = macro.name || '';
categoryInput.value = macro.category || '';
this.renderCommandRows(Array.isArray(macro.commands) ? macro.commands : []);
} catch (e) {
console.error('Error loading macro:', e);
this.showToast('Could not load macro', 'error');
}
} else {
title.textContent = 'New Macro';
if (deleteBtn) deleteBtn.hidden = true;
categoryInput.value = (this.currentTab && this.currentTab !== 'All') ? this.currentTab : '';
this.addCommandRow('text');
}
modal.hidden = false;
nameInput.focus();
}
closeMacroModal() {
const modal = document.getElementById('macro-modal');
if (modal) modal.hidden = true;
this.editingMacroId = null;
}
async saveMacro() {
const nameInput = document.getElementById('macro-name-input');
const categoryInput = document.getElementById('macro-category-input');
const saveBtn = document.getElementById('save-macro-btn');
const name = nameInput.value.trim();
const category = categoryInput.value.trim();
const commands = this.collectCommands();
if (!name) {
this.showToast('Please enter a name', 'error');
nameInput.focus();
return;
}
if (commands.length === 0) {
this.showToast('Add at least one step', 'error');
return;
}
const editing = this.editingMacroId;
const url = editing
? this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`)
: this.getApiUrl('/api/macros');
const method = editing ? 'PUT' : 'POST';
const body = JSON.stringify({ name, commands, category });
if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(url, {
method,
headers: this.getApiHeaders(),
body
});
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Save failed');
}
this.showToast(editing ? 'Macro updated' : 'Macro created', 'success');
this.closeMacroModal();
// WS broadcast will also refresh, but reload immediately for snappiness.
await this.loadTabs();
await this.loadMacros();
} catch (e) {
console.error('Error saving macro:', e);
this.showToast('Error saving macro', 'error');
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
async deleteMacro() {
const editing = this.editingMacroId;
if (!editing) return;
const nameInput = document.getElementById('macro-name-input');
const name = (nameInput && nameInput.value.trim()) || 'this macro';
if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
const res = await fetch(
this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`),
{ method: 'DELETE', headers: this.getApiHeaders() }
);
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Delete failed');
}
this.showToast('Macro deleted', 'success');
this.closeMacroModal();
await this.loadTabs();
await this.loadMacros();
} catch (e) {
console.error('Error deleting macro:', e);
this.showToast('Error deleting macro', 'error');
}
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
const tab = e.target.closest('.tab');
if (tab) {
this.currentTab = tab.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
// Header controls
document.getElementById('refresh-btn')?.addEventListener('click', () => this.refresh());
document.getElementById('fullscreen-btn')?.addEventListener('click', () => this.toggleFullscreen());
// Macro editor controls
document.getElementById('new-macro-btn')?.addEventListener('click', () => this.openMacroModal());
document.getElementById('add-command-btn')?.addEventListener('click', () => this.addCommandRow('text'));
document.getElementById('save-macro-btn')?.addEventListener('click', () => this.saveMacro());
document.getElementById('cancel-macro-btn')?.addEventListener('click', () => this.closeMacroModal());
document.getElementById('delete-macro-btn')?.addEventListener('click', () => this.deleteMacro());
document.getElementById('macro-modal-close')?.addEventListener('click', () => this.closeMacroModal());
const modal = document.getElementById('macro-modal');
modal?.addEventListener('click', (e) => {
// Click on the backdrop (not the content) closes.
if (e.target === modal) this.closeMacroModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal && !modal.hidden) this.closeMacroModal();
});
// Stop reconnecting once the page is going away.
const stop = () => {
this.shouldReconnect = false;
clearTimeout(this.reconnectTimer);
this.teardownSocket();
};
window.addEventListener('pagehide', stop);
window.addEventListener('beforeunload', stop);
}
// Toast notifications
@@ -376,13 +931,26 @@ class MacroPadApp {
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
const label = document.createElement('span');
label.textContent = 'Install MacroPad for quick access';
const actions = document.createElement('div');
const installBtn = document.createElement('button');
installBtn.type = 'button';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', () => this.installPWA());
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.className = 'dismiss';
dismissBtn.textContent = '✕';
dismissBtn.setAttribute('aria-label', 'Dismiss install banner');
dismissBtn.addEventListener('click', () => banner.remove());
actions.append(installBtn, dismissBtn);
banner.append(label, actions);
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
@@ -409,7 +977,7 @@ class MacroPadApp {
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
document.documentElement.requestFullscreen().catch((err) => {
console.log('Fullscreen error:', err);
});
} else {
+3 -2
View File
@@ -6,9 +6,10 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#2e2e2e",
"theme_color": "#007acc",
"background_color": "#15161a",
"theme_color": "#7c5cff",
"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": [
{
"src": "/static/icons/icon-192.png",
+51 -33
View File
@@ -1,5 +1,5 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v3';
const CACHE_NAME = 'macropad-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
'/manifest.json'
];
// Install event - cache assets
// Install event - pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Network-first for navigations + app code so deployed fixes reach clients.
async function networkFirst(request) {
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
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
self.addEventListener('fetch', (event) => {
const request = event.request;
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
if (request.method !== 'GET') {
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
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(
fetch(request).catch(() => new Response(
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
))
);
return;
}
// Navigations and static app assets: network-first with cache fallback.
event.respondWith(networkFirst(request));
});
+141 -24
View File
@@ -1,19 +1,35 @@
# FastAPI web server for MacroPad
import io
import os
import sys
import uuid
import hmac
import asyncio
from typing import List, Optional
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.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
from PIL import Image
import uvicorn
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):
"""Get the path to a bundled resource file."""
@@ -72,21 +88,28 @@ class ConnectionManager:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
for connection in self.active_connections:
"""Send message to all connected clients, pruning dead sockets."""
dead = []
# Iterate over a snapshot so the list isn't mutated during iteration.
for connection in list(self.active_connections):
try:
await connection.send_json(message)
except Exception:
pass
dead.append(connection)
for connection in dead:
self.disconnect(connection)
class WebServer:
"""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.app_dir = app_dir
self.port = port
self.auth_token = auth_token
self.host = host
self.app = None
self.manager = ConnectionManager()
self.server = None
@@ -114,6 +137,36 @@ class WebServer:
if os.path.exists(images_dir):
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)
async def index():
"""Serve the main PWA page."""
@@ -167,7 +220,12 @@ class WebServer:
@app.post("/api/execute")
async def execute_macro(request: ExecuteRequest):
"""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:
# Broadcast execution to all connected clients
await self.manager.broadcast({
@@ -216,38 +274,97 @@ class WebServer:
@app.post("/api/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for a macro."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
"""Upload an image for a macro.
# Save to temp location
import tempfile
import shutil
Hardened: enforces a size cap, validates the bytes are a real image
with Pillow (content_type is untrusted), and saves into the app's
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"
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
shutil.copyfileobj(file.file, tmp)
return {"path": tmp.name}
if not data:
raise HTTPException(status_code=400, detail="Empty upload")
# 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}")
async def get_image(image_path: str):
"""Get macro image (legacy compatibility)."""
full_path = os.path.join(self.app_dir, image_path)
if os.path.exists(full_path):
return FileResponse(full_path)
"""Get macro image (legacy compatibility, path-traversal safe)."""
images_dir = os.path.join(self.app_dir, "macro_images")
requested = os.path.realpath(os.path.join(self.app_dir, image_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")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""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)
try:
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()
# 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"})
except WebSocketDisconnect:
pass
except Exception:
# Malformed input or any other error: don't leak the connection.
pass
finally:
self.manager.disconnect(websocket)
self.app = app
@@ -260,7 +377,7 @@ class WebServer:
config = uvicorn.Config(
self.app,
host="0.0.0.0",
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
@@ -280,7 +397,7 @@ class WebServer:
config = uvicorn.Config(
self.app,
host="0.0.0.0",
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility