8 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
14 changed files with 137 additions and 61 deletions
+12
View File
@@ -87,9 +87,21 @@ jobs:
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
-1
View File
@@ -39,7 +39,6 @@ A cross-platform macro management application with desktop and web interfaces. C
- PyAutoGUI (Keyboard automation)
- Pillow (Image processing)
- pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
- aiohttp (Relay server client)
+1 -1
View File
@@ -1,6 +1,6 @@
# Configuration and constants for MacroPad Server
VERSION = "1.1.0"
VERSION = "1.1.2"
DEFAULT_PORT = 40000
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.
token = self.settings_manager.get_web_auth_token()
token_qs = f"?token={token}" if token else ""
ip = self._detect_lan_ip()
if ip:
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
return
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
@staticmethod
def _detect_lan_ip():
"""Best-effort primary LAN IPv4, with no third-party dependency.
Uses a UDP socket to discover which local interface would be used to
reach the internet (no packets are actually sent), then falls back to
resolving the hostname. Returns None if only loopback is available.
"""
import socket
try:
import netifaces
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr', '')
if ip and not ip.startswith('127.'):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
return
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
except Exception:
pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{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):
"""Copy the web interface URL to clipboard."""
+14 -6
View File
@@ -416,15 +416,23 @@ class MacroManager:
elif cmd_type == "app":
# Launch application.
# SECURITY: shell=True was removed to kill shell-metacharacter
# injection (no ; && | $() chaining). We tokenize the command
# and exec the program directly without a shell.
# SECURITY: never use shell=True — that would allow shell-metacharacter
# injection (; && | $() chaining, redirection). Both branches below run
# without a shell, so the command can only launch a program with args.
command = cmd.get("command", "")
if command:
try:
args = shlex.split(command, posix=(os.name != "nt"))
if args:
subprocess.Popen(args)
if os.name == "nt":
# Windows: pass the string so CreateProcess parses it
# (correctly handling quoted paths like "C:\Program
# Files\app.exe"). shell=False means no cmd.exe, so no
# metacharacter chaining.
subprocess.Popen(command)
else:
# POSIX: split into an argv list; no shell involved.
args = shlex.split(command)
if args:
subprocess.Popen(args)
except Exception as e:
print(f"Error launching app command: {e}")
+45 -20
View File
@@ -7,6 +7,11 @@ class MacroPadApp {
this.tabs = [];
this.currentTab = 'All';
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.wsAuthenticated = false;
@@ -249,35 +254,55 @@ class MacroPadApp {
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() {
// Fetch each macro image with the password header and display it as a blob
// object URL so the credential never appears in a URL. Images are fetched
// 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)) {
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.
}
this._applyMacroImage(img, macro.image_path);
}
}
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() {
document.getElementById('tabs-container').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
+4
View File
@@ -49,6 +49,10 @@ export function createApiProxy(
if (response.body?.base64 && response.body?.contentType) {
const buffer = Buffer.from(response.body.base64, 'base64');
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);
} else {
res.status(response.status).json(response.body);
-1
View File
@@ -45,7 +45,6 @@ a = Analysis(
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
# Relay client (imported lazily in the GUI, so declare explicitly)
-1
View File
@@ -49,7 +49,6 @@ a = Analysis(
'pyperclip',
'pystray',
'pystray._base',
'netifaces',
'websockets',
'multipart',
# Linux system tray
-1
View File
@@ -45,7 +45,6 @@ a = Analysis(
'pyautogui',
'pyperclip',
'pystray',
'netifaces',
'websockets',
'multipart',
],
-2
View File
@@ -23,8 +23,6 @@ dependencies = [
"uvicorn>=0.24.0",
"websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads
# Network utilities
"netifaces>=0.11.0",
# QR code generation
"qrcode>=7.4.2",
# Desktop GUI
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.1.2
+25 -16
View File
@@ -34,8 +34,10 @@ class MacroPadApp {
// Guards against out-of-order macro fetches
this._macroReqId = 0;
// Blob object URLs created for macro images (revoked on re-render)
this._objectUrls = [];
// Cache of image URL -> Promise<objectURL>. Macro images are
// 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
// reconnect loops and repeated toasts.
@@ -451,16 +453,12 @@ class MacroPadApp {
return headers;
}
// Fetch a macro image with the auth header and display it as a blob
// object URL, so no credential is ever placed on an <img> src.
// 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. The fetched blob is
// cached (images are immutable per URL) and reused across re-renders.
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);
const objectUrl = await this._getImageUrl(url);
img.src = objectUrl;
} catch (e) {
// 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)
renderTabs() {
const container = document.getElementById('tabs-container');
@@ -492,12 +507,6 @@ class MacroPadApp {
const container = document.getElementById('macro-grid');
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 = '';
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
if (os.path.commonpath([requested, images_real]) == images_real
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")
@app.websocket("/ws")