18 Commits
Author SHA1 Message Date
jknapp 7625053a6c Merge pull request 'perf(web): cache macro images in local PWA (1.1.2)' (#12) from perf/local-image-cache into main
Build Windows / preflight (push) Successful in 1s
Build Windows / build-windows (push) Successful in 5m15s
2026-07-18 06:29:20 +00:00
shadowdaoandClaude Opus 4.8 6218a46b57 perf(web): cache macro images in local PWA, bump to 1.1.2
Same fix as the relay: the local PWA re-fetched every macro image and revoked
the blob on each re-render. Cache image URL -> Promise<objectURL> and reuse
across renders (images are immutable per uuid path); add Cache-Control:
immutable on the desktop /api/image response. Verified in a browser: 7
re-renders produced only the initial 3 image fetches, none repeated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:29:16 -07:00
jknapp e4f0471614 Merge pull request 'perf(relay): cache macro images (faster display)' (#11) from perf/relay-image-cache into main 2026-07-18 06:22:08 +00:00
shadowdaoandClaude Opus 4.8 2bbbc5e283 perf(relay): cache macro images instead of re-fetching every render
The relay web client re-fetched every macro image through the relay->desktop
proxy on each render (tab switch, WS update) and revoked the object URL on
load, so nothing was reused and fetches were serial — slow to display.

- Client: cache image_path -> Promise<objectURL> and reuse across renders
  (macro images are content-addressed by uuid, so immutable); fetch in
  parallel and de-duplicate concurrent requests.
- Proxy: send Cache-Control: private, max-age=31536000, immutable on image
  responses so the browser also disk-caches them across reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:22:04 -07:00
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
16 changed files with 289 additions and 221 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

-1
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)
+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.2"
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}")
+45 -20
View File
@@ -7,6 +7,11 @@ class MacroPadApp {
this.tabs = []; this.tabs = [];
this.currentTab = 'All'; this.currentTab = 'All';
this.ws = null; this.ws = null;
// Cache of image_path -> Promise<objectURL>. Macro images are
// content-addressed by uuid filename, so they are immutable: once
// fetched, an image never changes for a given path. Caching here avoids
// re-fetching every image through the relay on each re-render.
this._imageCache = new Map();
this.desktopConnected = false; this.desktopConnected = false;
this.wsAuthenticated = false; this.wsAuthenticated = false;
@@ -249,35 +254,55 @@ class MacroPadApp {
this.loadMacroImages(); this.loadMacroImages();
} }
// Fetch each macro image with the password header and display it // Fetch each macro image with the password header and display it as a blob
// as a blob object URL so the credential never appears in a URL. // object URL so the credential never appears in a URL. Images are fetched
async loadMacroImages() { // in parallel and cached (see _imageCache) so re-renders reuse them instead
// of re-fetching through the relay each time.
loadMacroImages() {
for (const [id, macro] of Object.entries(this.macros)) { for (const [id, macro] of Object.entries(this.macros)) {
if (!macro.image_path) continue; if (!macro.image_path) continue;
const card = document.querySelector(`[data-macro-id="${id}"]`); const card = document.querySelector(`[data-macro-id="${id}"]`);
if (!card) continue; if (!card) continue;
const img = card.querySelector('.macro-image'); const img = card.querySelector('.macro-image');
if (!img) continue; if (!img) continue;
this._applyMacroImage(img, macro.image_path);
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.
}
} }
} }
async _applyMacroImage(img, imagePath) {
try {
const objectUrl = await this._getImageUrl(imagePath);
img.src = objectUrl;
img.style.display = '';
const placeholder = img.nextElementSibling;
if (placeholder) placeholder.style.display = 'none';
} catch (error) {
// Leave the placeholder visible on failure.
}
}
// Returns a cached Promise<objectURL> for an image path, fetching once.
// The object URL is retained for the page's lifetime (images are immutable
// per path, so there is nothing to invalidate); this de-duplicates
// concurrent requests and eliminates re-fetching on re-render.
_getImageUrl(imagePath) {
let entry = this._imageCache.get(imagePath);
if (entry) return entry;
entry = (async () => {
const response = await fetch(
`/${this.sessionId}/api/image/${imagePath}`,
{ headers: this.getApiHeaders() }
);
if (!response.ok) throw new Error(`image ${response.status}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
})();
// Drop failed fetches from the cache so a later render can retry.
entry.catch(() => this._imageCache.delete(imagePath));
this._imageCache.set(imagePath, entry);
return entry;
}
setupEventListeners() { setupEventListeners() {
document.getElementById('tabs-container').addEventListener('click', (e) => { document.getElementById('tabs-container').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) { if (e.target.classList.contains('tab')) {
+4
View File
@@ -49,6 +49,10 @@ export function createApiProxy(
if (response.body?.base64 && response.body?.contentType) { if (response.body?.base64 && response.body?.contentType) {
const buffer = Buffer.from(response.body.base64, 'base64'); const buffer = Buffer.from(response.body.base64, 'base64');
res.set('Content-Type', response.body.contentType); res.set('Content-Type', response.body.contentType);
// Macro images are content-addressed (uuid filenames) and therefore
// immutable, so let the browser cache them aggressively. 'private'
// keeps them out of shared proxy caches since they sit behind auth.
res.set('Cache-Control', 'private, max-age=31536000, immutable');
res.send(buffer); res.send(buffer);
} else { } else {
res.status(response.status).json(response.body); res.status(response.status).json(response.body);
+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.2
+25 -16
View File
@@ -34,8 +34,10 @@ 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) // Cache of image URL -> Promise<objectURL>. Macro images are
this._objectUrls = []; // content-addressed by uuid filename, so they are immutable; caching
// avoids re-fetching every image on each re-render.
this._imageCache = new Map();
// Set once a local-mode auth failure has been surfaced, to avoid // Set once a local-mode auth failure has been surfaced, to avoid
// reconnect loops and repeated toasts. // reconnect loops and repeated toasts.
@@ -451,16 +453,12 @@ class MacroPadApp {
return headers; return headers;
} }
// Fetch a macro image with the auth header and display it as a blob // Fetch a macro image with the auth header and display it as a blob object
// object URL, so no credential is ever placed on an <img> src. // URL, so no credential is ever placed on an <img> src. The fetched blob is
// cached (images are immutable per URL) and reused across re-renders.
async loadMacroImage(url, img, placeholder) { async loadMacroImage(url, img, placeholder) {
try { try {
const res = await fetch(url, { headers: this.getAuthHeaders() }); const objectUrl = await this._getImageUrl(url);
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; img.src = objectUrl;
} catch (e) { } catch (e) {
// Fall back to the placeholder if the image can't be loaded. // Fall back to the placeholder if the image can't be loaded.
@@ -469,6 +467,23 @@ class MacroPadApp {
} }
} }
// Returns a cached Promise<objectURL> for an image URL, fetching once and
// retaining it for the page's lifetime (immutable content, nothing to
// invalidate); de-duplicates concurrent requests too.
_getImageUrl(url) {
let entry = this._imageCache.get(url);
if (entry) return entry;
entry = (async () => {
const res = await fetch(url, { headers: this.getAuthHeaders() });
if (!res.ok) throw new Error('image request failed');
const blob = await res.blob();
return URL.createObjectURL(blob);
})();
entry.catch(() => this._imageCache.delete(url));
this._imageCache.set(url, entry);
return entry;
}
// Rendering (safe DOM construction only - no user value reaches innerHTML) // Rendering (safe DOM construction only - no user value reaches innerHTML)
renderTabs() { renderTabs() {
const container = document.getElementById('tabs-container'); const container = document.getElementById('tabs-container');
@@ -492,12 +507,6 @@ 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);
+5 -1
View File
@@ -338,7 +338,11 @@ class WebServer:
# Only serve files that resolve to inside the macro_images directory # Only serve files that resolve to inside the macro_images directory
if (os.path.commonpath([requested, images_real]) == images_real if (os.path.commonpath([requested, images_real]) == images_real
and os.path.isfile(requested)): and os.path.isfile(requested)):
return FileResponse(requested) # Macro images are content-addressed (uuid filenames) and thus
# immutable, so let the browser cache them aggressively.
return FileResponse(requested, headers={
"Cache-Control": "private, max-age=31536000, immutable"
})
raise HTTPException(status_code=404, detail="Image not found") raise HTTPException(status_code=404, detail="Image not found")
@app.websocket("/ws") @app.websocket("/ws")