18 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
shadowdaoandClaude Opus 4.8 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
26 changed files with 913 additions and 717 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

+4 -2
View File
@@ -39,7 +39,6 @@ A cross-platform macro management application with desktop and web interfaces. C
- PyAutoGUI (Keyboard automation) - PyAutoGUI (Keyboard automation)
- Pillow (Image processing) - Pillow (Image processing)
- pystray (System tray) - pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation) - qrcode (QR code generation)
- aiohttp (Relay server client) - aiohttp (Relay server client)
@@ -153,10 +152,13 @@ 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: 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 - Secure HTTPS connection
- Full macro execution and management - Macro browsing and execution
- PWA installation support - PWA installation support
- Wake lock and fullscreen mode - Wake lock and fullscreen mode
> [!NOTE]
> The relay web client is **execute-only**: remote devices can browse and trigger macros, but macro creation/editing is intentionally limited to the local network (the desktop app and the local web interface). This keeps macro authoring — which defines what runs on your computer — off the public internet.
> [!NOTE] > [!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. > 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.
+1 -1
View File
@@ -1,6 +1,6 @@
# Configuration and constants for MacroPad Server # Configuration and constants for MacroPad Server
VERSION = "1.0.0" VERSION = "1.1.1"
DEFAULT_PORT = 40000 DEFAULT_PORT = 40000
SETTINGS_FILE = "settings.json" SETTINGS_FILE = "settings.json"
+30 -10
View File
@@ -469,19 +469,39 @@ class MainWindow(QMainWindow):
# QR/copied URL lets a LAN device authenticate against the API. # QR/copied URL lets a LAN device authenticate against the API.
token = self.settings_manager.get_web_auth_token() token = self.settings_manager.get_web_auth_token()
token_qs = f"?token={token}" if token else "" 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: try:
import netifaces s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for iface in netifaces.interfaces(): try:
addrs = netifaces.ifaddresses(iface) s.connect(("8.8.8.8", 80))
if netifaces.AF_INET in addrs: ip = s.getsockname()[0]
for addr in addrs[netifaces.AF_INET]: finally:
ip = addr.get('addr', '') s.close()
if ip and not ip.startswith('127.'): if ip and not ip.startswith("127."):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}") return ip
return
except Exception: except Exception:
pass pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}") 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): def copy_url_to_clipboard(self):
"""Copy the web interface URL to clipboard.""" """Copy the web interface URL to clipboard."""
+14 -6
View File
@@ -416,15 +416,23 @@ class MacroManager:
elif cmd_type == "app": elif cmd_type == "app":
# Launch application. # Launch application.
# SECURITY: shell=True was removed to kill shell-metacharacter # SECURITY: never use shell=True — that would allow shell-metacharacter
# injection (no ; && | $() chaining). We tokenize the command # injection (; && | $() chaining, redirection). Both branches below run
# and exec the program directly without a shell. # without a shell, so the command can only launch a program with args.
command = cmd.get("command", "") command = cmd.get("command", "")
if command: if command:
try: try:
args = shlex.split(command, posix=(os.name != "nt")) if os.name == "nt":
if args: # Windows: pass the string so CreateProcess parses it
subprocess.Popen(args) # (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: except Exception as e:
print(f"Error launching app command: {e}") print(f"Error launching app command: {e}")
+13
View File
@@ -80,6 +80,19 @@ Set these in your container configuration:
- `DATA_DIR` - Data storage path (default: ./data) - `DATA_DIR` - Data storage path (default: ./data)
- `NODE_ENV` - production or development - `NODE_ENV` - production or development
- `LOG_LEVEL` - info, debug, error - `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 ## Test It Works
+3 -369
View File
@@ -264,8 +264,8 @@
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock"> <div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
<span class="wake-icon">☀</span> <span class="wake-icon">☀</span>
</div> </div>
<button class="header-btn icon-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen">⛶</button> <button class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen">⛶</button>
<button class="header-btn" onclick="app.refresh()">Refresh</button> <button class="header-btn" id="refresh-btn">Refresh</button>
</div> </div>
</header> </header>
@@ -277,372 +277,6 @@
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<script> <script src="/static/app.js"></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 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);
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]) => {
const hasImage = !!macro.image_path;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${hasImage ? `<img class="macro-image" style="display:none">` : ''}
<div class="macro-image-placeholder">${firstChar}</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
// 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();
}
});
}
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>
</body> </body>
</html> </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 -91
View File
@@ -158,96 +158,6 @@
<p class="status" id="status">Checking connection...</p> <p class="status" id="status">Checking connection...</p>
</div> </div>
<script> <script src="/static/login.js"></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 (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();
</script>
</body> </body>
</html> </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();
+10 -14
View File
@@ -12,26 +12,22 @@ export function createApiProxy(
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId; 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, // Credentials must be supplied via the header (never the URL/query,
// which leaks into logs, history and referrers). // which leaks into logs, history and referrers).
const password = req.headers['x-macropad-password'] as string; const password = req.headers['x-macropad-password'] as string;
if (!password) { // Uniform auth failure. A missing session, a missing password, and a
return res.status(401).json({ error: 'Password required' }); // 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' });
} }
const valid = await sessionManager.validatePassword(sessionId, password); // Only AFTER successful auth do we reveal desktop connectivity, so the
if (!valid) { // 503 "not connected" signal cannot itself be used to probe which
return res.status(401).json({ error: 'Invalid password' }); // session ids exist.
}
// Check desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId); const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (!desktop) { if (!desktop) {
return res.status(503).json({ error: 'Desktop not connected' }); return res.status(503).json({ error: 'Desktop not connected' });
+14 -6
View File
@@ -37,7 +37,8 @@ type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | Po
export function handleDesktopConnection( export function handleDesktopConnection(
socket: WebSocket, socket: WebSocket,
connectionManager: ConnectionManager, connectionManager: ConnectionManager,
sessionManager: SessionManager sessionManager: SessionManager,
clientIp: string
): void { ): void {
let authenticatedSessionId: string | null = null; let authenticatedSessionId: string | null = null;
// Track failed auth attempts on this individual socket. // Track failed auth attempts on this individual socket.
@@ -49,7 +50,7 @@ export function handleDesktopConnection(
switch (message.type) { switch (message.type) {
case 'auth': { case 'auth': {
const failed = await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => { const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => {
authenticatedSessionId = sessionId; authenticatedSessionId = sessionId;
}); });
if (failed) { if (failed) {
@@ -110,6 +111,7 @@ async function handleAuth(
message: AuthMessage, message: AuthMessage,
sessionManager: SessionManager, sessionManager: SessionManager,
connectionManager: ConnectionManager, connectionManager: ConnectionManager,
clientIp: string,
setSessionId: (id: string) => void setSessionId: (id: string) => void
): Promise<boolean> { ): Promise<boolean> {
try { try {
@@ -117,8 +119,14 @@ async function handleAuth(
let session; let session;
if (sessionId) { if (sessionId) {
// Reject early if this session id is currently locked out. // Namespace the brute-force lockout key to the desktop keyspace and
const lockRemaining = getLockoutRemaining(sessionId); // 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) { if (lockRemaining > 0) {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_response', type: 'auth_response',
@@ -132,7 +140,7 @@ async function handleAuth(
// Validate existing session // Validate existing session
const valid = await sessionManager.validatePassword(sessionId, message.password); const valid = await sessionManager.validatePassword(sessionId, message.password);
if (!valid) { if (!valid) {
recordFailure(sessionId); recordFailure(throttleKey);
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_response', type: 'auth_response',
success: false, success: false,
@@ -140,7 +148,7 @@ async function handleAuth(
})); }));
return true; return true;
} }
recordSuccess(sessionId); recordSuccess(throttleKey);
session = sessionManager.getSession(sessionId); session = sessionManager.getSession(sessionId);
} else { } else {
// Create new session (may throw on limit / short password) // Create new session (may throw on limit / short password)
+27 -27
View File
@@ -25,33 +25,26 @@ export function handleWebClientConnection(
socket: WebSocket, socket: WebSocket,
sessionId: string, sessionId: string,
connectionManager: ConnectionManager, connectionManager: ConnectionManager,
sessionManager: SessionManager sessionManager: SessionManager,
clientIp: string
): void { ): void {
// Check if session exists // Namespace the brute-force lockout key to the web keyspace and include the
const session = sessionManager.getSession(sessionId); // client IP. This prevents one abusive IP (which knows the shareable session
if (!session) { // id) from locking out other web clients or the desktop for this session.
socket.send(JSON.stringify({ const throttleKey = `web:${sessionId}:${clientIp}`;
type: 'error',
error: 'Session not found'
}));
socket.close();
return;
}
// 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); const client = connectionManager.addWebClient(sessionId, socket, false);
// Track failed auth attempts on this individual socket. // Track failed auth attempts on this individual socket.
let socketAuthFailures = 0; let socketAuthFailures = 0;
// Check if desktop is connected // Request authentication.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
// Request authentication
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_required' type: 'auth_required'
})); }));
@@ -62,7 +55,7 @@ export function handleWebClientConnection(
switch (message.type) { switch (message.type) {
case 'auth': { case 'auth': {
const failed = await handleAuth(socket, client, message, sessionId, sessionManager); const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
if (failed) { if (failed) {
socketAuthFailures++; socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) { if (socketAuthFailures >= config.authMaxSocketFailures) {
@@ -71,6 +64,12 @@ export function handleWebClientConnection(
} }
} else { } else {
socketAuthFailures = 0; 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; break;
} }
@@ -106,10 +105,11 @@ async function handleAuth(
client: WebClientConnection, client: WebClientConnection,
message: AuthMessage, message: AuthMessage,
sessionId: string, sessionId: string,
sessionManager: SessionManager sessionManager: SessionManager,
throttleKey: string
): Promise<boolean> { ): Promise<boolean> {
// Reject early if this session id is currently locked out. // Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(sessionId); const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) { if (lockRemaining > 0) {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_response', type: 'auth_response',
@@ -123,7 +123,7 @@ async function handleAuth(
const valid = await sessionManager.validatePassword(sessionId, message.password); const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) { if (valid) {
recordSuccess(sessionId); recordSuccess(throttleKey);
client.authenticated = true; client.authenticated = true;
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_response', type: 'auth_response',
@@ -132,11 +132,11 @@ async function handleAuth(
logger.debug(`Web client authenticated for session: ${sessionId}`); logger.debug(`Web client authenticated for session: ${sessionId}`);
return false; return false;
} else { } else {
recordFailure(sessionId); recordFailure(throttleKey);
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'auth_response', type: 'auth_response',
success: false, success: false,
error: 'Invalid password' error: 'Authentication failed'
})); }));
return true; return true;
} }
+8 -5
View File
@@ -89,12 +89,15 @@ export function createServer() {
// Middleware // Middleware
app.use(helmet({ app.use(helmet({
// The bundled login/app pages rely on inline <script> and inline event // Page scripts are served as external files (/static/app.js,
// handlers, so script/style inline is permitted; images may be blobs. // /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: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'", 'ws:', 'wss:'], connectSrc: ["'self'", 'ws:', 'wss:'],
@@ -192,7 +195,7 @@ export function createServer() {
// Desktop connection: /desktop // Desktop connection: /desktop
if (pathname === '/desktop') { if (pathname === '/desktop') {
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
handleDesktopConnection(ws, connectionManager, sessionManager); handleDesktopConnection(ws, connectionManager, sessionManager, ip);
}); });
return; return;
} }
@@ -202,7 +205,7 @@ export function createServer() {
if (webClientMatch) { if (webClientMatch) {
const sessionId = webClientMatch[1]; const sessionId = webClientMatch[1];
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager); handleWebClientConnection(ws, sessionId, connectionManager, sessionManager, ip);
}); });
return; return;
} }
@@ -136,6 +136,21 @@ export class SessionManager {
} while (this.sessions.has(id)); } while (this.sessions.has(id));
const passwordHash = await bcrypt.hash(password, config.bcryptRounds); 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 now = new Date().toISOString();
const session: Session = { const session: Session = {
+13 -9
View File
@@ -1,8 +1,12 @@
// Per-session-id auth brute-force lockout (in-memory). // Auth brute-force lockout (in-memory).
// //
// Tracks failed authentication attempts keyed by session id. After // Tracks failed authentication attempts keyed by an opaque, namespaced key
// `authLockoutMaxAttempts` failures within `authLockoutWindowMs`, further // (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`).
// attempts are rejected until `authLockoutDurationMs` has elapsed. // 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'; import { config } from '../config';
@@ -29,10 +33,12 @@ export function getLockoutRemaining(key: string): number {
} }
/** /**
* Record a failed auth attempt. Returns the lockout remaining (ms) after * Record a failed auth attempt. Locks the key out for
* this failure is applied (0 if still not locked). * `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
* within `authLockoutWindowMs`. Callers query the resulting lockout via
* getLockoutRemaining(), so this returns nothing.
*/ */
export function recordFailure(key: string): number { export function recordFailure(key: string): void {
const now = Date.now(); const now = Date.now();
let rec = records.get(key); let rec = records.get(key);
@@ -47,8 +53,6 @@ export function recordFailure(key: string): number {
if (rec.count >= config.authLockoutMaxAttempts) { if (rec.count >= config.authLockoutMaxAttempts) {
rec.lockedUntil = now + config.authLockoutDurationMs; rec.lockedUntil = now + config.authLockoutDurationMs;
} }
return rec.lockedUntil > now ? rec.lockedUntil - now : 0;
} }
/** /**
+5 -1
View File
@@ -18,7 +18,11 @@ export function generateSessionId(length: number = 12): string {
let result = ''; let result = '';
while (result.length < length) { while (result.length < length) {
// Over-fetch a little to reduce the number of syscalls when rejecting. // 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); const bytes = randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) { for (let i = 0; i < bytes.length && result.length < length; i++) {
const byte = bytes[i]; const byte = bytes[i];
+4 -2
View File
@@ -45,9 +45,11 @@ a = Analysis(
'pyautogui', 'pyautogui',
'pyperclip', 'pyperclip',
'pystray', 'pystray',
'netifaces',
'websockets', 'websockets',
'multipart', 'multipart',
# Relay client (imported lazily in the GUI, so declare explicitly)
'relay_client',
'aiohttp',
], ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
@@ -78,5 +80,5 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon='Macro Pad.png', icon='Macro Pad.ico',
) )
-1
View File
@@ -49,7 +49,6 @@ a = Analysis(
'pyperclip', 'pyperclip',
'pystray', 'pystray',
'pystray._base', 'pystray._base',
'netifaces',
'websockets', 'websockets',
'multipart', 'multipart',
# Linux system tray # Linux system tray
-1
View File
@@ -45,7 +45,6 @@ a = Analysis(
'pyautogui', 'pyautogui',
'pyperclip', 'pyperclip',
'pystray', 'pystray',
'netifaces',
'websockets', 'websockets',
'multipart', 'multipart',
], ],
-2
View File
@@ -23,8 +23,6 @@ dependencies = [
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
"websockets>=12.0", "websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads "python-multipart>=0.0.6", # For file uploads
# Network utilities
"netifaces>=0.11.0",
# QR code generation # QR code generation
"qrcode>=7.4.2", "qrcode>=7.4.2",
# Desktop GUI # Desktop GUI
+1 -1
View File
@@ -1 +1 @@
1.0.0 1.1.1
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <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' ws: wss:; object-src 'none'; base-uri 'none'"> <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="#15161a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
<meta name="description" content="Remote macro control for your desktop"> <meta name="description" content="Remote macro control for your desktop">
+78 -9
View File
@@ -34,6 +34,13 @@ class MacroPadApp {
// Guards against out-of-order macro fetches // Guards against out-of-order macro fetches
this._macroReqId = 0; 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) // Macro editor state (null = create mode)
this.editingMacroId = null; this.editingMacroId = null;
@@ -204,12 +211,44 @@ class MacroPadApp {
} }
handleAuthError() { handleAuthError() {
this.showToast('Authentication failed', 'error');
if (this.relayMode) { if (this.relayMode) {
this.showToast('Authentication failed', 'error');
// Clear stored password and redirect to login // Clear stored password and redirect to login
sessionStorage.removeItem(`macropad_${this.sessionId}`); sessionStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${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() { handleDesktopDisconnected() {
@@ -396,15 +435,38 @@ class MacroPadApp {
macroImageUrl(macro) { macroImageUrl(macro) {
if (!macro.image_path) return null; if (!macro.image_path) return null;
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`); // No credential ever goes on the URL; auth is via a header on a
// Credentials go as query params since <img> can't set headers. // 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) { if (this.relayMode && this.password) {
return `${basePath}?password=${encodeURIComponent(this.password)}`; headers['X-MacroPad-Password'] = this.password;
} else if (!this.relayMode && this.localToken) {
headers['X-MacroPad-Token'] = this.localToken;
} }
if (!this.relayMode && this.localToken) { return headers;
return `${basePath}?token=${encodeURIComponent(this.localToken)}`; }
// 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 = '';
} }
return basePath;
} }
// Rendering (safe DOM construction only - no user value reaches innerHTML) // Rendering (safe DOM construction only - no user value reaches innerHTML)
@@ -430,6 +492,12 @@ class MacroPadApp {
const container = document.getElementById('macro-grid'); const container = document.getElementById('macro-grid');
if (!container) return; if (!container) return;
// 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 = ''; container.textContent = '';
const macroEntries = Object.entries(this.macros); const macroEntries = Object.entries(this.macros);
@@ -464,14 +532,15 @@ class MacroPadApp {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'macro-image'; img.className = 'macro-image';
img.alt = ''; img.alt = '';
img.src = imageSrc;
placeholder.style.display = 'none'; placeholder.style.display = 'none';
// Fall back to the placeholder if the image fails to load. // Fall back to the placeholder if the (blob) image fails to load.
img.addEventListener('error', () => { img.addEventListener('error', () => {
img.style.display = 'none'; img.style.display = 'none';
placeholder.style.display = ''; placeholder.style.display = '';
}); });
card.appendChild(img); card.appendChild(img);
// Header-authenticated fetch -> blob object URL (no credential on src).
this.loadMacroImage(imageSrc, img, placeholder);
} }
card.appendChild(placeholder); card.appendChild(placeholder);