64 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:14:56 -07:00
shadowdao 7922910bd8 Update README and version for v1.0.0 release
- Add relay server documentation and setup instructions
- Add relay_client.py to project structure
- Add aiohttp to core dependencies
- Update version.txt to 1.0.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:06:52 -08:00
shadowdao 664d652e9e Fix Copied! visibility and remove URL hover effect
- Use visible gray (#888888) for "Copied!" text
- Remove hover color change from URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:48:45 -08:00
shadowdao 10971e6a02 Remove version from title bar, improve copy feedback
- Remove version number from window title (still shown in About)
- Show "Copied!" over URL text with dimmed style instead of status bar message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:44:31 -08:00
shadowdao 17f4bc0c5f Make URL clickable to copy instead of separate button
Cleaner interface - URL text now highlights on hover and copies to
clipboard when clicked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:33:14 -08:00
shadowdao 44c21e68d8 Release v1.0.0 with UI improvements and bug fixes
- Fix window restore when clicking Show from system tray or double-clicking
- Add Copy button for web interface URL
- Fix image loading when app starts with Windows startup
- Remove debug print statements
- Bump version to 1.0.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:26:29 -08:00
shadowdao 6f3823eccf Add PWA, wake lock, and fullscreen to relay app
- Add wake lock (keep screen awake) functionality
- Add fullscreen toggle button
- Add dynamic PWA manifest generation
- Add favicon and icons for all relay pages
- Copy icons from main web folder

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:04:03 -08:00
shadowdao 4a93f94b8c Fix relay URL update using Qt signals for thread safety
QTimer.singleShot doesn't work properly from non-Qt threads.
Use Qt signals instead which are thread-safe and properly marshal
calls to the main thread.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:00:30 -08:00
shadowdao f87dab6bc2 Add debug output for relay URL update issue
- Always trigger on_session_id callback when server returns session ID
- Add debug prints to trace URL update flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:56:30 -08:00
shadowdao 5b6eb33bad Fix image authentication in relay app.html
Add password query parameter to image URLs in the relay server's
app.html - this file has its own inline JavaScript separate from
the main web/js/app.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:51:37 -08:00
shadowdao 7d95d47c73 Update relay server defaults and repo links
- Update index.html links to repo.anhonesthost.net
- Adjust rate limit defaults in .env.example

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:47:32 -08:00
shadowdao 59254383ad Fix relay server issues: images and URL display
1. Images not showing through relay:
   - Use getApiUrl() for image paths in relay mode
   - Add password as query param for img tags (can't use headers)

2. URL not updating in desktop app:
   - Set _connected=True before on_session_id callback fires
   - Ensures update_ip_label() shows relay URL immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:42:45 -08:00
shadowdao 5aed19564c Add index landing page for relay server
- Add index.html explaining what MacroPad Relay is
- Add /ping endpoint for container health checks
- Add route for index page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:33:03 -08:00
shadowdao 6e76d469c8 Add .env.example for relay server configuration
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:27:06 -08:00
shadowdao ff3c7b990c Fix relay server for cloud-node-container deployment
- Add postinstall script to build TypeScript automatically
- Move typescript to dependencies (needed during npm install)
- Update main and start to point to dist/index.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:23:31 -08:00
shadowdao 1d7f18018d Add relay server for remote HTTPS access
Node.js/TypeScript relay server that enables remote access to MacroPad:
- WebSocket-based communication between desktop and relay
- Password authentication with bcrypt hashing
- Session management with consistent IDs
- REST API proxying to desktop app
- Web client WebSocket relay
- Login page and PWA-ready app page
- Designed for cloud-node-container deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:46:33 -08:00
shadowdao 8e4c32fea4 Add v0.9.5 features: minimize to tray, settings, relay support
## New Features

### Minimize to Tray
- Window minimizes to system tray instead of taskbar
- Tray notification shown when minimized
- Double-click tray icon to restore

### Settings System
- New settings dialog (Edit > Settings or Ctrl+,)
- JSON-based settings persistence
- General tab: minimize to tray toggle
- Relay Server tab: enable/configure relay connection

### Relay Server Support
- New relay_client.py for connecting to relay server
- WebSocket client with auto-reconnection
- Forwards API requests to local server
- Updates QR code/URL when relay connected

### PWA Updates
- Added relay mode detection and authentication
- Password passed via header for API requests
- WebSocket authentication for relay connections
- Desktop status handling (connected/disconnected)
- Wake lock icon now always visible with status indicator

## Files Added
- gui/settings_manager.py
- gui/settings_dialog.py
- relay_client.py

## Dependencies
- Added aiohttp>=3.9.0 for relay client

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:33:07 -08:00
shadowdao 6974947028 Bump version to 0.9.3 2026-01-04 12:28:13 -08:00
shadowdao 517ee943a9 Fix hotkey execution reliability on Windows
- Add interval parameter (50ms) between key presses in pyautogui.hotkey()
- Add small delay before hotkey execution for better Windows compatibility
- Add defensive check to handle keys stored as string instead of list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:18:17 -08:00
shadowdao c9d0c9812d Bump version to 0.9.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:02:36 -08:00
shadowdao 3521f777e9 Fix author name capitalization to ShadowDao
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:00:32 -08:00
shadowdao 8ce09fcaf6 Add author, updates, and donation links to About dialog
- Author link: shadowdao.com
- Updates link: shadowdao.com
- Donate link: liberapay.com/GoTakeAKnapp/
- Links are clickable and open in default browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:59:57 -08:00
shadowdao a1a7334772 Add examples to App command dialog
Show platform-specific examples when adding or editing an App command:
- Windows: notepad.exe, paths with quotes
- Linux: firefox with URL
- macOS: open -a Safari

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:56:24 -08:00
shadowdao 063949cd7d Add fullscreen toggle and wake lock to prevent screen sleep
## Features
- Fullscreen button (⛶) in header to toggle fullscreen mode
- Wake Lock API integration to keep screen on while using the app
- Sun icon (☀) indicator shows wake lock status (bright = active)

## Wake Lock behavior
- Automatically requests wake lock when page loads
- Re-acquires wake lock when returning to the page
- Visual indicator pulses when active
- Gracefully hidden if Wake Lock API not supported

## Fullscreen
- Works on Android Chrome and desktop browsers
- iOS Safari has limited support (no fullscreen API)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:34:56 -08:00
shadowdao da5d2d6ded Fix PWA to open as standalone app instead of browser tab
## manifest.json
- Add `id` and `scope` fields for proper PWA identification
- Split icon purposes into separate entries (was "any maskable", now separate)
- Add `prefer_related_applications: false`

## index.html
- Add `viewport-fit=cover` for notched devices
- Add `mobile-web-app-capable` meta tag
- Add `application-name` and `msapplication` meta tags
- Add both 192px and 512px apple-touch-icon sizes

## styles.css
- Add safe-area-inset padding for notched devices (iPhone X+)
- Use 100dvh for proper mobile viewport height
- Add bottom safe area to toast container and macro grid

## service-worker.js
- Bump cache version to v2 to force update

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:25:17 -08:00
shadowdao b37def8fec Add category dropdown with existing categories in macro editor
- Replace QLineEdit with editable QComboBox for category field
- Populate dropdown with existing categories from saved macros
- Allow typing new category names (editable combo box)
- Add styled dropdown arrow and item view to match dark theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:05:10 -08:00
shadowdao efabf55eae Fix macro not executing correctly on first run after save
The commands list was stored as a shallow copy, meaning the individual
command dicts were shared references with the CommandBuilder widget.
When the dialog closed, these shared objects could be affected.

Fix: Use copy.deepcopy() when storing commands in add_macro() and
update_macro() to ensure the macro manager has its own independent
copy of all command data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:57:12 -08:00
shadowdao a3d5a9d001 Fix command button height to prevent text cutoff
- Set fixed height of 28px on all command action buttons
- Increase vertical padding from 4px to 6px
- Add min-height: 20px in stylesheet for safety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:53:36 -08:00
shadowdao 256e8c109c Simplify web interface to execute-only, improve desktop editor UX
## Web Interface
- Remove Add/Edit functionality from web interface (execute-only now)
- Remove modal dialog and command builder
- Simplified JS from 480 to 267 lines
- Users can still create/edit macros in the desktop app

## Desktop Editor
- Fix Edit button padding (set fixed width of 50px)
- Capitalize key options for better readability (Enter, Tab, etc.)
- Display keys capitalized in command list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:48:21 -08:00
shadowdao a71c1f5ec4 Fix PyInstaller build issues and add right-click edit
## Fixes
- Web interface now loads correctly in built app (use sys._MEIPASS for bundled web files)
- Macro execution no longer locks up (use pyperclip clipboard for Unicode text support)
- Right-click context menu works (use Qt signals instead of fragile parent traversal)

## Changes
- web_server.py: Use get_resource_path() for web directory
- macro_manager.py: Use clipboard paste for text commands instead of typewrite
- gui/main_window.py: Add edit_requested/delete_requested signals to MacroButton
- pyproject.toml: Add pyperclip dependency
- All .spec files: Add pyperclip to hidden imports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:37:43 -08:00
shadowdao f61df830ca Fix uvicorn logging error in PyInstaller builds
Set log_config=None to disable uvicorn's default logging configuration
which fails in frozen executables with "Unable to configure formatter 'default'"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:25:41 -08:00
shadowdao f6743339c0 Add web server error handling and proper shutdown
- Add stop() method to WebServer class
- Store server instance for graceful shutdown
- Add error handling in start_server() with user-friendly alert
- Show warning dialog if server fails to start (e.g., port in use)
- Stop server in closeEvent before application exit
- Add status bar messages for server state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:15:38 -08:00
shadowdao 418407b2b9 Fix icon loading in PyInstaller builds
PyInstaller bundles data files into sys._MEIPASS, not the executable directory.

- Add get_resource_path() helper in main.py and main_window.py
- Use _MEIPASS for bundled resources (icon, web files)
- Keep app_dir pointing to executable directory for user data (macros.json)

This fixes the missing icon in taskbar and system tray on Windows builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:12:39 -08:00
shadowdao 9bba673ba7 Fix PySide6 enum access for Windows compatibility
- Import QStyle from PySide6.QtWidgets
- Use QStyle.StandardPixmap.SP_ComputerIcon instead of self.style().SP_ComputerIcon
- Use QSystemTrayIcon.ActivationReason.DoubleClick instead of QSystemTrayIcon.DoubleClick

PySide6 requires fully qualified enum paths unlike PyQt5.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:07:49 -08:00
shadowdao dbdb465fde Add BUILDING.md with uv and PyInstaller build instructions
Comprehensive guide covering:
- Installing uv on Windows/Linux/macOS
- Building executables with uv run pyinstaller
- Platform-specific build commands
- Troubleshooting common issues
- Detailed cleanup instructions for build artifacts
- Clean rebuild commands for all platforms
- Alternative pip-based build approach

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:05:50 -08:00
shadowdao 5888aeb603 Modernize application to v0.9.0 with PySide6, FastAPI, and PWA support
## Major Changes

### Build System
- Replace requirements.txt with pyproject.toml for modern dependency management
- Support for uv package manager alongside pip
- Update PyInstaller spec files for new dependencies and structure

### Desktop GUI (Tkinter → PySide6)
- Complete rewrite of UI using PySide6/Qt6
- New modular structure in gui/ directory:
  - main_window.py: Main application window
  - macro_editor.py: Macro creation/editing dialog
  - command_builder.py: Visual command sequence builder
- Modern dark theme with consistent styling
- System tray integration

### Web Server (Flask → FastAPI)
- Migrate from Flask/Waitress to FastAPI/Uvicorn
- Add WebSocket support for real-time updates
- Full CRUD API for macro management
- Image upload endpoint

### Web Interface → PWA
- New web/ directory with standalone static files
- PWA manifest and service worker for installability
- Offline caching support
- Full macro editing from web interface
- Responsive mobile-first design
- Command builder UI matching desktop functionality

### Macro System Enhancement
- New command sequence model replacing simple text/app types
- Command types: text, key, hotkey, wait, app
- Support for delays between commands (wait in ms)
- Support for key presses between commands (enter, tab, etc.)
- Automatic migration of existing macros to new format
- Backward compatibility maintained

### Files Added
- pyproject.toml
- gui/__init__.py, main_window.py, macro_editor.py, command_builder.py
- gui/widgets/__init__.py
- web/index.html, manifest.json, service-worker.js
- web/css/styles.css, web/js/app.js
- web/icons/icon-192.png, icon-512.png

### Files Removed
- requirements.txt (replaced by pyproject.toml)
- ui_components.py (replaced by gui/ modules)
- web_templates.py (replaced by web/ static files)
- main.spec (consolidated into platform-specific specs)

### Files Modified
- main.py: Simplified entry point for PySide6
- macro_manager.py: Command sequence model and migration
- web_server.py: FastAPI implementation
- config.py: Version bump to 0.9.0
- All .spec files: Updated for PySide6 and new structure
- README.md: Complete rewrite for v0.9.0
- .gitea/workflows/release.yml: Disabled pending build testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:57:14 -08:00
jknapp ded281cc64 Merge pull request 'Working on the Linux Build Issues' (#5) from fix-linux-update into main
Build and Release / create-release (push) Successful in 3s
Build and Release / build-windows (push) Successful in 36s
Build and Release / build-linux (push) Successful in 1m7s
Build and Release / attach-to-release (push) Successful in 4s
Reviewed-on: #5
2025-06-05 22:21:49 +00:00
shadowdao f0af9ad84e Working on the Linux Build Issues 2025-06-05 15:21:34 -07:00
jknapp 00120c8562 Merge pull request 'fixes Linux bugs' (#4) from update-build-and-docs into main
Build and Release / create-release (push) Successful in 4s
Build and Release / build-windows (push) Successful in 39s
Build and Release / build-linux (push) Failing after 1m1s
Build and Release / attach-to-release (push) Has been skipped
Reviewed-on: #4
2025-06-05 22:13:17 +00:00
shadowdao 073bfbf7d9 fixes Linux bugs 2025-06-05 15:12:31 -07:00
jknapp c4d151a6d2 Merge pull request 'Fix build errors' (#3) from update-build-and-docs into main
Build and Release / create-release (push) Successful in 4s
Build and Release / build-linux (push) Successful in 33s
Build and Release / build-windows (push) Successful in 4m19s
Build and Release / attach-to-release (push) Successful in 9s
Reviewed-on: #3
2025-06-05 21:39:27 +00:00
shadowdao 45dfe44b59 Fix build errors 2025-06-05 14:39:00 -07:00
60 changed files with 11606 additions and 1625 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"
-140
View File
@@ -1,140 +0,0 @@
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 -r requirements.txt
- 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 dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r requirements.txt
- 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 is temporarily disabled
# Uncomment this section when macOS build environment becomes available
#
# 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 -r requirements.txt
#
# - 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
# macropad-macos/macropad.app/**/*
+239
View File
@@ -0,0 +1,239 @@
# Building MacroPad Server
This guide explains how to build standalone executables for MacroPad Server using `uv` and PyInstaller.
## Prerequisites
### Install uv
**Windows (PowerShell):**
```powershell
irm https://astral.sh/uv/install.ps1 | iex
```
**Linux/macOS:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
### Clone the Repository
```bash
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
```
## Building Executables
> **Important:** PyInstaller cannot cross-compile. You must build on the target platform:
> - Windows executables → build on Windows
> - Linux executables → build on Linux
> - macOS executables → build on macOS
### Step 1: Install Dependencies
```bash
uv sync
```
### Step 2: Add PyInstaller
```bash
uv add --dev pyinstaller
```
### Step 3: Build for Your Platform
#### Windows
```powershell
uv run pyinstaller macropad.spec
```
The executable will be created at: `dist\macropad.exe`
#### Linux
```bash
# Install system dependencies first (Ubuntu/Debian)
sudo apt-get update
sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1
# Build
uv run pyinstaller macropad_linux.spec
```
The executable will be created at: `dist/macropad`
#### macOS
```bash
uv run pyinstaller macropad_macos.spec
```
The app bundle will be created at: `dist/MacroPad Server.app`
## Build Output
After a successful build, you'll find the executable in the `dist/` directory:
| Platform | Output |
|----------|--------|
| Windows | `dist/macropad.exe` |
| Linux | `dist/macropad` |
| macOS | `dist/MacroPad Server.app` |
## Troubleshooting
### Missing Hidden Imports
If you get import errors when running the built executable, you may need to add hidden imports to the `.spec` file:
```python
hiddenimports=[
'missing_module_name',
# ... existing imports
],
```
### Linux: PySide6 Display Issues
Ensure you have the required Qt libraries:
```bash
sudo apt-get install -y \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libegl1 \
libxcb-cursor0
```
### Linux: System Tray Issues
For system tray support on Linux:
```bash
sudo apt-get install -y \
libgtk-3-dev \
gir1.2-appindicator3-0.1
```
### Windows: Antivirus False Positives
PyInstaller executables may trigger antivirus warnings. This is a known issue with PyInstaller-built applications. You may need to add an exception in your antivirus software.
### macOS: Unsigned Application
The built app is unsigned and will trigger Gatekeeper:
1. Right-click the app → Open
2. Or: `xattr -cr "dist/MacroPad Server.app"`
## Development Builds
For debugging, you can enable console output by editing the `.spec` file:
```python
exe = EXE(
...
console=True, # Change from False to True
...
)
```
## Cleaning Up
PyInstaller creates several directories and files during the build process. Here's how to clean them up:
### Build Artifacts
| Directory/File | Description |
|----------------|-------------|
| `build/` | Temporary build files (can be large) |
| `dist/` | Final executable output |
| `*.spec` | Spec files (keep these - they're source files) |
| `__pycache__/` | Python bytecode cache |
### Clean Commands
#### Windows (PowerShell)
```powershell
# Remove build artifacts
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
# Also clear Python cache (optional)
Get-ChildItem -Recurse -Directory -Name __pycache__ | Remove-Item -Recurse -Force
```
#### Windows (Command Prompt)
```cmd
rmdir /s /q build dist
```
#### Linux/macOS
```bash
# Remove build artifacts
rm -rf build/ dist/
# Also clear Python cache (optional)
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null
```
### Clean Rebuild
To perform a completely clean rebuild:
#### Windows (PowerShell)
```powershell
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
uv run pyinstaller macropad.spec
```
#### Linux
```bash
rm -rf build/ dist/
uv run pyinstaller macropad_linux.spec
```
#### macOS
```bash
rm -rf build/ dist/
uv run pyinstaller macropad_macos.spec
```
### Space Recovery
The `build/` directory can consume significant disk space (often 500MB+). If you're done testing, remove it:
```bash
# Keep the executable, remove build intermediates
rm -rf build/
```
## Alternative: Using pip
If you prefer not to use uv:
```bash
# Create virtual environment
python -m venv .venv
# Activate it
# Windows:
.venv\Scripts\activate
# Linux/macOS:
source .venv/bin/activate
# Install dependencies
pip install -e .
pip install pyinstaller
# Build
pyinstaller macropad.spec
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+231 -101
View File
@@ -1,164 +1,294 @@
# MacroPad Server
A versatile MacroPad server application that lets you create, manage, and execute custom macros from both a local interface and remotely via a web interface.
A cross-platform macro management application with desktop and web interfaces. Create powerful command sequences with delays, key presses, and text input - accessible locally or remotely via a PWA-enabled web interface.
## Features
- **Text Macros**: Insert frequently used text snippets with a single click
- **Application Macros**: Launch applications or scripts directly
- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros
### Macro Capabilities
- **Command Sequences**: Build multi-step macros with:
- **Text**: Type text strings
- **Keys**: Press individual keys (Enter, Tab, Escape, etc.)
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
- **Wait**: Insert delays between commands (in milliseconds)
- **App**: Launch applications or scripts
- **Media & System Keys**: Control playback and audio (volume up/down/mute, play/pause, next/previous track, stop) alongside regular keys
- **Custom Images**: Assign images to macros for easy identification
- **Category Management**: Organize macros into custom tabs for better organization
- **Web Interface**: Access and trigger your macros from other devices on your network
- **System Tray Integration**: Minimize to tray when minimized, exit when closed
- **QR Code Generation**: Quickly connect mobile devices to the web interface
- **Sorting Options**: Sort macros by name, type, or recent usage
- **Persistent Storage**: Macros are automatically saved for future sessions
- **Dark Theme**: Modern dark interface for comfortable use
- **Modular Architecture**: Clean separation of concerns with dedicated modules
- **Category Management**: Organize macros into custom tabs
### Interfaces
- **Desktop GUI**: Modern PySide6/Qt interface with visual command builder
- **Web Interface**: PWA-enabled for installation on any device
- **System Tray**: Minimize to tray, always accessible
### Additional Features
- **Relay Server Support**: Access your macros securely over HTTPS from anywhere
- **QR Code Generation**: Quickly connect mobile devices
- **Real-time Sync**: WebSocket updates across all connected devices
- **Offline Support**: PWA caches for offline macro viewing
- **Dark Theme**: Modern dark interface throughout
- **Auto-Migration**: Existing macros automatically upgraded to new format
## Requirements
- Python 3.11+
- Required Python packages (install via requirements.txt):
- tkinter
- flask
- pyautogui
- pystray
- Pillow (PIL)
- waitress
- netifaces
- qrcode
- Dependencies managed via `pyproject.toml`
### Core Dependencies
- PySide6 (Desktop GUI)
- FastAPI + Uvicorn (Web server)
- PyAutoGUI (Keyboard automation)
- Pillow (Image processing)
- pystray (System tray)
- qrcode (QR code generation)
- aiohttp (Relay server client)
## Installation
### Method 1: From Source
### Method 1: Using uv (Recommended)
1. Clone or download this repository
2. Install the required dependencies:
```bash
pip install -r requirements.txt
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and install
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
uv sync
```
### Method 2: Pre-built Executables
### Method 2: Using pip
```bash
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
pip install -e .
```
### Method 3: Pre-built Executables
1. Go to the [Releases](https://repo.anhonesthost.net/MacroPad/MP-Server/releases) page
2. Download the appropriate version for your operating system:
2. Download for your operating system:
- Windows: `macropad.exe`
- Linux: `macropad`
- macOS: `macropad.app`
- macOS: `MacroPad Server.app`
3. Run the downloaded file
> [!IMPORTANT]
> The executables are unsigned and may trigger security warnings. You may need to click "More info" and "Run anyway" in Windows SmartScreen, adjust permissions on Linux (`chmod +x macropad`), or override Gatekeeper on macOS.
> Executables are unsigned and may trigger security warnings. You may need to:
> - Windows: Click "More info" → "Run anyway" in SmartScreen
> - Linux: Run `chmod +x macropad` before executing
> - macOS: Right-click → Open, or adjust Gatekeeper settings
## Usage
### Main Interface
### Running the Application
When launched, MacroPad displays your existing macros with options to:
```bash
# If installed with uv
uv run python main.py
- **Add New Macro**: Create text snippets or application shortcuts
- **Edit Macro**: Modify existing macros
- **Delete Macro**: Remove unwanted macros
- **Sort Options**: Sort the Macros by type, name, and recent usage
- **Manage Tabs**: Assign categories to macros for better organization
- **Start Web Server**: Starts the web server to serve the MacroPad web interface.
# If installed with pip
python main.py
```
### Creating a Macro
1. Click the "Add Macro" button
2. Fill in the details:
- **Name**: A descriptive name for your macro
- **Category**: Assign a category to associate with a tab
- **Type**: Choose between Text or Application
- **Command/Text**: The text to insert or application command to run
- **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter
- **Image**: Optionally add an image for visual identification
3. Click "Save" to create your macro
1. Click **+ Add Macro** in the toolbar
2. Enter a **Name** and optional **Category**
3. Build your command sequence using the buttons:
- **+ Text**: Add text to type
- **+ Key**: Add a key press (enter, tab, escape, etc.)
- **+ Hotkey**: Add a key combination (ctrl+c, alt+tab)
- **+ Wait**: Add a delay in milliseconds
- **+ App**: Add an application to launch
4. Reorder commands with **Up/Down** buttons
5. Click **Save**
### Remote Access
### Example: Login Macro
The application runs a web server enabling remote access:
A macro that types a username, waits, presses Tab, types a password, and presses Enter:
1. Note your computer's local IP address (shown in the application header)
2. From another device on the same network, open a web browser
3. Navigate to `http://<your-ip-address>:40000`
4. Click on any macro to execute it on your main computer
```
[TEXT] myusername
[WAIT] 200ms
[KEY] tab
[TEXT] mypassword
[KEY] enter
```
### Web Interface (PWA)
1. Start the application (web server starts automatically on port 40000)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000/?token=...`)
3. Open this URL on any device on your network
4. **Install as PWA**:
- Mobile: Tap browser menu → "Add to Home Screen"
- Desktop: Click install icon in address bar
> [!IMPORTANT]
> The local web server now requires an access token. A per-install token is auto-generated and stored in `settings.json`, and is embedded in the URL shown in the toolbar and in the QR code — so copying the URL or scanning the QR just works. Anyone with that URL can control your macros, so treat it like a password. By default the server also binds to your LAN; set `web.allow_lan` to `false` in settings to restrict access to localhost only.
The web interface provides full macro management:
- View and execute macros
- Create, edit, and delete macros directly from the web app — tap the **+** button to open the command-sequence builder (no desktop app required)
- Organize into categories
- Real-time sync across devices
### System Tray
When minimized to the system tray:
- Right-click the tray icon to show options
- Select "Show" to restore the window
- Select "Exit" to close the application
- Minimize window → App continues in tray
- Right-click tray icon:
- **Show**: Restore window
- **Quit**: Exit application
### Relay Server (Remote Access)
Access your macros from outside your local network using a relay server:
1. Click **Settings** (gear icon) in the toolbar
2. Check **Enable Relay Server**
3. Enter your relay server URL and password
4. Click **Save**
Once connected, a relay URL will appear in the toolbar. Use this URL from any device with internet access. The relay provides:
- Secure HTTPS connection
- Macro browsing and execution
- PWA installation support
- Wake lock and fullscreen mode
> [!NOTE]
> The relay web client is **execute-only**: remote devices can browse and trigger macros, but macro creation/editing is intentionally limited to the local network (the desktop app and the local web interface). This keeps macro authoring — which defines what runs on your computer — off the public internet.
> [!NOTE]
> The relay password is stored in your OS keyring when one is available, falling back to the settings file (written with restricted `0600` permissions) otherwise.
> [!NOTE]
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions. The self-hosted relay now enforces login rate-limiting/lockout, keeps credentials out of URLs, and requires setting sane environment values — see the relay repo's `.env.example`.
## Command Types Reference
| Type | Description | Parameters |
|------|-------------|------------|
| `text` | Types a text string | `value`: The text to type |
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
| `wait` | Delays execution | `ms`: Milliseconds to wait |
| `app` | Launches an application | `command`: Program and arguments to run (tokenized and executed directly — no shell) |
## Example Application Commands
### Windows Examples
> [!IMPORTANT]
> `app` commands are tokenized and the program is executed **directly**, without a shell. This closes a remote-code-execution risk, but it means shell features are **not** supported: command chaining (`;`, `&&`, `|`), output redirection (`>`), environment-variable expansion (`$VAR`, `%VAR%`), and shell built-ins will not work. Launching a program with arguments works exactly as shown below.
#### Steam Applications
```
### Windows
```bash
# Steam game
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
```
#### Chrome to a website
```
"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao
```
# Chrome to website
"C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com
#### Run Notepad
```
notepad.exe
```
#### Open File Explorer to a specific location
```
# Open folder
explorer.exe "C:\Users\YourUsername\Documents"
```
### Linux Examples
#### Opening Firefox
```
### Linux
```bash
# Firefox
firefox https://example.com
```
#### Opening Steam
```
# Steam game
steam steam://rungameid/2767030
```
#### Launch Terminal
```
# Terminal
gnome-terminal
```
#### Open File Manager
```
nautilus ~/Documents
```
### macOS Examples
#### Opening Safari
```
### macOS
```bash
# Safari
open -a Safari https://example.com
```
#### Opening Terminal
```
open -a Terminal
```
# VS Code
open -a "Visual Studio Code"
#### Open Finder to a specific location
```
# Folder
open ~/Documents
```
#### Launch Applications
```
open -a "Visual Studio Code"
## Building Executables
Build platform-specific executables using PyInstaller:
```bash
# Install PyInstaller
pip install pyinstaller
# Windows (run on Windows)
pyinstaller macropad.spec
# Linux (run on Linux)
pyinstaller macropad_linux.spec
# macOS (run on macOS)
pyinstaller macropad_macos.spec
```
#### Special Thanks to CatArgent_ on Twitch for proof reading my stuff and providing valuable feedback.
> [!NOTE]
> PyInstaller cannot cross-compile. You must build on the target platform.
## Project Structure
```
MP-Server/
├── main.py # Application entry point
├── config.py # Configuration constants
├── macro_manager.py # Macro storage and execution
├── web_server.py # FastAPI web server
├── relay_client.py # Relay server WebSocket client
├── pyproject.toml # Dependencies and build config
├── gui/ # PySide6 desktop interface
│ ├── main_window.py
│ ├── macro_editor.py
│ └── command_builder.py
├── web/ # PWA web interface
│ ├── index.html
│ ├── manifest.json
│ ├── service-worker.js
│ ├── css/styles.css
│ ├── js/app.js
│ └── icons/
└── macros.json # Macro storage (auto-created)
```
## Migrating from v0.8.x
Existing macros are automatically migrated on first run. The old format:
```json
{
"type": "text",
"command": "Hello",
"modifiers": {"enter": true}
}
```
Becomes the new command sequence format:
```json
{
"type": "sequence",
"commands": [
{"type": "text", "value": "Hello"},
{"type": "key", "value": "enter"}
]
}
```
## License
MIT License
## Acknowledgments
Special thanks to CatArgent_ on Twitch for proofreading and providing valuable feedback.
+6 -2
View File
@@ -1,7 +1,8 @@
# Configuration and constants for MacroPad Server
VERSION = "0.8.5 Beta"
VERSION = "1.1.0"
DEFAULT_PORT = 40000
SETTINGS_FILE = "settings.json"
# UI Theme colors
THEME = {
@@ -12,7 +13,10 @@ THEME = {
'button_bg': "#505050",
'button_fg': "#ffffff",
'tab_bg': "#404040",
'tab_selected': "#007acc"
'tab_selected': "#007acc",
'accent_hover': "#0096ff",
'danger_color': "#dc3545",
'danger_hover': "#c82333"
}
# File extensions for images
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
# MacroPad Server GUI Module
# PySide6-based desktop interface
from .main_window import MainWindow
from .macro_editor import MacroEditorDialog, CommandBuilder
__all__ = ['MainWindow', 'MacroEditorDialog', 'CommandBuilder']
+5
View File
@@ -0,0 +1,5 @@
# Command builder widget (re-exported from macro_editor for convenience)
from .macro_editor import CommandBuilder, CommandItem
__all__ = ['CommandBuilder', 'CommandItem']
+657
View File
@@ -0,0 +1,657 @@
# Macro editor dialog with command builder (PySide6)
import os
from typing import Optional, List
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QComboBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
from config import THEME, IMAGE_EXTENSIONS
# Selectable single keys offered in the key picker. Includes common media/system
# keys pyautogui supports, a near-zero-cost win for a media/stream deck.
KEY_CHOICES = [
"Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
# Media / system keys
"VolumeUp", "VolumeDown", "VolumeMute",
"PlayPause", "NextTrack", "PrevTrack", "Stop",
]
class CommandItem(QWidget):
"""Widget representing a single command in the list."""
delete_clicked = Signal()
move_up_clicked = Signal()
move_down_clicked = Signal()
edit_clicked = Signal()
def __init__(self, command: dict, parent=None):
super().__init__(parent)
self.command = command
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(8)
# Type label
type_label = QLabel(command.get("type", "").upper())
type_label.setFixedWidth(60)
type_label.setStyleSheet(f"""
background-color: {THEME['accent_color']};
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
""")
type_label.setAlignment(Qt.AlignCenter)
layout.addWidget(type_label)
# Value label
value_label = QLabel(self._get_display_value())
value_label.setStyleSheet(f"color: {THEME['fg_color']}; font-family: monospace;")
layout.addWidget(value_label, 1)
# Action buttons
btn_style = f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 6px 8px;
border-radius: 4px;
min-height: 20px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
"""
edit_btn = QPushButton("Edit")
edit_btn.setFixedSize(50, 28)
edit_btn.setStyleSheet(btn_style)
edit_btn.clicked.connect(self.edit_clicked.emit)
layout.addWidget(edit_btn)
up_btn = QPushButton("^")
up_btn.setStyleSheet(btn_style)
up_btn.setFixedSize(30, 28)
up_btn.clicked.connect(self.move_up_clicked.emit)
layout.addWidget(up_btn)
down_btn = QPushButton("v")
down_btn.setStyleSheet(btn_style)
down_btn.setFixedSize(30, 28)
down_btn.clicked.connect(self.move_down_clicked.emit)
layout.addWidget(down_btn)
del_btn = QPushButton("X")
del_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 6px 8px;
border-radius: 4px;
min-height: 20px;
}}
QPushButton:hover {{
background-color: {THEME['danger_hover']};
}}
""")
del_btn.setFixedSize(30, 28)
del_btn.clicked.connect(self.delete_clicked.emit)
layout.addWidget(del_btn)
def _get_display_value(self) -> str:
"""Get display text for the command."""
cmd_type = self.command.get("type", "")
if cmd_type == "text":
return self.command.get("value", "")[:50]
elif cmd_type == "key":
key = self.command.get("value", "")
return key.capitalize() if key else ""
elif cmd_type == "hotkey":
keys = self.command.get("keys", [])
return " + ".join(k.capitalize() for k in keys)
elif cmd_type == "wait":
return f"{self.command.get('ms', 0)}ms"
elif cmd_type == "app":
return self.command.get("command", "")[:50]
return ""
class CommandBuilder(QWidget):
"""Widget for building command sequences."""
commands_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.commands: List[dict] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Command list
self.list_widget = QListWidget()
self.list_widget.setStyleSheet(f"""
QListWidget {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
}}
QListWidget::item {{
padding: 4px;
}}
""")
self.list_widget.setMinimumHeight(150)
layout.addWidget(self.list_widget)
# Add command buttons
btn_layout = QHBoxLayout()
for cmd_type, label in [
("text", "+ Text"),
("key", "+ Key"),
("hotkey", "+ Hotkey"),
("wait", "+ Wait"),
("app", "+ App")
]:
btn = QPushButton(label)
btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 12px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
btn.clicked.connect(lambda checked, t=cmd_type: self.add_command(t))
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
def set_commands(self, commands: List[dict]):
"""Set the command list."""
self.commands = list(commands)
self.refresh()
def get_commands(self) -> List[dict]:
"""Get the command list."""
return list(self.commands)
def refresh(self):
"""Refresh the command list display."""
self.list_widget.clear()
for i, cmd in enumerate(self.commands):
item = QListWidgetItem(self.list_widget)
widget = CommandItem(cmd)
widget.delete_clicked.connect(lambda idx=i: self.remove_command(idx))
widget.move_up_clicked.connect(lambda idx=i: self.move_command(idx, -1))
widget.move_down_clicked.connect(lambda idx=i: self.move_command(idx, 1))
widget.edit_clicked.connect(lambda idx=i: self.edit_command(idx))
item.setSizeHint(widget.sizeHint())
self.list_widget.addItem(item)
self.list_widget.setItemWidget(item, widget)
def add_command(self, cmd_type: str):
"""Add a new command."""
command = {"type": cmd_type}
if cmd_type == "text":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(self, "Text Command", "Enter text to type:")
if not ok or not text:
return
command["value"] = text
elif cmd_type == "key":
from PySide6.QtWidgets import QInputDialog
keys = list(KEY_CHOICES)
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
if not ok or not key:
return
command["value"] = key.lower()
elif cmd_type == "hotkey":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(
self, "Hotkey Command",
"Enter key combination (comma separated, e.g., ctrl,c):"
)
if not ok or not text:
return
command["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
from PySide6.QtWidgets import QInputDialog
ms, ok = QInputDialog.getInt(
self, "Wait Command",
"Enter delay in milliseconds:",
500, 0, 60000, 100
)
if not ok:
return
command["ms"] = ms
elif cmd_type == "app":
from PySide6.QtWidgets import QInputDialog
cmd, ok = QInputDialog.getText(
self, "App Command",
"Enter application command:\n\n"
"Examples:\n"
" Windows: notepad.exe\n"
" Windows: \"C:\\Program Files\\App\\app.exe\"\n"
" Linux: firefox https://example.com\n"
" macOS: open -a Safari"
)
if not ok or not cmd:
return
command["command"] = cmd
self.commands.append(command)
self.refresh()
self.commands_changed.emit()
def remove_command(self, index: int):
"""Remove a command at index."""
if 0 <= index < len(self.commands):
del self.commands[index]
self.refresh()
self.commands_changed.emit()
def move_command(self, index: int, direction: int):
"""Move a command up or down."""
new_index = index + direction
if 0 <= new_index < len(self.commands):
self.commands[index], self.commands[new_index] = \
self.commands[new_index], self.commands[index]
self.refresh()
self.commands_changed.emit()
def edit_command(self, index: int):
"""Edit a command at index."""
if not (0 <= index < len(self.commands)):
return
cmd = self.commands[index]
cmd_type = cmd.get("type", "")
from PySide6.QtWidgets import QInputDialog
if cmd_type == "text":
text, ok = QInputDialog.getText(
self, "Edit Text", "Enter text:",
text=cmd.get("value", "")
)
if ok and text:
cmd["value"] = text
elif cmd_type == "key":
keys = list(KEY_CHOICES)
keys_lower = [k.lower() for k in keys]
current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
if ok and key:
cmd["value"] = key.lower()
elif cmd_type == "hotkey":
text, ok = QInputDialog.getText(
self, "Edit Hotkey", "Enter key combination:",
text=",".join(cmd.get("keys", []))
)
if ok and text:
cmd["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
ms, ok = QInputDialog.getInt(
self, "Edit Wait", "Enter delay in milliseconds:",
cmd.get("ms", 500), 0, 60000, 100
)
if ok:
cmd["ms"] = ms
elif cmd_type == "app":
text, ok = QInputDialog.getText(
self, "Edit App",
"Enter application command:\n\n"
"Examples:\n"
" Windows: notepad.exe\n"
" Windows: \"C:\\Program Files\\App\\app.exe\"\n"
" Linux: firefox https://example.com\n"
" macOS: open -a Safari",
text=cmd.get("command", "")
)
if ok and text:
cmd["command"] = text
self.refresh()
self.commands_changed.emit()
class MacroEditorDialog(QDialog):
"""Dialog for creating/editing macros."""
def __init__(self, macro_manager, macro_id: Optional[str] = None, parent=None):
super().__init__(parent)
self.macro_manager = macro_manager
self.macro_id = macro_id
self.image_path = ""
self.setWindowTitle("Edit Macro" if macro_id else "Add Macro")
self.setMinimumSize(500, 500)
self.setStyleSheet(f"""
QDialog {{
background-color: {THEME['highlight_color']};
}}
QLabel {{
color: {THEME['fg_color']};
}}
QLineEdit {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 8px;
color: {THEME['fg_color']};
}}
QLineEdit:focus {{
border-color: {THEME['accent_color']};
}}
QComboBox {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 8px;
color: {THEME['fg_color']};
}}
QComboBox:focus {{
border-color: {THEME['accent_color']};
}}
QComboBox::drop-down {{
border: none;
width: 20px;
}}
QComboBox::down-arrow {{
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid {THEME['fg_color']};
margin-right: 5px;
}}
QComboBox QAbstractItemView {{
background-color: {THEME['bg_color']};
color: {THEME['fg_color']};
selection-background-color: {THEME['accent_color']};
border: 1px solid {THEME['button_bg']};
}}
""")
self.setup_ui()
# Load existing macro data if editing
if macro_id:
self.load_macro()
def setup_ui(self):
"""Setup the dialog UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Scroll area for content
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet("border: none;")
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(12)
# Name field
name_group = QGroupBox("Macro Name")
name_group.setStyleSheet(f"""
QGroupBox {{
color: {THEME['fg_color']};
font-weight: bold;
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
margin-top: 8px;
padding-top: 8px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
}}
""")
name_layout = QVBoxLayout(name_group)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Enter macro name")
name_layout.addWidget(self.name_input)
content_layout.addWidget(name_group)
# Category field
category_group = QGroupBox("Category (optional)")
category_group.setStyleSheet(name_group.styleSheet())
category_layout = QVBoxLayout(category_group)
self.category_input = QComboBox()
self.category_input.setEditable(True)
self.category_input.setInsertPolicy(QComboBox.NoInsert)
self.category_input.lineEdit().setPlaceholderText("Select or enter category")
# Populate with existing categories (excluding "All")
existing_categories = [c for c in self.macro_manager.get_unique_tabs() if c != "All"]
self.category_input.addItem("") # Empty option for no category
self.category_input.addItems(existing_categories)
category_layout.addWidget(self.category_input)
content_layout.addWidget(category_group)
# Command builder
commands_group = QGroupBox("Commands")
commands_group.setStyleSheet(name_group.styleSheet())
commands_layout = QVBoxLayout(commands_group)
self.command_builder = CommandBuilder()
commands_layout.addWidget(self.command_builder)
content_layout.addWidget(commands_group)
# Image selection
image_group = QGroupBox("Image (optional)")
image_group.setStyleSheet(name_group.styleSheet())
image_layout = QHBoxLayout(image_group)
self.image_preview = QLabel()
self.image_preview.setFixedSize(64, 64)
self.image_preview.setStyleSheet(f"""
background-color: {THEME['bg_color']};
border-radius: 4px;
""")
self.image_preview.setAlignment(Qt.AlignCenter)
image_layout.addWidget(self.image_preview)
image_btn_layout = QVBoxLayout()
select_btn = QPushButton("Select Image")
select_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
select_btn.clicked.connect(self.select_image)
image_btn_layout.addWidget(select_btn)
clear_btn = QPushButton("Clear Image")
clear_btn.setStyleSheet(select_btn.styleSheet())
clear_btn.clicked.connect(self.clear_image)
image_btn_layout.addWidget(clear_btn)
image_layout.addLayout(image_btn_layout)
image_layout.addStretch()
content_layout.addWidget(image_group)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Dialog buttons
btn_layout = QHBoxLayout()
if self.macro_id:
delete_btn = QPushButton("Delete")
delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: {THEME['danger_hover']};
}}
""")
delete_btn.clicked.connect(self.delete_macro)
btn_layout.addWidget(delete_btn)
btn_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 10px 20px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_macro)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
def load_macro(self):
"""Load existing macro data into the form."""
macro = self.macro_manager.get_macro(self.macro_id)
if not macro:
return
self.name_input.setText(macro.get("name", ""))
self.category_input.setCurrentText(macro.get("category", ""))
self.command_builder.set_commands(macro.get("commands", []))
if macro.get("image_path"):
self.image_path = macro["image_path"]
pixmap = QPixmap(self.image_path)
if not pixmap.isNull():
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def select_image(self):
"""Open file dialog to select an image."""
ext_filter = "Images (" + " ".join(f"*{ext}" for ext in IMAGE_EXTENSIONS) + ")"
file_path, _ = QFileDialog.getOpenFileName(
self, "Select Image", "", ext_filter
)
if file_path:
self.image_path = file_path
pixmap = QPixmap(file_path)
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def clear_image(self):
"""Clear the selected image."""
self.image_path = ""
self.image_preview.clear()
def save_macro(self):
"""Save the macro."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Error", "Please enter a macro name")
return
commands = self.command_builder.get_commands()
if not commands:
QMessageBox.warning(self, "Error", "Please add at least one command")
return
category = self.category_input.currentText().strip()
if self.macro_id:
# Update existing macro
self.macro_manager.update_macro(
self.macro_id,
name=name,
commands=commands,
category=category,
image_path=self.image_path if self.image_path else None
)
else:
# Create new macro
self.macro_manager.add_macro(
name=name,
commands=commands,
category=category,
image_path=self.image_path
)
self.accept()
def delete_macro(self):
"""Delete the current macro."""
macro_name = self.name_input.text().strip()
prompt = (
f"Are you sure you want to delete \"{macro_name}\"?"
if macro_name else
"Are you sure you want to delete this macro?"
)
reply = QMessageBox.question(
self, "Delete Macro",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(self.macro_id)
self.accept()
+915
View File
@@ -0,0 +1,915 @@
# Main window for MacroPad Server (PySide6)
import os
import sys
import threading
from typing import Optional
# Windows startup management
if sys.platform == 'win32':
import winreg
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base_path, relative_path)
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTabWidget, QGridLayout,
QScrollArea, QMenu, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
from macro_manager import MacroManager
from web_server import WebServer
from .settings_manager import SettingsManager
class MacroButton(QPushButton):
"""Custom button widget for displaying a macro."""
# Signals for context menu actions
edit_requested = Signal(str)
delete_requested = Signal(str)
def __init__(self, macro_id: str, macro: dict, app_dir: str, parent=None):
super().__init__(parent)
self.macro_id = macro_id
self.macro = macro
self.setFixedSize(120, 100)
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
border-radius: 8px;
padding: 8px;
text-align: center;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
QPushButton:pressed {{
background-color: {THEME['accent_color']};
}}
""")
# Layout
layout = QVBoxLayout(self)
layout.setSpacing(4)
layout.setContentsMargins(4, 4, 4, 4)
# Image or placeholder
image_label = QLabel()
image_label.setFixedSize(48, 48)
image_label.setAlignment(Qt.AlignCenter)
if macro.get("image_path"):
# Resolve relative image path against app directory
image_path = os.path.join(app_dir, macro["image_path"])
pixmap = QPixmap(image_path)
if not pixmap.isNull():
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label.setPixmap(pixmap)
else:
self._set_placeholder(image_label, macro["name"])
else:
self._set_placeholder(image_label, macro["name"])
layout.addWidget(image_label, alignment=Qt.AlignCenter)
# Name label
name_label = QLabel(macro["name"])
name_label.setAlignment(Qt.AlignCenter)
name_label.setWordWrap(True)
name_label.setStyleSheet(f"color: {THEME['fg_color']}; font-size: 11px;")
layout.addWidget(name_label)
def _set_placeholder(self, label: QLabel, name: str):
"""Set a placeholder with the first letter of the name."""
label.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
border-radius: 8px;
font-size: 20px;
font-weight: bold;
color: {THEME['fg_color']};
""")
label.setText(name[0].upper() if name else "?")
def contextMenuEvent(self, event):
"""Show context menu on right-click."""
menu = QMenu(self)
edit_action = menu.addAction("Edit")
delete_action = menu.addAction("Delete")
action = menu.exec_(event.globalPos())
if action == edit_action:
self.edit_requested.emit(self.macro_id)
elif action == delete_action:
self.delete_requested.emit(self.macro_id)
class MainWindow(QMainWindow):
"""Main application window."""
macros_changed = Signal()
# Signals for thread-safe relay status updates
relay_session_received = Signal(str)
relay_connected_signal = Signal()
relay_disconnected_signal = Signal()
# Report macro execution result back to the GUI thread (success flag).
macro_execution_finished = Signal(bool)
def __init__(self, app_dir: str):
super().__init__()
self.app_dir = app_dir
self.current_tab = "All"
self.sort_by = "name"
# Initialize settings manager
settings_file = os.path.join(app_dir, SETTINGS_FILE)
self.settings_manager = SettingsManager(settings_file)
# Initialize macro manager
data_file = os.path.join(app_dir, "macros.json")
images_dir = os.path.join(app_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
# Initialize web server
token = self.settings_manager.get_web_auth_token()
host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1"
self.web_server = WebServer(
self.macro_manager, app_dir, DEFAULT_PORT,
auth_token=token, host=host
)
self.server_thread = None
# Relay client (initialized later if enabled)
self.relay_client = None
# Setup UI
self.setup_ui()
self.setup_menu()
self.setup_tray()
# Start web server
self.start_server()
# Start relay client if enabled
if self.settings_manager.get_relay_enabled():
self.start_relay_client()
# Connect signals
self.macros_changed.connect(self.refresh_macros)
self.relay_session_received.connect(self._handle_relay_session)
self.relay_connected_signal.connect(lambda: self._update_relay_status(True))
self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False))
self.macro_execution_finished.connect(self._on_macro_execution_finished)
# Debounce timer for resize-driven grid rebuilds.
self._resize_timer = QTimer(self)
self._resize_timer.setSingleShot(True)
self._resize_timer.setInterval(150)
self._resize_timer.timeout.connect(self._apply_resize)
self._last_col_count = None
# Load initial data
self.refresh_tabs()
self.refresh_macros()
def setup_ui(self):
"""Setup the main UI components."""
self.setWindowTitle("MacroPad Server")
self.setMinimumSize(600, 400)
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
# Central widget
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Toolbar
toolbar = QWidget()
toolbar.setStyleSheet(f"background-color: {THEME['highlight_color']};")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(10, 10, 10, 10)
add_btn = QPushButton("+ Add Macro")
add_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: {THEME['accent_hover']};
}}
""")
add_btn.clicked.connect(self.add_macro)
toolbar_layout.addWidget(add_btn)
toolbar_layout.addStretch()
# Clickable IP address label (click to copy)
self.ip_label = QPushButton()
self.ip_label.setCursor(Qt.PointingHandCursor)
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.ip_label.clicked.connect(self.copy_url_to_clipboard)
self.update_ip_label()
toolbar_layout.addWidget(self.ip_label)
qr_btn = QPushButton("QR Code")
qr_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
qr_btn.clicked.connect(self.show_qr_code)
toolbar_layout.addWidget(qr_btn)
layout.addWidget(toolbar)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(f"""
QTabWidget::pane {{
border: none;
background: {THEME['bg_color']};
}}
QTabBar::tab {{
background: {THEME['tab_bg']};
color: {THEME['fg_color']};
padding: 8px 16px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}}
QTabBar::tab:selected {{
background: {THEME['tab_selected']};
}}
QTabBar::tab:hover {{
background: {THEME['highlight_color']};
}}
""")
self.tab_widget.currentChanged.connect(self.on_tab_changed)
layout.addWidget(self.tab_widget)
# Status bar
self.status_bar = QStatusBar()
self.status_bar.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
""")
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
def setup_menu(self):
"""Setup the menu bar."""
menubar = self.menuBar()
menubar.setStyleSheet(f"""
QMenuBar {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenuBar::item:selected {{
background-color: {THEME['accent_color']};
}}
QMenu {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenu::item:selected {{
background-color: {THEME['accent_color']};
}}
""")
# File menu
file_menu = menubar.addMenu("File")
add_action = QAction("Add Macro", self)
add_action.setShortcut("Ctrl+N")
add_action.triggered.connect(self.add_macro)
file_menu.addAction(add_action)
file_menu.addSeparator()
# Windows startup option (only on Windows)
if sys.platform == 'win32':
self.startup_action = QAction("Start on Windows Startup", self)
self.startup_action.setCheckable(True)
self.startup_action.setChecked(self.get_startup_enabled())
self.startup_action.triggered.connect(self.toggle_startup)
file_menu.addAction(self.startup_action)
file_menu.addSeparator()
quit_action = QAction("Quit", self)
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
# Edit menu
edit_menu = menubar.addMenu("Edit")
settings_action = QAction("Settings...", self)
settings_action.setShortcut("Ctrl+,")
settings_action.triggered.connect(self.show_settings)
edit_menu.addAction(settings_action)
# View menu
view_menu = menubar.addMenu("View")
refresh_action = QAction("Refresh", self)
refresh_action.setShortcut("F5")
refresh_action.triggered.connect(self.refresh_all)
view_menu.addAction(refresh_action)
# Sort submenu
sort_menu = view_menu.addMenu("Sort By")
for sort_option in [("Name", "name"), ("Type", "type"), ("Recent", "recent")]:
action = QAction(sort_option[0], self)
action.triggered.connect(lambda checked, s=sort_option[1]: self.set_sort(s))
sort_menu.addAction(action)
# Help menu
help_menu = menubar.addMenu("Help")
about_action = QAction("About", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def setup_tray(self):
"""Setup system tray icon."""
self.tray_icon = QSystemTrayIcon(self)
# Load icon from bundled resources
icon_path = get_resource_path("Macro Pad.png")
if os.path.exists(icon_path):
icon = QIcon(icon_path)
self.tray_icon.setIcon(icon)
self.setWindowIcon(icon)
else:
self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
# Tray menu
tray_menu = QMenu()
show_action = tray_menu.addAction("Show")
show_action.triggered.connect(self.restore_window)
quit_action = tray_menu.addAction("Quit")
quit_action.triggered.connect(self.close)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.activated.connect(self.on_tray_activated)
self.tray_icon.show()
def on_tray_activated(self, reason):
"""Handle tray icon activation."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.restore_window()
def restore_window(self):
"""Restore and bring window to front."""
# Clear minimized state and show
self.setWindowState(Qt.WindowNoState)
self.show()
self.raise_()
self.activateWindow()
def start_server(self):
"""Start the web server in a background thread."""
self.server_error = None
def run():
try:
self.web_server.create_app()
self.web_server.run()
except Exception as e:
self.server_error = str(e)
# Emit signal to show error on main thread
QTimer.singleShot(0, self.show_server_error)
self.server_thread = threading.Thread(target=run, daemon=True)
self.server_thread.start()
# Give the server a moment to start, then check for errors
QTimer.singleShot(1000, self.check_server_started)
def check_server_started(self):
"""Check if server started successfully."""
if self.server_error:
self.show_server_error()
else:
self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}")
def show_server_error(self):
"""Show server error dialog."""
error_msg = self.server_error or "Unknown error"
self.status_bar.showMessage(f"Server failed to start")
QMessageBox.warning(
self,
"Web Server Error",
f"Failed to start web server on port {DEFAULT_PORT}.\n\n"
f"Error: {error_msg}\n\n"
"The web interface will not be available.\n"
"Check if another application is using the port."
)
def stop_server(self):
"""Stop the web server."""
if self.web_server:
self.web_server.stop()
self.status_bar.showMessage("Server stopped")
def update_ip_label(self):
"""Update the IP address label."""
# Check if relay is connected and has a session ID
relay_connected = self.relay_client and self.relay_client.is_connected()
session_id = self.settings_manager.get_relay_session_id()
if relay_connected and session_id:
relay_url = self.settings_manager.get_relay_url()
# Convert wss:// to https:// for display
base_url = relay_url.replace('wss://', 'https://').replace('ws://', 'http://')
base_url = base_url.replace('/desktop', '').rstrip('/')
full_url = f"{base_url}/{session_id}"
self.ip_label.setText(full_url)
return
# Fall back to local IP. Append the auth token as a query param so the
# QR/copied URL lets a LAN device authenticate against the API.
token = self.settings_manager.get_web_auth_token()
token_qs = f"?token={token}" if token else ""
ip = self._detect_lan_ip()
if ip:
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
return
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
@staticmethod
def _detect_lan_ip():
"""Best-effort primary LAN IPv4, with no third-party dependency.
Uses a UDP socket to discover which local interface would be used to
reach the internet (no packets are actually sent), then falls back to
resolving the hostname. Returns None if only loopback is available.
"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
except Exception:
pass
try:
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
if not ip.startswith("127."):
return ip
except Exception:
pass
return None
def copy_url_to_clipboard(self):
"""Copy the web interface URL to clipboard."""
url = self.ip_label.text()
if url == "Copied!":
return # Already showing copied feedback
clipboard = QApplication.clipboard()
clipboard.setText(url)
# Show "Copied!" feedback with dimmed style
self.ip_label.setText("Copied!")
self.ip_label.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #888888;
border: none;
padding: 4px 8px;
}
""")
# Restore original URL after delay
QTimer.singleShot(1500, self._restore_url_label)
def _restore_url_label(self):
"""Restore URL label after copy feedback."""
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.update_ip_label()
def refresh_tabs(self):
"""Refresh the tab widget."""
self.tab_widget.blockSignals(True)
self.tab_widget.clear()
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(f"background-color: {THEME['bg_color']}; border: none;")
container = QWidget()
container.setStyleSheet(f"background-color: {THEME['bg_color']};")
scroll.setWidget(container)
self.tab_widget.addTab(scroll, tab_name)
self.tab_widget.blockSignals(False)
def refresh_macros(self):
"""Refresh the macro grid."""
current_index = self.tab_widget.currentIndex()
if current_index < 0:
return
scroll = self.tab_widget.widget(current_index)
container = scroll.widget()
# Clear existing layout
if container.layout():
while container.layout().count():
item = container.layout().takeAt(0)
if item.widget():
item.widget().deleteLater()
else:
container.setLayout(QGridLayout())
layout = container.layout()
layout.setSpacing(10)
layout.setContentsMargins(10, 10, 10, 10)
layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
# Get macros for current tab
tab_name = self.tab_widget.tabText(current_index)
macro_list = self.macro_manager.get_sorted_macros(self.sort_by)
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
# Add macro buttons
cols = self._compute_columns()
self._last_col_count = cols
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro, self.app_dir)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
btn.edit_requested.connect(self.edit_macro)
btn.delete_requested.connect(self.delete_macro)
layout.addWidget(btn, i // cols, i % cols)
def on_tab_changed(self, index):
"""Handle tab change."""
self.refresh_macros()
def execute_macro(self, macro_id: str):
"""Execute a macro off the GUI thread so the UI doesn't freeze.
pyautogui presses and wait steps are blocking; running them on the Qt
main thread would freeze the window. Run in a worker thread and report
the result back via a signal (thread-safe).
"""
self.status_bar.showMessage("Executing macro...", 2000)
def run():
try:
success = self.macro_manager.execute_macro(macro_id)
except Exception:
success = False
self.macro_execution_finished.emit(success)
threading.Thread(target=run, daemon=True).start()
def _on_macro_execution_finished(self, success: bool):
"""Report macro execution result on the GUI thread."""
if success:
self.status_bar.showMessage("Macro executed", 2000)
else:
self.status_bar.showMessage("Macro execution failed", 2000)
def add_macro(self):
"""Open dialog to add a new macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def edit_macro(self, macro_id: str):
"""Open dialog to edit a macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, macro_id=macro_id, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def delete_macro(self, macro_id: str):
"""Delete a macro with confirmation."""
macro = self.macro_manager.get_macro(macro_id)
macro_name = macro.get("name", "") if macro else ""
prompt = (
f"Are you sure you want to delete \"{macro_name}\"?"
if macro_name else
"Are you sure you want to delete this macro?"
)
reply = QMessageBox.question(
self, "Delete Macro",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(macro_id)
self.refresh_tabs()
self.refresh_macros()
def set_sort(self, sort_by: str):
"""Set the sort order."""
self.sort_by = sort_by
self.refresh_macros()
def refresh_all(self):
"""Refresh tabs and macros."""
self.refresh_tabs()
self.refresh_macros()
def show_qr_code(self):
"""Show QR code dialog."""
try:
import qrcode
from PySide6.QtWidgets import QDialog, QVBoxLayout
from io import BytesIO
url = self.ip_label.text()
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to QPixmap
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
pixmap = QPixmap()
pixmap.loadFromData(buffer.read())
# Show dialog
dialog = QDialog(self)
dialog.setWindowTitle("QR Code")
layout = QVBoxLayout(dialog)
label = QLabel()
label.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation))
layout.addWidget(label)
url_label = QLabel(url)
url_label.setStyleSheet(f"color: {THEME['fg_color']};")
url_label.setAlignment(Qt.AlignCenter)
layout.addWidget(url_label)
dialog.exec_()
except ImportError:
QMessageBox.warning(self, "Error", "QR code library not available")
def show_about(self):
"""Show about dialog."""
about_box = QMessageBox(self)
about_box.setWindowTitle("About MacroPad Server")
about_box.setTextFormat(Qt.RichText)
about_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
about_box.setText(
f"<h2>MacroPad Server v{VERSION}</h2>"
"<p>A cross-platform macro management application<br>"
"with desktop and web interfaces.</p>"
"<p><b>Author:</b> <a href='https://shadowdao.com'>ShadowDao</a></p>"
"<p><b>Updates:</b> <a href='https://shadowdao.com'>shadowdao.com</a></p>"
"<p><b>Donate:</b> <a href='https://liberapay.com/GoTakeAKnapp/'>Liberapay</a></p>"
)
about_box.setStandardButtons(QMessageBox.Ok)
about_box.exec()
def show_settings(self):
"""Show settings dialog."""
from .settings_dialog import SettingsDialog
dialog = SettingsDialog(self.settings_manager, parent=self)
dialog.relay_settings_changed.connect(self.on_relay_settings_changed)
dialog.exec_()
def on_relay_settings_changed(self):
"""Handle relay settings changes."""
# Stop existing relay client if running
self.stop_relay_client()
# Start new relay client if enabled
if self.settings_manager.get_relay_enabled():
self.start_relay_client()
# Update IP label to show relay URL if connected
self.update_ip_label()
def start_relay_client(self):
"""Start the relay client in a background thread."""
try:
from relay_client import RelayClient
except ImportError:
self.status_bar.showMessage("Relay client not available", 3000)
return
url = self.settings_manager.get_relay_url()
password = self.settings_manager.get_relay_password()
session_id = self.settings_manager.get_relay_session_id()
if not url or not password:
self.status_bar.showMessage("Relay not configured", 3000)
return
self.relay_client = RelayClient(
relay_url=url,
password=password,
session_id=session_id,
local_port=DEFAULT_PORT,
on_connected=self.on_relay_connected,
on_disconnected=self.on_relay_disconnected,
on_session_id=self.on_relay_session_id,
local_auth_token=self.settings_manager.get_web_auth_token()
)
self.relay_client.start()
self.status_bar.showMessage("Connecting to relay server...")
def stop_relay_client(self):
"""Stop the relay client."""
if self.relay_client:
self.relay_client.stop()
self.relay_client = None
self.status_bar.showMessage("Relay disconnected", 2000)
def on_relay_connected(self):
"""Handle relay connection established (called from background thread)."""
self.relay_connected_signal.emit()
def on_relay_disconnected(self):
"""Handle relay disconnection (called from background thread)."""
self.relay_disconnected_signal.emit()
def on_relay_session_id(self, session_id: str):
"""Handle receiving session ID from relay (called from background thread)."""
self.relay_session_received.emit(session_id)
def _handle_relay_session(self, session_id: str):
"""Handle relay session on main thread."""
if not self.settings_manager.set_relay_session_id(session_id):
self.status_bar.showMessage("Warning: failed to save relay session", 3000)
self.update_ip_label()
def _update_relay_status(self, connected: bool):
"""Update UI for relay status (called on main thread)."""
if connected:
self.status_bar.showMessage("Connected to relay server")
else:
self.status_bar.showMessage("Relay disconnected - reconnecting...")
self.update_ip_label()
# Windows startup management
def get_startup_enabled(self) -> bool:
"""Check if app is set to start on Windows startup."""
if sys.platform != 'win32':
return False
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_READ
)
try:
winreg.QueryValueEx(key, "MacroPad Server")
return True
except FileNotFoundError:
return False
finally:
winreg.CloseKey(key)
except Exception:
return False
def set_startup_enabled(self, enabled: bool):
"""Enable or disable starting on Windows startup."""
if sys.platform != 'win32':
return
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_SET_VALUE
)
try:
if enabled:
# Get the executable path
if getattr(sys, 'frozen', False):
# Running as compiled executable
exe_path = sys.executable
else:
# Running as script - use pythonw to avoid console
exe_path = f'"{sys.executable}" "{os.path.abspath(sys.argv[0])}"'
winreg.SetValueEx(key, "MacroPad Server", 0, winreg.REG_SZ, exe_path)
self.status_bar.showMessage("Added to Windows startup", 3000)
else:
try:
winreg.DeleteValue(key, "MacroPad Server")
self.status_bar.showMessage("Removed from Windows startup", 3000)
except FileNotFoundError:
pass
finally:
winreg.CloseKey(key)
except Exception as e:
QMessageBox.warning(self, "Error", f"Failed to update startup settings: {e}")
def toggle_startup(self):
"""Toggle the startup setting."""
current = self.get_startup_enabled()
self.set_startup_enabled(not current)
# Update menu checkmark
if hasattr(self, 'startup_action'):
self.startup_action.setChecked(not current)
def closeEvent(self, event):
"""Handle window close."""
# Stop the relay client
self.stop_relay_client()
# Stop the web server
self.stop_server()
# Hide tray icon
self.tray_icon.hide()
event.accept()
def _compute_columns(self) -> int:
"""Compute how many macro columns fit at the current width."""
return max(1, (self.width() - 40) // 130)
def _apply_resize(self):
"""Rebuild the grid only if the column count actually changed."""
if self._compute_columns() != self._last_col_count:
self.refresh_macros()
def resizeEvent(self, event):
"""Handle window resize (debounced to coalesce rapid resize events)."""
super().resizeEvent(event)
# Coalesce a burst of resize events; only rebuild after things settle
# and only when the computed column count changed.
self._resize_timer.start()
def changeEvent(self, event):
"""Handle window state changes - minimize to tray."""
if event.type() == QEvent.Type.WindowStateChange:
if (self.windowState() & Qt.WindowMinimized
and self.settings_manager.get_minimize_to_tray()):
# Hide instead of minimize (goes to tray)
event.ignore()
self.hide()
self.tray_icon.showMessage(
"MacroPad Server",
"Running in system tray. Double-click to restore.",
QSystemTrayIcon.Information,
2000
)
return
super().changeEvent(event)
+350
View File
@@ -0,0 +1,350 @@
# Settings Dialog for MacroPad Server
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget,
QWidget, QLabel, QLineEdit, QCheckBox, QPushButton,
QGroupBox, QFormLayout, QMessageBox
)
from PySide6.QtCore import Qt, Signal
from config import THEME
class SettingsDialog(QDialog):
"""Settings dialog for application preferences."""
relay_settings_changed = Signal()
def __init__(self, settings_manager, parent=None):
super().__init__(parent)
self.settings_manager = settings_manager
self.setup_ui()
self.load_settings()
def setup_ui(self):
"""Setup the dialog UI."""
self.setWindowTitle("Settings")
self.setMinimumSize(500, 400)
self.setStyleSheet(f"""
QDialog {{
background-color: {THEME['bg_color']};
color: {THEME['fg_color']};
}}
QTabWidget::pane {{
border: 1px solid {THEME['highlight_color']};
background: {THEME['bg_color']};
}}
QTabBar::tab {{
background: {THEME['tab_bg']};
color: {THEME['fg_color']};
padding: 8px 16px;
margin-right: 2px;
}}
QTabBar::tab:selected {{
background: {THEME['tab_selected']};
}}
QGroupBox {{
border: 1px solid {THEME['highlight_color']};
border-radius: 4px;
margin-top: 12px;
padding-top: 8px;
color: {THEME['fg_color']};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}}
QLabel {{
color: {THEME['fg_color']};
}}
QLineEdit {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 6px;
}}
QLineEdit:focus {{
border-color: {THEME['accent_color']};
}}
QLineEdit:read-only {{
background-color: {THEME['bg_color']};
}}
QCheckBox {{
color: {THEME['fg_color']};
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
}}
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
QPushButton:pressed {{
background-color: {THEME['accent_color']};
}}
""")
layout = QVBoxLayout(self)
# Tab widget
self.tab_widget = QTabWidget()
# General tab
general_tab = self.create_general_tab()
self.tab_widget.addTab(general_tab, "General")
# Relay Server tab
relay_tab = self.create_relay_tab()
self.tab_widget.addTab(relay_tab, "Relay Server")
layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
}}
QPushButton:hover {{
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_settings)
button_layout.addWidget(save_btn)
layout.addLayout(button_layout)
def create_general_tab(self) -> QWidget:
"""Create the general settings tab."""
tab = QWidget()
layout = QVBoxLayout(tab)
# Behavior group
behavior_group = QGroupBox("Behavior")
behavior_layout = QVBoxLayout(behavior_group)
self.minimize_to_tray_cb = QCheckBox("Minimize to system tray")
self.minimize_to_tray_cb.setToolTip(
"When enabled, minimizing the window will hide it to the system tray\n"
"instead of the taskbar. Double-click the tray icon to restore."
)
behavior_layout.addWidget(self.minimize_to_tray_cb)
layout.addWidget(behavior_group)
layout.addStretch()
return tab
def create_relay_tab(self) -> QWidget:
"""Create the relay server settings tab."""
tab = QWidget()
layout = QVBoxLayout(tab)
# Info label
info_label = QLabel(
"The relay server allows you to access MacroPad from anywhere\n"
"via a secure HTTPS connection. Your macros will be accessible\n"
"through a unique URL that you can share with your devices."
)
info_label.setWordWrap(True)
info_label.setStyleSheet(f"color: #aaa; margin-bottom: 10px;")
layout.addWidget(info_label)
# Connection group
connection_group = QGroupBox("Connection")
connection_layout = QFormLayout(connection_group)
self.relay_enabled_cb = QCheckBox("Enable relay server")
self.relay_enabled_cb.stateChanged.connect(self.on_relay_enabled_changed)
connection_layout.addRow("", self.relay_enabled_cb)
self.relay_url_edit = QLineEdit()
self.relay_url_edit.setPlaceholderText("wss://relay.example.com")
connection_layout.addRow("Server URL:", self.relay_url_edit)
layout.addWidget(connection_group)
# Authentication group
auth_group = QGroupBox("Authentication")
auth_layout = QFormLayout(auth_group)
self.relay_password_edit = QLineEdit()
self.relay_password_edit.setEchoMode(QLineEdit.Password)
self.relay_password_edit.setPlaceholderText("Required - minimum 8 characters")
auth_layout.addRow("Password:", self.relay_password_edit)
# Show password toggle
show_password_cb = QCheckBox("Show password")
show_password_cb.stateChanged.connect(
lambda state: self.relay_password_edit.setEchoMode(
QLineEdit.Normal if state else QLineEdit.Password
)
)
auth_layout.addRow("", show_password_cb)
layout.addWidget(auth_group)
# Status group
status_group = QGroupBox("Status")
status_layout = QFormLayout(status_group)
self.relay_session_id_edit = QLineEdit()
self.relay_session_id_edit.setReadOnly(True)
self.relay_session_id_edit.setPlaceholderText("Not connected")
status_layout.addRow("Session ID:", self.relay_session_id_edit)
self.relay_full_url_edit = QLineEdit()
self.relay_full_url_edit.setReadOnly(True)
self.relay_full_url_edit.setPlaceholderText("Connect to see your URL")
status_layout.addRow("Your URL:", self.relay_full_url_edit)
# Regenerate button
regen_btn = QPushButton("Generate New URL")
regen_btn.setToolTip("Generate a new unique URL (invalidates the old one)")
regen_btn.clicked.connect(self.regenerate_session_id)
status_layout.addRow("", regen_btn)
layout.addWidget(status_group)
layout.addStretch()
return tab
def load_settings(self):
"""Load current settings into the UI."""
# General
self.minimize_to_tray_cb.setChecked(
self.settings_manager.get_minimize_to_tray()
)
# Relay
self.relay_enabled_cb.setChecked(
self.settings_manager.get_relay_enabled()
)
self.relay_url_edit.setText(
self.settings_manager.get_relay_url()
)
self.relay_password_edit.setText(
self.settings_manager.get_relay_password()
)
session_id = self.settings_manager.get_relay_session_id()
if session_id:
self.relay_session_id_edit.setText(session_id)
base_url = self.relay_url_edit.text().replace('wss://', 'https://').replace('ws://', 'http://')
base_url = base_url.replace('/desktop', '')
self.relay_full_url_edit.setText(f"{base_url}/{session_id}")
self.on_relay_enabled_changed()
def on_relay_enabled_changed(self):
"""Handle relay enabled checkbox change."""
enabled = self.relay_enabled_cb.isChecked()
self.relay_url_edit.setEnabled(enabled)
self.relay_password_edit.setEnabled(enabled)
def validate_settings(self) -> bool:
"""Validate settings before saving."""
if self.relay_enabled_cb.isChecked():
url = self.relay_url_edit.text().strip()
password = self.relay_password_edit.text()
if not url:
QMessageBox.warning(
self, "Validation Error",
"Relay server URL is required when relay is enabled."
)
return False
if not url.startswith(('ws://', 'wss://')):
QMessageBox.warning(
self, "Validation Error",
"Relay server URL must start with ws:// or wss://"
)
return False
if len(password) < 8:
QMessageBox.warning(
self, "Validation Error",
"Password must be at least 8 characters."
)
return False
return True
def save_settings(self):
"""Save settings and close dialog."""
if not self.validate_settings():
return
# General
self.settings_manager.set(
'minimize_to_tray',
self.minimize_to_tray_cb.isChecked()
)
# Relay - check if settings changed
old_enabled = self.settings_manager.get_relay_enabled()
old_url = self.settings_manager.get_relay_url()
old_password = self.settings_manager.get_relay_password()
new_enabled = self.relay_enabled_cb.isChecked()
new_url = self.relay_url_edit.text().strip()
new_password = self.relay_password_edit.text()
relay_changed = (
old_enabled != new_enabled or
old_url != new_url or
old_password != new_password
)
self.settings_manager.set('relay.enabled', new_enabled)
self.settings_manager.set('relay.server_url', new_url)
# Store the password via the keyring-aware setter (also persists the
# rest of the settings). Avoid writing the plaintext password to JSON.
ok = self.settings_manager.set_relay_password(new_password)
if not ok:
QMessageBox.warning(
self, "Save Warning",
"Settings could not be fully saved to disk."
)
if relay_changed:
self.relay_settings_changed.emit()
self.accept()
def regenerate_session_id(self):
"""Clear session ID to force regeneration on next connect."""
reply = QMessageBox.question(
self, "Regenerate URL",
"This will generate a new URL. The old URL will stop working.\n\n"
"Are you sure you want to continue?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.settings_manager.set('relay.session_id', None)
self.relay_session_id_edit.setText("")
self.relay_full_url_edit.setText("")
self.settings_manager.save()
QMessageBox.information(
self, "URL Regenerated",
"A new URL will be generated when you reconnect to the relay server."
)
+171
View File
@@ -0,0 +1,171 @@
# Settings Manager for MacroPad Server
import os
import json
import secrets
from typing import Any, Optional
# Optional OS keyring for storing the relay password at rest. If the library or
# a working backend is unavailable we fall back to the JSON file (now chmod 0600).
try:
import keyring as _keyring
except Exception: # ImportError or any backend import-time failure
_keyring = None
KEYRING_SERVICE = "MacroPad"
KEYRING_USER = "relay"
DEFAULT_SETTINGS = {
"relay": {
"enabled": False,
"server_url": "wss://relay.macropad.example.com",
"session_id": None,
"password": ""
},
"web": {
"auth_token": "",
"allow_lan": True
},
"minimize_to_tray": True
}
class SettingsManager:
"""Manages application settings with JSON persistence."""
def __init__(self, settings_file: str):
self.settings_file = settings_file
self.settings = {}
self.load()
def load(self):
"""Load settings from file."""
if os.path.exists(self.settings_file):
try:
with open(self.settings_file, 'r', encoding='utf-8') as f:
self.settings = json.load(f)
except (json.JSONDecodeError, IOError):
self.settings = {}
# Merge with defaults to ensure all keys exist
self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings)
# Ensure a web auth token exists (this is the security boundary for API access)
if not self.get('web.auth_token'):
token = secrets.token_urlsafe(32)
self.set('web.auth_token', token)
self.save()
def save(self):
"""Save settings to file."""
try:
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2)
# Best-effort restrict permissions (protects stored relay password + token)
try:
os.chmod(self.settings_file, 0o600)
except OSError:
pass
return True
except IOError:
return False
def _merge_defaults(self, defaults: dict, current: dict) -> dict:
"""Merge current settings with defaults, keeping current values."""
result = defaults.copy()
for key, value in current.items():
if key in result:
if isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._merge_defaults(result[key], value)
else:
result[key] = value
else:
result[key] = value
return result
def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value by key (supports dot notation)."""
keys = key.split('.')
value = self.settings
try:
for k in keys:
value = value[k]
return value
except (KeyError, TypeError):
return default
def set(self, key: str, value: Any):
"""Set a setting value by key (supports dot notation)."""
keys = key.split('.')
target = self.settings
for k in keys[:-1]:
if k not in target:
target[k] = {}
target = target[k]
target[keys[-1]] = value
def get_relay_enabled(self) -> bool:
"""Check if relay server is enabled."""
return self.get('relay.enabled', False)
def get_relay_url(self) -> str:
"""Get the relay server URL."""
return self.get('relay.server_url', '')
def get_relay_session_id(self) -> Optional[str]:
"""Get the stored relay session ID."""
return self.get('relay.session_id')
def get_relay_password(self) -> str:
"""Get the relay password, preferring the OS keyring when available."""
if _keyring is not None:
try:
value = _keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
if value:
return value
except Exception:
# Keyring backend missing/broken: fall back to JSON.
pass
return self.get('relay.password', '')
def set_relay_password(self, password: str) -> bool:
"""Store the relay password, preferring the OS keyring when available.
When keyring works, the plaintext password is kept out of the JSON file.
Never crashes if the keyring backend is missing; falls back to JSON.
Returns True on successful persistence.
"""
password = password or ""
stored_in_keyring = False
if _keyring is not None:
try:
_keyring.set_password(KEYRING_SERVICE, KEYRING_USER, password)
stored_in_keyring = True
except Exception:
stored_in_keyring = False
if stored_in_keyring:
# Don't persist plaintext in the JSON when the keyring holds it.
self.set('relay.password', "")
else:
self.set('relay.password', password)
return self.save()
def set_relay_session_id(self, session_id: str) -> bool:
"""Store the relay session ID. Returns True on successful save."""
self.set('relay.session_id', session_id)
return self.save()
def get_web_auth_token(self) -> str:
"""Get the web API auth token."""
return self.get('web.auth_token', '')
def get_web_allow_lan(self) -> bool:
"""Check if LAN (0.0.0.0) binding is allowed."""
return self.get('web.allow_lan', True)
def get_minimize_to_tray(self) -> bool:
"""Check if minimize to tray is enabled."""
return self.get('minimize_to_tray', True)
+1
View File
@@ -0,0 +1 @@
# MacroPad Server GUI Widgets
+408 -178
View File
@@ -1,244 +1,474 @@
# Macro management and execution
# Macro management and execution with command sequence support
import copy
import json
import os
import shlex
import tempfile
import threading
import uuid
import pyautogui
import subprocess
import time
from PIL import Image
from typing import Optional
# --- Payload validation for execution ---------------------------------------
# Allowlist of key names pyautogui knows how to press. Anything not in this set
# is rejected before being handed to pyautogui so arbitrary strings can't be
# passed through. Populated from pyautogui when available.
VALID_KEYS = frozenset(getattr(pyautogui, "KEYBOARD_KEYS", []) or [])
# Upper bound (milliseconds) for a single wait step so a macro can't hang the
# executor forever.
MAX_WAIT_MS = 60000
# Only persist last_used to disk at most this often (seconds) to avoid rewriting
# the whole JSON file on every single execution.
LAST_USED_SAVE_INTERVAL = 5.0
class MacroManager:
def __init__(self, data_file, images_dir, app_dir):
"""Manages macro storage, migration, and execution with command sequences.
Thread-safe: all reads/writes of ``self.macros`` and all disk writes are
guarded by ``self._lock``. Public methods acquire the lock and delegate to
``*_locked`` internal helpers so we never re-acquire a non-reentrant lock.
"""
def __init__(self, data_file: str, images_dir: str, app_dir: str):
self.data_file = data_file
self.images_dir = images_dir
self.app_dir = app_dir
self.macros = {}
self._lock = threading.Lock()
# Timestamp of the last last_used-triggered save (for debouncing).
self._last_used_save_time = 0.0
self.load_macros()
def load_macros(self):
"""Load macros from JSON file"""
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
"""Load macros from JSON file and migrate if needed."""
with self._lock:
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
if migrated:
self._save_macros_locked()
print("Migrated macros to new command sequence format")
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def _migrate_macro(self, old_macro: dict) -> dict:
"""Convert old macro format to new command sequence format."""
if old_macro.get("type") == "sequence":
return old_macro
commands = []
modifiers = old_macro.get("modifiers", {})
if old_macro.get("type") == "text":
# Build held keys list
held_keys = []
for mod in ["ctrl", "alt", "shift"]:
if modifiers.get(mod):
held_keys.append(mod)
if held_keys:
# Use hotkey for modified text
commands.append({
"type": "hotkey",
"keys": held_keys + [old_macro.get("command", "")]
})
else:
# Plain text
commands.append({
"type": "text",
"value": old_macro.get("command", "")
})
# Add enter if requested
if modifiers.get("enter"):
commands.append({"type": "key", "value": "enter"})
elif old_macro.get("type") == "app":
commands.append({
"type": "app",
"command": old_macro.get("command", "")
})
return {
"name": old_macro.get("name", "Unnamed"),
"type": "sequence",
"commands": commands,
"category": old_macro.get("category", ""),
"image_path": old_macro.get("image_path", ""),
"last_used": old_macro.get("last_used", 0)
}
def save_macros(self):
"""Save macros to JSON file"""
"""Save macros to JSON file (thread-safe, atomic)."""
with self._lock:
self._save_macros_locked()
def _save_macros_locked(self):
"""Atomically write macros to disk. Caller must hold ``self._lock``.
Writes to a temp file in the same directory then os.replace() over the
target so a crash mid-write can't leave a truncated/corrupt JSON file.
"""
try:
with open(self.data_file, "w") as file:
json.dump(self.macros, file, indent=4)
target_dir = os.path.dirname(self.data_file) or "."
os.makedirs(target_dir, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix=".macros-", suffix=".tmp", dir=target_dir
)
try:
with os.fdopen(fd, "w") as file:
json.dump(self.macros, file, indent=4)
file.flush()
os.fsync(file.fileno())
os.replace(tmp_path, self.data_file)
except Exception:
# Clean up the temp file on any failure
try:
os.remove(tmp_path)
except OSError:
pass
raise
except Exception as e:
print(f"Error saving macros: {e}")
def get_sorted_macros(self, sort_by="name"):
"""Get macros sorted by specified criteria"""
macro_list = list(self.macros.items())
def get_sorted_macros(self, sort_by: str = "name"):
"""Get macros sorted by specified criteria."""
with self._lock:
macro_list = list(self.macros.items())
if sort_by == "name":
macro_list.sort(key=lambda x: x[1]["name"].lower())
macro_list.sort(key=lambda x: x[1].get("name", "").lower())
elif sort_by == "type":
macro_list.sort(key=lambda x: (x[1].get("type", ""), x[1]["name"].lower()))
# Sort by first command type in sequence
def get_first_type(macro):
cmds = macro.get("commands", [])
return cmds[0].get("type", "") if cmds else ""
macro_list.sort(
key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower())
)
elif sort_by == "recent":
# Sort by last_used timestamp if available, otherwise by name
macro_list.sort(key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), reverse=True)
macro_list.sort(
key=lambda x: (x[1].get("last_used", 0), x[1].get("name", "").lower()),
reverse=True
)
return macro_list
def filter_macros_by_tab(self, macro_list, tab_name):
"""Filter macros based on tab name"""
def filter_macros_by_tab(self, macro_list: list, tab_name: str):
"""Filter macros based on tab/category name."""
if tab_name == "All":
return macro_list
filtered = []
for macro_id, macro in macro_list:
# Check type match
if macro.get("type", "").title() == tab_name:
# Check category match
if macro.get("category") == tab_name:
filtered.append((macro_id, macro))
# Check custom category match
elif macro.get("category") == tab_name:
filtered.append((macro_id, macro))
# Check first command type match
elif macro.get("commands"):
first_type = macro["commands"][0].get("type", "").title()
if first_type == tab_name:
filtered.append((macro_id, macro))
return filtered
def get_unique_tabs(self):
"""Get list of unique tabs based on macro types and categories"""
def get_unique_tabs(self) -> list:
"""Get list of unique tabs based on categories."""
tabs = ["All"]
unique_types = set()
for macro in self.macros.values():
if macro.get("type"):
unique_types.add(macro["type"].title())
if macro.get("category"):
unique_types.add(macro["category"])
for tab_type in sorted(unique_types):
if tab_type not in ["All"]:
tabs.append(tab_type)
categories = set()
with self._lock:
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
for category in sorted(categories):
if category and category not in tabs:
tabs.append(category)
return tabs
def add_macro(self, name, macro_type, command, category="", modifiers=None, image_path=""):
"""Add a new macro"""
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
def add_macro(
self,
name: str,
commands: list,
category: str = "",
image_path: str = ""
) -> str:
"""Add a new macro with command sequence."""
macro_id = str(uuid.uuid4())
# Process image if provided
image_path_reference = ""
if image_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(image_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(image_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
# Create macro data
# Process image if provided (file IO; does not touch self.macros)
image_path_reference = self._process_image(image_path) if image_path else ""
# Create macro data (deep copy commands to avoid reference issues)
macro_data = {
"name": name,
"type": macro_type,
"command": command,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"modifiers": modifiers,
"last_used": 0
}
if category:
macro_data["category"] = category
self.macros[macro_id] = macro_data
self.save_macros()
with self._lock:
self.macros[macro_id] = macro_data
self._save_macros_locked()
return macro_id
def update_macro(self, macro_id, name, macro_type, command, category="", modifiers=None, image_path=""):
"""Update an existing macro"""
if macro_id not in self.macros:
return False
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
macro = self.macros[macro_id]
# Keep the old image or update with new one
image_path_reference = macro.get("image_path", "")
if image_path:
try:
# Generate unique filename for the image
file_ext = os.path.splitext(image_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(image_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
# Store the relative path to the image
image_path_reference = os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
def update_macro(
self,
macro_id: str,
name: str,
commands: list,
category: str = "",
image_path: Optional[str] = None
) -> bool:
"""Update an existing macro."""
with self._lock:
if macro_id not in self.macros:
return False
existing_image = self.macros[macro_id].get("image_path", "")
existing_last_used = self.macros[macro_id].get("last_used", 0)
# Update macro data
updated_macro = {
# Keep old image or update with new one (file IO outside the lock)
if image_path is not None:
image_path_reference = self._process_image(image_path) if image_path else ""
else:
image_path_reference = existing_image
new_data = {
"name": name,
"type": macro_type,
"command": command,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"modifiers": modifiers,
"last_used": macro.get("last_used", 0)
"last_used": existing_last_used
}
if category:
updated_macro["category"] = category
self.macros[macro_id] = updated_macro
self.save_macros()
with self._lock:
if macro_id not in self.macros:
return False
self.macros[macro_id] = new_data
self._save_macros_locked()
return True
def delete_macro(self, macro_id):
"""Delete a macro"""
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
# Delete associated image file if it exists
if "image_path" in macro and macro["image_path"]:
def _process_image(self, image_path: str) -> str:
"""Process and store an image for a macro."""
if not image_path:
return ""
try:
file_ext = os.path.splitext(image_path)[1].lower()
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
dest_path = os.path.join(self.images_dir, unique_filename)
# Resize image to max 256x256
with Image.open(image_path) as img:
img.thumbnail((256, 256))
img.save(dest_path)
return os.path.join("macro_images", unique_filename)
except Exception as e:
print(f"Error processing image: {e}")
return ""
def delete_macro(self, macro_id: str) -> bool:
"""Delete a macro."""
with self._lock:
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
image_path = macro.get("image_path")
del self.macros[macro_id]
self._save_macros_locked()
# Delete associated image file (outside the lock; pure file IO)
if image_path:
try:
img_path = os.path.join(self.app_dir, macro["image_path"])
img_path = os.path.join(self.app_dir, image_path)
if os.path.exists(img_path):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[macro_id]
self.save_macros()
return True
def execute_macro(self, macro_id):
"""Execute a macro by ID"""
if macro_id not in self.macros:
return False
def execute_macro(self, macro_id: str) -> bool:
"""Execute a macro by ID.
The macro's commands are copied under the lock, then executed *outside*
the lock so long-running wait/keyboard steps don't block other threads
(web server, GUI). last_used is updated in memory and only persisted at
most every LAST_USED_SAVE_INTERVAL seconds to avoid rewriting the whole
file on every press.
"""
with self._lock:
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
commands = copy.deepcopy(macro.get("commands", []))
# Update last_used in memory; debounce the disk write.
now = time.time()
macro["last_used"] = now
if now - self._last_used_save_time >= LAST_USED_SAVE_INTERVAL:
self._last_used_save_time = now
self._save_macros_locked()
macro = self.macros[macro_id]
# Update last_used timestamp for recent sorting
self.macros[macro_id]["last_used"] = time.time()
self.save_macros()
try:
if macro["type"] == "text":
# Handle key modifiers
modifiers = macro.get("modifiers", {})
# Add modifier keys if enabled
if modifiers.get("ctrl", False):
pyautogui.keyDown('ctrl')
if modifiers.get("alt", False):
pyautogui.keyDown('alt')
if modifiers.get("shift", False):
pyautogui.keyDown('shift')
# Handle single character vs multi-character commands
if str(macro["command"]) and len(str(macro["command"])) == 1:
pyautogui.keyDown(macro["command"])
time.sleep(0.5)
pyautogui.keyUp(macro["command"])
else:
pyautogui.typewrite(macro["command"], interval=0.02)
# Release modifier keys in reverse order
if modifiers.get("shift", False):
pyautogui.keyUp('shift')
if modifiers.get("alt", False):
pyautogui.keyUp('alt')
if modifiers.get("ctrl", False):
pyautogui.keyUp('ctrl')
# Add Enter/Return if requested
if modifiers.get("enter", False):
pyautogui.press('enter')
elif macro["type"] == "app":
subprocess.Popen(macro["command"], shell=True)
for cmd in commands:
self._execute_command(cmd)
return True
except Exception as e:
print(f"Error executing macro: {e}")
return False
return False
@staticmethod
def _valid_keys(keys) -> list:
"""Filter a list of key names to those pyautogui recognises."""
result = []
for k in keys:
if not isinstance(k, str):
continue
name = k.strip().lower()
if not name:
continue
if VALID_KEYS and name not in VALID_KEYS:
print(f"Skipping invalid key: {name!r}")
continue
result.append(name)
return result
def _execute_command(self, cmd: dict):
"""Execute a single command from a sequence."""
cmd_type = cmd.get("type", "")
if cmd_type == "text":
# Type text string using clipboard for Unicode support
value = cmd.get("value", "")
if value:
try:
import pyperclip
# Save current clipboard, paste text, restore (optional)
pyperclip.copy(value)
pyautogui.hotkey("ctrl", "v")
time.sleep(0.05) # Small delay after paste
except ImportError:
# Fallback to typewrite for ASCII-only
if len(value) == 1:
pyautogui.press(value)
else:
pyautogui.typewrite(value, interval=0.02)
elif cmd_type == "key":
# Press a single key (validated against pyautogui's known keys)
key = cmd.get("value", "")
valid = self._valid_keys([key]) if key else []
if valid:
pyautogui.press(valid[0])
elif cmd_type == "hotkey":
# Press key combination
keys = cmd.get("keys", [])
if keys:
# Ensure keys is a list, not a string
if isinstance(keys, str):
keys = [k.strip().lower() for k in keys.split(",")]
valid = self._valid_keys(keys)
# Only fire if every requested key was valid; a partial hotkey
# is worse than nothing.
if valid and len(valid) == len([k for k in keys if str(k).strip()]):
# Small delay before hotkey for reliability on Windows
time.sleep(0.05)
pyautogui.hotkey(*valid, interval=0.05)
elif cmd_type == "wait":
# Delay in milliseconds (bounded, non-negative)
try:
ms = int(cmd.get("ms", 0))
except (TypeError, ValueError):
ms = 0
if ms > 0:
ms = min(ms, MAX_WAIT_MS)
time.sleep(ms / 1000.0)
elif cmd_type == "app":
# Launch application.
# SECURITY: shell=True was removed to kill shell-metacharacter
# injection (no ; && | $() chaining). We tokenize the command
# and exec the program directly without a shell.
command = cmd.get("command", "")
if command:
try:
args = shlex.split(command, posix=(os.name != "nt"))
if args:
subprocess.Popen(args)
except Exception as e:
print(f"Error launching app command: {e}")
# Legacy API compatibility methods
def add_macro_legacy(
self,
name: str,
macro_type: str,
command: str,
category: str = "",
modifiers: Optional[dict] = None,
image_path: str = ""
) -> str:
"""Add macro using legacy format (auto-converts to sequence)."""
if modifiers is None:
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
# Build command sequence
commands = []
held_keys = []
for mod in ["ctrl", "alt", "shift"]:
if modifiers.get(mod):
held_keys.append(mod)
if macro_type == "text":
if held_keys:
commands.append({"type": "hotkey", "keys": held_keys + [command]})
else:
commands.append({"type": "text", "value": command})
if modifiers.get("enter"):
commands.append({"type": "key", "value": "enter"})
elif macro_type == "app":
commands.append({"type": "app", "command": command})
return self.add_macro(name, commands, category, image_path)
def get_macro(self, macro_id: str) -> Optional[dict]:
"""Get a macro by ID."""
with self._lock:
macro = self.macros.get(macro_id)
return copy.deepcopy(macro) if macro is not None else None
def get_all_macros(self) -> dict:
"""Get all macros."""
with self._lock:
return copy.deepcopy(self.macros)
+52
View File
@@ -0,0 +1,52 @@
# MacroPad Relay Server Configuration
# Copy this file to .env and adjust values as needed
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
# Security
BCRYPT_ROUNDS=10
# Session ID length (default: 12 characters)
SESSION_ID_LENGTH=12
# Minimum password length enforced on session creation
MIN_PASSWORD_LENGTH=6
# Maximum number of concurrent sessions
MAX_SESSIONS=1000
# Prune sessions unused for this many days
SESSION_TTL_DAYS=90
# HTTP rate limiting (15 minute window, 300 requests per window)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=300
# WebSocket upgrade rate limiting (per IP)
WS_UPGRADE_WINDOW_MS=60000
WS_UPGRADE_MAX=60
# Auth brute-force protection
AUTH_MAX_SOCKET_FAILURES=5
AUTH_LOCKOUT_MAX_ATTEMPTS=10
AUTH_LOCKOUT_WINDOW_MS=900000
AUTH_LOCKOUT_DURATION_MS=900000
# Allowed origins for CORS + WebSocket (comma-separated). Empty = same-host only.
# ALLOWED_ORIGINS=https://macropad.example.com
# Trust reverse proxy (number of hops, or true/false). Default: 1
TRUST_PROXY=1
# WebSocket timeouts (in milliseconds)
PING_INTERVAL=30000
REQUEST_TIMEOUT=30000
# Logging (error, warn, info, debug)
LOG_LEVEL=info
# Data directory (default: ./data)
# DATA_DIR=/path/to/data
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# Data
data/sessions.json
# Environment
.env
# Logs
*.log
error.log
combined.log
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
+140
View File
@@ -0,0 +1,140 @@
# MacroPad Relay Server - Deployment Guide
## Cloud Node Container Deployment
For AnHonestHost cloud-node-container deployment:
### 1. Build Locally
```bash
cd /home/jknapp/code/macropad/macropad-relay
npm install
npm run build # required: the build is no longer triggered automatically on install
```
> Note: the `postinstall` build hook was removed. Always run `npm run build`
> explicitly (locally or in CI) after `npm install` to produce `dist/`.
### 2. Prepare Deployment Package
The build outputs to `dist/` with public files copied. Upload:
```bash
# Upload built files to your node container app directory
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' \
dist/ package.json public/ \
user@YOUR_SERVER:/path/to/app/
```
### 3. On Server
The cloud-node-container will automatically:
- Install dependencies from package.json
- Start the app using PM2
- Configure the process from package.json settings
### 4. Create Data Directory
```bash
mkdir -p /path/to/app/data
```
## Directory Structure on Server
```
app/
├── index.js # Main entry (compiled)
├── config.js
├── server.js
├── services/
├── handlers/
├── utils/
├── public/
│ ├── login.html
│ └── app.html
├── data/
│ └── sessions.json # Created automatically
└── package.json
```
## Update After Code Changes
```bash
# On local machine:
cd /home/jknapp/code/macropad/macropad-relay
npm run build
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' --exclude 'data' \
dist/ package.json public/ \
user@YOUR_SERVER:/path/to/app/
# On server - restart via your container's control panel or:
pm2 restart macropad-relay
```
## Environment Variables
Set these in your container configuration:
- `PORT` - Server port (default: 3000)
- `DATA_DIR` - Data storage path (default: ./data)
- `NODE_ENV` - production or development
- `LOG_LEVEL` - info, debug, error
- `ALLOWED_ORIGINS` - Comma-separated list of allowed browser origins for the
WebSocket Origin check (e.g. `https://macropad.example.com`). **Set this to
your public origin(s) when running behind a reverse proxy** (see note below).
> **Reverse proxy note (`ALLOWED_ORIGINS`):** When `ALLOWED_ORIGINS` is unset,
> the WebSocket upgrade Origin check falls back to comparing the browser's
> `Origin` host against the raw `Host` header seen by the app. Behind a reverse
> proxy the internal `Host` (e.g. `localhost:3000`) often differs from the
> public origin the browser sends (e.g. `https://macropad.example.com`), so
> legitimate browser WebSocket upgrades get **rejected**. Set `ALLOWED_ORIGINS`
> to your public origin(s) to fix this. (Forwarding the public host via
> `proxy_set_header Host $host;`, as in the Nginx example below, also helps, but
> setting `ALLOWED_ORIGINS` explicitly is the reliable fix.)
## Test It Works
```bash
# Test health endpoint
curl http://localhost:3000/health
# Should return (counts only - no session ids are exposed):
# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23}
```
## Nginx/Reverse Proxy (for HTTPS)
```nginx
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeout (24 hours)
proxy_read_timeout 86400;
}
```
## Troubleshooting
**Check logs:**
```bash
pm2 logs macropad-relay
```
**Check sessions:**
```bash
cat /path/to/app/data/sessions.json
```
**Port in use:**
```bash
lsof -i :3000
```
+2484
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "macropad-relay",
"version": "1.0.0",
"description": "Relay server for MacroPad remote access",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
"clean": "rm -rf dist"
},
"keywords": ["macropad", "relay", "websocket", "proxy"],
"author": "ShadowDao",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"typescript": "^5.3.2",
"winston": "^3.11.0",
"ws": "^8.14.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.16",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.9",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2"
}
}
+282
View File
@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#007acc">
<meta name="description" content="Remote macro control for your desktop">
<!-- PWA / iOS specific -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<title>MacroPad</title>
<!-- PWA manifest will be dynamically set -->
<link rel="manifest" id="manifest-link">
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
:root {
--bg-color: #2e2e2e;
--fg-color: #ffffff;
--highlight-color: #3e3e3e;
--accent-color: #007acc;
--button-bg: #505050;
--button-hover: #606060;
--tab-bg: #404040;
--tab-selected: #007acc;
--danger-color: #dc3545;
--success-color: #28a745;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
min-height: 100dvh;
}
.header {
background-color: var(--highlight-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 { font-size: 1.5rem; }
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #aaa;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger-color);
}
.status-dot.connected { background: var(--success-color); }
.header-btn {
background: var(--button-bg);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.header-btn:hover { background: var(--button-hover); }
.header-btn.icon-btn {
padding: 0.5rem 0.75rem;
font-size: 1.2rem;
}
.wake-lock-status {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.wake-lock-status:hover { background: var(--button-bg); }
.wake-lock-status.active .wake-icon { color: var(--success-color); }
.wake-lock-status.unsupported { opacity: 0.3; cursor: default; }
.wake-lock-status.unsupported .wake-icon { color: #888; text-decoration: line-through; }
.wake-icon {
font-size: 1.2rem;
color: #888;
transition: color 0.2s;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
overflow-x: auto;
}
.tab {
background: var(--tab-bg);
color: var(--fg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.tab:hover { background: var(--button-hover); }
.tab.active { background: var(--tab-selected); }
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
}
.macro-card {
background: var(--button-bg);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
min-height: 120px;
}
.macro-card:hover { background: var(--button-hover); transform: translateY(-2px); }
.macro-card:active { transform: translateY(0); }
.macro-card.executing {
animation: pulse 0.3s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(0.95); background: var(--accent-color); }
100% { transform: scale(1); }
}
.macro-image {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.macro-image-placeholder {
width: 64px;
height: 64px;
background: var(--highlight-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.macro-name {
text-align: center;
font-size: 0.9rem;
word-break: break-word;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #888;
grid-column: 1 / -1;
}
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 300;
}
.toast {
background: var(--highlight-color);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 0.5rem;
animation: slideIn 0.3s ease-out;
}
.toast.success { border-left: 4px solid var(--success-color); }
.toast.error { border-left: 4px solid var(--danger-color); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.loading {
display: flex;
justify-content: center;
padding: 2rem;
grid-column: 1 / -1;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--button-bg);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.offline-banner {
background: var(--danger-color);
color: white;
text-align: center;
padding: 0.5rem;
display: none;
}
.offline-banner.visible { display: block; }
</style>
</head>
<body>
<div class="offline-banner" id="offline-banner">
Desktop is offline - waiting for reconnection...
</div>
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status">
<div class="status-dot"></div>
<span>Connecting...</span>
</div>
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
<span class="wake-icon"></span>
</div>
<button class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen"></button>
<button class="header-btn" id="refresh-btn">Refresh</button>
</div>
</header>
<nav class="tabs" id="tabs-container"></nav>
<main class="macro-grid" id="macro-grid">
<div class="loading"><div class="spinner"></div></div>
</main>
<div class="toast-container" id="toast-container"></div>
<script src="/static/app.js"></script>
</body>
</html>
+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();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad Relay</title>
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 600px;
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
color: #fff;
}
.tagline {
font-size: 1.2rem;
color: #888;
margin-bottom: 40px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
}
.card h2 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #4a9eff;
}
.card p {
line-height: 1.6;
color: #b0b0b0;
}
.features {
display: grid;
gap: 20px;
text-align: left;
}
.feature {
display: flex;
align-items: flex-start;
gap: 15px;
}
.feature-icon {
font-size: 24px;
flex-shrink: 0;
}
.feature-text h3 {
font-size: 1rem;
margin-bottom: 5px;
color: #fff;
}
.feature-text p {
font-size: 0.9rem;
color: #888;
}
.cta {
margin-top: 30px;
}
.cta a {
display: inline-block;
background: #4a9eff;
color: #fff;
text-decoration: none;
padding: 12px 30px;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
}
.cta a:hover {
background: #3a8eef;
}
.footer {
margin-top: 40px;
font-size: 0.85rem;
color: #666;
}
.footer a {
color: #4a9eff;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">&#9000;</div>
<h1>MacroPad Relay</h1>
<p class="tagline">Secure remote access to your MacroPad</p>
<div class="card">
<h2>What is this?</h2>
<p>
MacroPad Relay enables secure remote access to your MacroPad desktop application
from anywhere. Control your macros from your phone or tablet over HTTPS,
even when you're away from your local network.
</p>
</div>
<div class="card features">
<div class="feature">
<span class="feature-icon">&#128274;</span>
<div class="feature-text">
<h3>Secure Connection</h3>
<p>Password-protected sessions with encrypted WebSocket communication.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">&#127760;</span>
<div class="feature-text">
<h3>Access Anywhere</h3>
<p>Use your macros from any device with a web browser.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">&#9889;</span>
<div class="feature-text">
<h3>Real-time Sync</h3>
<p>Changes sync instantly between your desktop and mobile devices.</p>
</div>
</div>
</div>
<div class="cta">
<a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">Learn More</a>
</div>
<p class="footer">
Part of the <a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">MacroPad</a> project
</p>
</div>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad - Login</title>
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #2e2e2e;
color: #ffffff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-container {
background-color: #3e3e3e;
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
color: #007acc;
}
.subtitle {
text-align: center;
color: #aaa;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
}
input[type="password"] {
width: 100%;
padding: 0.75rem;
background-color: #2e2e2e;
border: 1px solid #505050;
border-radius: 4px;
color: #fff;
font-size: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: #007acc;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #0096ff;
}
button:disabled {
background-color: #505050;
cursor: not-allowed;
}
.error {
background-color: rgba(220, 53, 69, 0.2);
border: 1px solid #dc3545;
color: #dc3545;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.status {
text-align: center;
margin-top: 1rem;
color: #aaa;
font-size: 0.85rem;
}
.status.connected {
color: #28a745;
}
.status.disconnected {
color: #dc3545;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.checkbox-group input {
width: auto;
}
.checkbox-group label {
margin: 0;
color: #aaa;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="login-container">
<h1>MacroPad</h1>
<p class="subtitle">Enter password to access your macros</p>
<div class="error" id="error"></div>
<form id="loginForm">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required autofocus>
</div>
<div class="checkbox-group">
<input type="checkbox" id="remember">
<label for="remember">Remember on this device</label>
</div>
<button type="submit" id="submitBtn">Connect</button>
</form>
<p class="status" id="status">Checking connection...</p>
</div>
<script src="/static/login.js"></script>
</body>
</html>
+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();
+71
View File
@@ -0,0 +1,71 @@
// Configuration for MacroPad Relay Server
import dotenv from 'dotenv';
import path from 'path';
dotenv.config();
function parseTrustProxy(): boolean | number | string {
const v = process.env.TRUST_PROXY ?? '1';
if (v === 'false') return false;
if (v === 'true') return true;
const n = Number(v);
return Number.isNaN(n) ? v : n;
}
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
// Data storage
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
// Session settings
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '12', 10),
// Maximum number of sessions that may exist at once
maxSessions: parseInt(process.env.MAX_SESSIONS || '1000', 10),
// Minimum password length enforced when creating a new session
minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH || '6', 10),
// Sessions unused for this many days are pruned
sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS || '90', 10),
// Security
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
// Rate limiting (HTTP)
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
// WebSocket upgrade rate limiting (per IP)
wsUpgradeWindowMs: parseInt(process.env.WS_UPGRADE_WINDOW_MS || '60000', 10), // 1 minute
wsUpgradeMax: parseInt(process.env.WS_UPGRADE_MAX || '60', 10), // max upgrades per IP per window
// Auth brute-force protection
authMaxSocketFailures: parseInt(process.env.AUTH_MAX_SOCKET_FAILURES || '5', 10),
authLockoutMaxAttempts: parseInt(process.env.AUTH_LOCKOUT_MAX_ATTEMPTS || '10', 10),
authLockoutWindowMs: parseInt(process.env.AUTH_LOCKOUT_WINDOW_MS || '900000', 10), // 15 minutes
authLockoutDurationMs: parseInt(process.env.AUTH_LOCKOUT_DURATION_MS || '900000', 10), // 15 minutes
// WebSocket
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
// Origin / CORS allowlist (comma-separated). Empty = same-host only.
allowedOrigins: (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0),
// Trust the X-Forwarded-* headers from a reverse proxy
trustProxy: parseTrustProxy(),
// Logging
logLevel: process.env.LOG_LEVEL || 'info',
// Environment
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV !== 'production',
};
+84
View File
@@ -0,0 +1,84 @@
// API Proxy Handler - proxies REST requests to desktop apps
import { Request, Response, NextFunction } from 'express';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
export function createApiProxy(
connectionManager: ConnectionManager,
sessionManager: SessionManager
) {
return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId;
// Credentials must be supplied via the header (never the URL/query,
// which leaks into logs, history and referrers).
const password = req.headers['x-macropad-password'] as string;
// Uniform auth failure. A missing session, a missing password, and a
// wrong password all return the SAME status + generic message so a
// caller cannot infer whether a given session id exists (no
// enumeration oracle). validatePassword() safely returns false for an
// unknown session id.
if (!password || !(await sessionManager.validatePassword(sessionId, password))) {
return res.status(401).json({ error: 'Authentication failed' });
}
// Only AFTER successful auth do we reveal desktop connectivity, so the
// 503 "not connected" signal cannot itself be used to probe which
// session ids exist.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (!desktop) {
return res.status(503).json({ error: 'Desktop not connected' });
}
// Extract the API path (remove the session ID prefix)
const apiPath = req.path.replace(`/${sessionId}`, '');
try {
const response = await connectionManager.forwardApiRequest(
sessionId,
req.method,
apiPath,
req.body,
filterHeaders(req.headers)
);
// Handle binary responses (images)
if (response.body?.base64 && response.body?.contentType) {
const buffer = Buffer.from(response.body.base64, 'base64');
res.set('Content-Type', response.body.contentType);
res.send(buffer);
} else {
res.status(response.status).json(response.body);
}
} catch (error: any) {
logger.error(`API proxy error for ${sessionId}:`, error);
if (error.message === 'Request timeout') {
return res.status(504).json({ error: 'Desktop request timeout' });
}
if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') {
return res.status(503).json({ error: 'Desktop not connected' });
}
res.status(500).json({ error: 'Proxy error' });
}
};
}
function filterHeaders(headers: any): Record<string, string> {
// Only forward relevant headers
const allowed = ['content-type', 'accept', 'accept-language'];
const filtered: Record<string, string> = {};
for (const key of allowed) {
if (headers[key]) {
filtered[key] = headers[key];
}
}
return filtered;
}
@@ -0,0 +1,207 @@
// Desktop WebSocket Handler
import WebSocket from 'ws';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
import { config } from '../config';
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
// Close code 1008 = policy violation (used for auth abuse).
const WS_POLICY_VIOLATION = 1008;
interface AuthMessage {
type: 'auth';
sessionId: string | null;
password: string;
}
interface ApiResponseMessage {
type: 'api_response';
requestId: string;
status: number;
body: any;
}
interface WsBroadcastMessage {
type: 'ws_broadcast';
data: any;
}
interface PongMessage {
type: 'pong';
}
type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | PongMessage;
export function handleDesktopConnection(
socket: WebSocket,
connectionManager: ConnectionManager,
sessionManager: SessionManager,
clientIp: string
): void {
let authenticatedSessionId: string | null = null;
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
socket.on('message', async (data) => {
try {
const message: DesktopMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth': {
const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => {
authenticatedSessionId = sessionId;
});
if (failed) {
socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) {
logger.warn('Desktop exceeded auth attempts, closing socket');
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
}
} else {
socketAuthFailures = 0;
}
break;
}
case 'api_response':
if (authenticatedSessionId) {
handleApiResponse(message, authenticatedSessionId, connectionManager);
}
break;
case 'ws_broadcast':
if (authenticatedSessionId) {
handleWsBroadcast(message, authenticatedSessionId, connectionManager);
}
break;
case 'pong':
if (authenticatedSessionId) {
connectionManager.updateDesktopPing(authenticatedSessionId);
}
break;
default:
logger.warn('Unknown message type from desktop:', (message as any).type);
}
} catch (error) {
logger.error('Error handling desktop message:', error);
}
});
socket.on('close', () => {
if (authenticatedSessionId) {
connectionManager.disconnectDesktop(authenticatedSessionId);
}
});
socket.on('error', (error) => {
logger.error('Desktop WebSocket error:', error);
if (authenticatedSessionId) {
connectionManager.disconnectDesktop(authenticatedSessionId);
}
});
}
// Returns true if the auth attempt failed (so the caller can count it).
async function handleAuth(
socket: WebSocket,
message: AuthMessage,
sessionManager: SessionManager,
connectionManager: ConnectionManager,
clientIp: string,
setSessionId: (id: string) => void
): Promise<boolean> {
try {
let sessionId = message.sessionId;
let session;
if (sessionId) {
// Namespace the brute-force lockout key to the desktop keyspace and
// include the client IP. This keeps the desktop's lockout state entirely
// separate from the web-client keyspace, so a web-auth flood can never
// lock the desktop out of (re)authenticating.
const throttleKey = `desktop:${sessionId}:${clientIp}`;
// Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Too many failed attempts, try again later',
retryAfterMs: lockRemaining
}));
return true;
}
// Validate existing session
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (!valid) {
recordFailure(throttleKey);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid session ID or password'
}));
return true;
}
recordSuccess(throttleKey);
session = sessionManager.getSession(sessionId);
} else {
// Create new session (may throw on limit / short password)
session = await sessionManager.createSession(message.password);
sessionId = session.id;
}
if (!session || !sessionId) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Failed to create session'
}));
return true;
}
// Add to connection manager
connectionManager.addDesktopConnection(sessionId, socket);
setSessionId(sessionId);
// Send success response
socket.send(JSON.stringify({
type: 'auth_response',
success: true,
sessionId: sessionId
}));
logger.info(`Desktop authenticated: ${sessionId}`);
return false;
} catch (error: any) {
logger.error('Desktop auth error:', error);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: error?.message || 'Authentication failed'
}));
return true;
}
}
function handleApiResponse(
message: ApiResponseMessage,
sessionId: string,
connectionManager: ConnectionManager
): void {
connectionManager.handleApiResponse(sessionId, message.requestId, message.status, message.body);
}
function handleWsBroadcast(
message: WsBroadcastMessage,
sessionId: string,
connectionManager: ConnectionManager
): void {
// Forward the broadcast to all web clients for this session
connectionManager.broadcastToWebClients(sessionId, message.data);
}
@@ -0,0 +1,157 @@
// Web Client WebSocket Handler
import WebSocket from 'ws';
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
import { config } from '../config';
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
// Close code 1008 = policy violation (used for auth abuse).
const WS_POLICY_VIOLATION = 1008;
interface AuthMessage {
type: 'auth';
password: string;
}
interface PingMessage {
type: 'ping';
}
type WebClientMessage = AuthMessage | PingMessage | any;
export function handleWebClientConnection(
socket: WebSocket,
sessionId: string,
connectionManager: ConnectionManager,
sessionManager: SessionManager,
clientIp: string
): void {
// Namespace the brute-force lockout key to the web keyspace and include the
// client IP. This prevents one abusive IP (which knows the shareable session
// id) from locking out other web clients or the desktop for this session.
const throttleKey = `web:${sessionId}:${clientIp}`;
// Do NOT reveal whether the session exists. A non-existent session id and
// an existing one must be indistinguishable to an unauthenticated client so
// session ids cannot be enumerated: we always run the same handshake and let
// the (uniform) auth response be the only signal. validatePassword() safely
// returns false for an unknown session id, and desktop connectivity is only
// disclosed AFTER a successful auth (see the 'auth' case below).
const client = connectionManager.addWebClient(sessionId, socket, false);
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
// Request authentication.
socket.send(JSON.stringify({
type: 'auth_required'
}));
socket.on('message', async (data) => {
try {
const message: WebClientMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth': {
const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
if (failed) {
socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) {
logger.warn(`Web client exceeded auth attempts for session ${sessionId}, closing socket`);
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
}
} else {
socketAuthFailures = 0;
// Authenticated: only now do we disclose desktop connectivity.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
}
break;
}
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
break;
default:
// Forward other messages to desktop if authenticated
if (client.authenticated) {
forwardToDesktop(message, sessionId, connectionManager);
}
}
} catch (error) {
logger.error('Error handling web client message:', error);
}
});
socket.on('close', () => {
connectionManager.removeWebClient(sessionId, client);
});
socket.on('error', (error) => {
logger.error('Web client WebSocket error:', error);
connectionManager.removeWebClient(sessionId, client);
});
}
// Returns true if the auth attempt failed (so the caller can count it).
async function handleAuth(
socket: WebSocket,
client: WebClientConnection,
message: AuthMessage,
sessionId: string,
sessionManager: SessionManager,
throttleKey: string
): Promise<boolean> {
// Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Too many failed attempts, try again later',
retryAfterMs: lockRemaining
}));
return true;
}
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) {
recordSuccess(throttleKey);
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
success: true
}));
logger.debug(`Web client authenticated for session: ${sessionId}`);
return false;
} else {
recordFailure(throttleKey);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Authentication failed'
}));
return true;
}
}
function forwardToDesktop(
message: any,
sessionId: string,
connectionManager: ConnectionManager
): void {
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
desktop.socket.send(JSON.stringify({
type: 'ws_message',
data: message
}));
}
}
+21
View File
@@ -0,0 +1,21 @@
// MacroPad Relay Server Entry Point
import { createServer } from './server';
import { config } from './config';
import { logger } from './utils/logger';
async function main() {
logger.info('Starting MacroPad Relay Server...');
const { server } = createServer();
server.listen(config.port, config.host, () => {
logger.info(`Server running on http://${config.host}:${config.port}`);
logger.info(`Environment: ${config.nodeEnv}`);
});
}
main().catch((error) => {
logger.error('Failed to start server:', error);
process.exit(1);
});
+232
View File
@@ -0,0 +1,232 @@
// Express + WebSocket Server Setup
import express from 'express';
import fs from 'fs';
import http from 'http';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import { config } from './config';
import { logger } from './utils/logger';
import { SessionManager } from './services/SessionManager';
import { ConnectionManager } from './services/ConnectionManager';
import { handleDesktopConnection } from './handlers/desktopHandler';
import { handleWebClientConnection } from './handlers/webClientHandler';
import { createApiProxy } from './handlers/apiProxy';
// Resolve the client IP for an upgrade request, honouring the proxy's
// X-Forwarded-For first hop when we trust the proxy.
function getClientIp(request: http.IncomingMessage): string {
if (config.trustProxy) {
const xff = request.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff.length > 0) {
return xff.split(',')[0].trim();
}
if (Array.isArray(xff) && xff.length > 0) {
return xff[0].split(',')[0].trim();
}
}
return request.socket.remoteAddress || 'unknown';
}
// Simple in-memory sliding-window rate limiter for WS upgrades, keyed by IP.
function createUpgradeRateLimiter() {
const hits = new Map<string, number[]>();
const cleanup = setInterval(() => {
const cutoff = Date.now() - config.wsUpgradeWindowMs;
for (const [ip, times] of hits) {
const recent = times.filter((t) => t > cutoff);
if (recent.length === 0) {
hits.delete(ip);
} else {
hits.set(ip, recent);
}
}
}, config.wsUpgradeWindowMs);
if (typeof cleanup.unref === 'function') cleanup.unref();
return function allow(ip: string): boolean {
const now = Date.now();
const cutoff = now - config.wsUpgradeWindowMs;
const times = (hits.get(ip) || []).filter((t) => t > cutoff);
times.push(now);
hits.set(ip, times);
return times.length <= config.wsUpgradeMax;
};
}
// Validate the Origin header for a WS upgrade against the allowlist.
// Native (desktop) clients send no Origin and are permitted.
function isOriginAllowed(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return true; // non-browser clients (e.g. desktop app)
if (config.allowedOrigins.length > 0) {
return config.allowedOrigins.includes(origin);
}
// Default: allow only same-host origins.
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
export function createServer() {
const app = express();
const server = http.createServer(app);
// Trust the reverse proxy so client IPs (rate limiting) are accurate.
app.set('trust proxy', config.trustProxy);
// Initialize managers
const sessionManager = new SessionManager();
const connectionManager = new ConnectionManager();
// Middleware
app.use(helmet({
// Page scripts are served as external files (/static/app.js,
// /static/login.js) with no inline handlers, so scripts are restricted to
// 'self' (no 'unsafe-inline'). The pages still use inline <style> blocks,
// so style-src keeps 'unsafe-inline'. Images may be data:/blob: (macro
// images are loaded as blob object URLs) and WS connections need ws:/wss:.
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
manifestSrc: ["'self'", 'blob:'],
objectSrc: ["'none'"],
frameAncestors: ["'self'"],
},
},
}));
// Restrict CORS to the configured origins. When none are configured we
// disallow cross-origin requests (the first-party app is same-origin).
const corsOptions: cors.CorsOptions =
config.allowedOrigins.length > 0
? { origin: config.allowedOrigins }
: { origin: false };
app.use(cors(corsOptions));
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: config.rateLimitWindowMs,
max: config.rateLimitMax,
message: { error: 'Too many requests, please try again later' }
});
app.use(limiter);
// Static files - check both locations (dev vs production)
const publicPath = fs.existsSync(path.join(__dirname, 'public'))
? path.join(__dirname, 'public')
: path.join(__dirname, '..', 'public');
app.use('/static', express.static(publicPath));
// Health check - counts only, never leak session ids.
app.get('/health', (req, res) => {
const stats = connectionManager.getStats();
res.json({
status: 'ok',
desktops: stats.desktopConnections,
webClients: stats.webClients,
uptime: process.uptime()
});
});
// Ping endpoint for container health checks
app.get('/ping', (req, res) => {
res.json({ status: 'ok' });
});
// Index page
app.get('/', (req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Login page for session. Served uniformly regardless of whether the
// session exists so validity cannot be probed via this endpoint.
app.get('/:sessionId', (req, res) => {
res.sendFile(path.join(publicPath, 'login.html'));
});
// PWA app page (served uniformly; auth is enforced by the API/WS layer).
app.get('/:sessionId/app', (req, res) => {
res.sendFile(path.join(publicPath, 'app.html'));
});
// API proxy routes
const apiProxy = createApiProxy(connectionManager, sessionManager);
app.all('/:sessionId/api/*', apiProxy);
// WebSocket server
const wss = new WebSocketServer({ noServer: true });
const allowUpgrade = createUpgradeRateLimiter();
// Handle HTTP upgrade for WebSocket
server.on('upgrade', (request, socket, head) => {
// IP-based upgrade rate limiting (WS upgrades bypass Express middleware).
const ip = getClientIp(request);
if (!allowUpgrade(ip)) {
logger.warn(`WS upgrade rate limit exceeded for ${ip}`);
socket.destroy();
return;
}
// Origin validation.
if (!isOriginAllowed(request.headers.origin, request.headers.host)) {
logger.warn(`WS upgrade rejected: bad origin ${request.headers.origin}`);
socket.destroy();
return;
}
const url = new URL(request.url || '', `http://${request.headers.host}`);
const pathname = url.pathname;
// Desktop connection: /desktop
if (pathname === '/desktop') {
wss.handleUpgrade(request, socket, head, (ws) => {
handleDesktopConnection(ws, connectionManager, sessionManager, ip);
});
return;
}
// Web client connection: /:sessionId/ws
const webClientMatch = pathname.match(/^\/([a-zA-Z0-9]+)\/ws$/);
if (webClientMatch) {
const sessionId = webClientMatch[1];
wss.handleUpgrade(request, socket, head, (ws) => {
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager, ip);
});
return;
}
// Invalid path
socket.destroy();
});
// Graceful shutdown
const shutdown = () => {
logger.info('Shutting down...');
connectionManager.shutdown();
sessionManager.shutdown();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
return { app, server, sessionManager, connectionManager };
}
@@ -0,0 +1,275 @@
// Connection Manager - manages desktop and web client connections
import WebSocket from 'ws';
import { logger } from '../utils/logger';
import { generateRequestId } from '../utils/idGenerator';
import { config } from '../config';
export interface PendingRequest {
resolve: (response: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
export interface DesktopConnection {
sessionId: string;
socket: WebSocket;
authenticated: boolean;
connectedAt: Date;
lastPing: Date;
pendingRequests: Map<string, PendingRequest>;
}
export interface WebClientConnection {
socket: WebSocket;
sessionId: string;
authenticated: boolean;
}
export class ConnectionManager {
// Desktop connections: sessionId -> connection
private desktopConnections: Map<string, DesktopConnection> = new Map();
// Web clients: sessionId -> set of connections
private webClients: Map<string, Set<WebClientConnection>> = new Map();
// Ping interval handle
private pingInterval: NodeJS.Timeout | null = null;
constructor() {
this.startPingInterval();
}
private startPingInterval(): void {
this.pingInterval = setInterval(() => {
this.pingDesktops();
}, config.pingInterval);
}
private pingDesktops(): void {
const now = Date.now();
for (const [sessionId, desktop] of this.desktopConnections) {
// Check if desktop hasn't responded in too long
if (now - desktop.lastPing.getTime() > config.pingInterval * 2) {
logger.warn(`Desktop ${sessionId} not responding, disconnecting`);
this.disconnectDesktop(sessionId);
continue;
}
// Send ping
try {
desktop.socket.send(JSON.stringify({ type: 'ping' }));
} catch (error) {
logger.error(`Failed to ping desktop ${sessionId}:`, error);
this.disconnectDesktop(sessionId);
}
}
}
// Desktop connection methods
addDesktopConnection(sessionId: string, socket: WebSocket): DesktopConnection {
// Close existing connection if any
const existing = this.desktopConnections.get(sessionId);
if (existing) {
try {
existing.socket.close();
} catch (e) {
// Ignore
}
}
const connection: DesktopConnection = {
sessionId,
socket,
authenticated: true,
connectedAt: new Date(),
lastPing: new Date(),
pendingRequests: new Map()
};
this.desktopConnections.set(sessionId, connection);
logger.info(`Desktop connected: ${sessionId}`);
// Notify web clients
this.broadcastToWebClients(sessionId, {
type: 'desktop_status',
status: 'connected'
});
return connection;
}
disconnectDesktop(sessionId: string): void {
const connection = this.desktopConnections.get(sessionId);
if (connection) {
// Reject all pending requests
for (const [requestId, pending] of connection.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Desktop disconnected'));
}
try {
connection.socket.close();
} catch (e) {
// Ignore
}
this.desktopConnections.delete(sessionId);
logger.info(`Desktop disconnected: ${sessionId}`);
// Notify web clients
this.broadcastToWebClients(sessionId, {
type: 'desktop_status',
status: 'disconnected'
});
}
}
getDesktopBySessionId(sessionId: string): DesktopConnection | undefined {
return this.desktopConnections.get(sessionId);
}
updateDesktopPing(sessionId: string): void {
const connection = this.desktopConnections.get(sessionId);
if (connection) {
connection.lastPing = new Date();
}
}
// Web client connection methods
addWebClient(sessionId: string, socket: WebSocket, authenticated: boolean = false): WebClientConnection {
if (!this.webClients.has(sessionId)) {
this.webClients.set(sessionId, new Set());
}
const client: WebClientConnection = {
socket,
sessionId,
authenticated
};
this.webClients.get(sessionId)!.add(client);
logger.debug(`Web client connected to session: ${sessionId}`);
return client;
}
removeWebClient(sessionId: string, client: WebClientConnection): void {
const clients = this.webClients.get(sessionId);
if (clients) {
clients.delete(client);
if (clients.size === 0) {
this.webClients.delete(sessionId);
}
}
logger.debug(`Web client disconnected from session: ${sessionId}`);
}
getWebClientsBySessionId(sessionId: string): Set<WebClientConnection> {
return this.webClients.get(sessionId) || new Set();
}
broadcastToWebClients(sessionId: string, message: object): void {
const clients = this.webClients.get(sessionId);
if (!clients) return;
const data = JSON.stringify(message);
for (const client of clients) {
if (client.authenticated && client.socket.readyState === WebSocket.OPEN) {
try {
client.socket.send(data);
} catch (error) {
logger.error('Failed to send to web client:', error);
}
}
}
}
// API request forwarding
async forwardApiRequest(
sessionId: string,
method: string,
path: string,
body?: any,
headers?: Record<string, string>
): Promise<{ status: number; body: any }> {
const desktop = this.desktopConnections.get(sessionId);
if (!desktop) {
throw new Error('Desktop not connected');
}
const requestId = generateRequestId();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
desktop.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, config.requestTimeout);
desktop.pendingRequests.set(requestId, { resolve, reject, timeout });
try {
desktop.socket.send(JSON.stringify({
type: 'api_request',
requestId,
method,
path,
body,
headers
}));
} catch (error) {
desktop.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(error);
}
});
}
handleApiResponse(sessionId: string, requestId: string, status: number, body: any): void {
const desktop = this.desktopConnections.get(sessionId);
if (!desktop) return;
const pending = desktop.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
desktop.pendingRequests.delete(requestId);
pending.resolve({ status, body });
}
}
// Cleanup
shutdown(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
for (const [sessionId] of this.desktopConnections) {
this.disconnectDesktop(sessionId);
}
for (const [sessionId, clients] of this.webClients) {
for (const client of clients) {
try {
client.socket.close();
} catch (e) {
// Ignore
}
}
}
this.webClients.clear();
}
// Stats
getStats() {
return {
desktopConnections: this.desktopConnections.size,
webClients: Array.from(this.webClients.values()).reduce((sum, set) => sum + set.size, 0),
sessions: Array.from(this.desktopConnections.keys())
};
}
}
@@ -0,0 +1,228 @@
// Session Manager - handles session storage and authentication
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcrypt';
import { config } from '../config';
import { generateSessionId } from '../utils/idGenerator';
import { logger } from '../utils/logger';
export interface Session {
id: string;
passwordHash: string;
createdAt: string;
lastConnected: string;
}
interface SessionStore {
sessions: Record<string, Session>;
}
// Flush pending (debounced) writes at most this often.
const FLUSH_INTERVAL_MS = 60 * 1000;
// Prune expired sessions on this cadence.
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
export class SessionManager {
private sessionsFile: string;
private sessions: Map<string, Session> = new Map();
private dirty = false;
private flushTimer: NodeJS.Timeout | null = null;
private pruneTimer: NodeJS.Timeout | null = null;
constructor() {
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
this.load();
this.pruneExpired();
this.startBackgroundTasks();
}
private startBackgroundTasks(): void {
this.flushTimer = setInterval(() => {
if (this.dirty) {
this.save();
this.dirty = false;
}
}, FLUSH_INTERVAL_MS);
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref();
this.pruneTimer = setInterval(() => {
this.pruneExpired();
}, PRUNE_INTERVAL_MS);
if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref();
}
private load(): void {
try {
if (fs.existsSync(this.sessionsFile)) {
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
for (const [id, session] of Object.entries(data.sessions || {})) {
this.sessions.set(id, session);
}
logger.info(`Loaded ${this.sessions.size} sessions`);
}
} catch (error) {
logger.error('Failed to load sessions:', error);
}
}
// Atomic write: write to a temp file in the same directory, then rename.
private save(): void {
try {
const store: SessionStore = {
sessions: Object.fromEntries(this.sessions)
};
const dir = path.dirname(this.sessionsFile);
fs.mkdirSync(dir, { recursive: true });
const tmpFile = path.join(
dir,
`.sessions.${process.pid}.${Date.now()}.tmp`
);
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2));
fs.renameSync(tmpFile, this.sessionsFile);
} catch (error) {
logger.error('Failed to save sessions:', error);
}
}
// Prune sessions that have been unused past the configured TTL.
private pruneExpired(): void {
const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000;
if (ttlMs <= 0) return;
const now = Date.now();
let pruned = 0;
for (const [id, session] of this.sessions) {
const last = Date.parse(session.lastConnected || session.createdAt);
if (!Number.isNaN(last) && now - last > ttlMs) {
this.sessions.delete(id);
pruned++;
}
}
if (pruned > 0) {
logger.info(`Pruned ${pruned} expired session(s)`);
this.save();
this.dirty = false;
}
}
async exists(sessionId: string): Promise<boolean> {
return this.sessions.has(sessionId);
}
async createSession(password: string): Promise<Session> {
// Reject empty / short passwords before doing any hashing.
if (!password || password.length < config.minPasswordLength) {
throw new Error(
`Password must be at least ${config.minPasswordLength} characters`
);
}
// Cap total sessions to prevent unbounded resource exhaustion.
if (this.sessions.size >= config.maxSessions) {
// Try to reclaim space by pruning expired sessions first.
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
// Generate unique session ID
let id: string;
do {
id = generateSessionId(config.sessionIdLength);
} while (this.sessions.has(id));
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
// Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only
// suspension point between the first cap check and the set() below, so
// concurrent createSession calls could each have passed the earlier check
// and then filled the map while we were hashing. Re-checking here — with
// no await between this check and the synchronous set() — makes the
// check-and-insert atomic under Node's single-threaded model, so the cap
// cannot be overshot.
if (this.sessions.size >= config.maxSessions) {
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
const now = new Date().toISOString();
const session: Session = {
id,
passwordHash,
createdAt: now,
lastConnected: now
};
this.sessions.set(id, session);
this.save(); // creation is important, persist immediately
logger.info(`Created new session: ${id}`);
return session;
}
async validatePassword(sessionId: string, password: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// Update last-connected time, but only mark dirty (debounced flush)
// if it moved by more than a minute to avoid writing on every auth.
const now = Date.now();
const prev = Date.parse(session.lastConnected);
if (Number.isNaN(prev) || now - prev > 60 * 1000) {
session.lastConnected = new Date(now).toISOString();
this.dirty = true;
}
}
return valid;
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
updateLastConnected(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.dirty = true;
}
}
deleteSession(sessionId: string): boolean {
const deleted = this.sessions.delete(sessionId);
if (deleted) {
this.save();
logger.info(`Deleted session: ${sessionId}`);
}
return deleted;
}
// Flush any pending writes and stop background timers.
shutdown(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
this.pruneTimer = null;
}
if (this.dirty) {
this.save();
this.dirty = false;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// Auth brute-force lockout (in-memory).
//
// Tracks failed authentication attempts keyed by an opaque, namespaced key
// (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`).
// Namespacing by path and client IP keeps the web and desktop keyspaces
// separate so one abusive web client cannot lock out other users or the
// desktop for the same session. After `authLockoutMaxAttempts` failures within
// `authLockoutWindowMs`, further attempts are rejected until
// `authLockoutDurationMs` has elapsed.
import { config } from '../config';
interface AttemptRecord {
count: number;
windowStart: number;
lockedUntil: number;
}
const records = new Map<string, AttemptRecord>();
/**
* Returns the number of milliseconds remaining on a lockout for this key,
* or 0 if the key is not currently locked.
*/
export function getLockoutRemaining(key: string): number {
const rec = records.get(key);
if (!rec) return 0;
const now = Date.now();
if (rec.lockedUntil > now) {
return rec.lockedUntil - now;
}
return 0;
}
/**
* Record a failed auth attempt. Locks the key out for
* `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
* within `authLockoutWindowMs`. Callers query the resulting lockout via
* getLockoutRemaining(), so this returns nothing.
*/
export function recordFailure(key: string): void {
const now = Date.now();
let rec = records.get(key);
// Start a fresh window if none exists or the current one has expired.
if (!rec || now - rec.windowStart > config.authLockoutWindowMs) {
rec = { count: 0, windowStart: now, lockedUntil: 0 };
records.set(key, rec);
}
rec.count += 1;
if (rec.count >= config.authLockoutMaxAttempts) {
rec.lockedUntil = now + config.authLockoutDurationMs;
}
}
/**
* Clear any recorded failures for this key (call on successful auth).
*/
export function recordSuccess(key: string): void {
records.delete(key);
}
// Periodically drop stale records so the map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, rec] of records) {
const windowExpired = now - rec.windowStart > config.authLockoutWindowMs;
const notLocked = rec.lockedUntil <= now;
if (windowExpired && notLocked) {
records.delete(key);
}
}
}, Math.max(60000, config.authLockoutWindowMs));
// Do not keep the process alive solely for this timer.
if (typeof cleanup.unref === 'function') {
cleanup.unref();
}
+44
View File
@@ -0,0 +1,44 @@
// Unique ID generation utilities
import { randomBytes, randomUUID } from 'crypto';
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Largest multiple of 62 that fits in a byte (62 * 4 = 248). Bytes >= 248
// are rejected so every character is drawn with uniform probability
// (avoids modulo bias from `byte % 62`).
const REJECTION_THRESHOLD = Math.floor(256 / BASE62_CHARS.length) * BASE62_CHARS.length;
/**
* Generate a random base62 string of the specified length.
* Uses cryptographically secure random bytes with rejection sampling
* to eliminate modulo bias.
*/
export function generateSessionId(length: number = 12): string {
let result = '';
while (result.length < length) {
// Fetch exactly `length` random bytes per pass. Bytes at or above the
// rejection threshold are skipped to avoid modulo bias, so a pass may
// yield fewer than `length` characters; the outer while then runs another
// pass until we have enough. (No over-fetching — each pass draws exactly
// `length` bytes.)
const bytes = randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) {
const byte = bytes[i];
if (byte >= REJECTION_THRESHOLD) {
continue; // reject to avoid bias
}
result += BASE62_CHARS[byte % BASE62_CHARS.length];
}
}
return result;
}
/**
* Generate a UUID v4 for request IDs using the crypto module.
*/
export function generateRequestId(): string {
return randomUUID();
}
+36
View File
@@ -0,0 +1,36 @@
// Logger utility using Winston
import winston from 'winston';
import { config } from '../config';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ level, message, timestamp, stack }) => {
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
})
);
export const logger = winston.createLogger({
level: config.logLevel,
format: logFormat,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
logFormat
)
})
]
});
// Add file transport in production
if (!config.isDevelopment) {
logger.add(new winston.transports.File({
filename: 'error.log',
level: 'error'
}));
logger.add(new winston.transports.File({
filename: 'combined.log'
}));
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+48 -2
View File
@@ -1,12 +1,56 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Windows)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'websockets',
'multipart',
# Relay client (imported lazily in the GUI, so declare explicitly)
'relay_client',
'aiohttp',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -14,6 +58,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -35,4 +80,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='Macro Pad.ico',
)
+64 -6
View File
@@ -1,25 +1,83 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Linux)
import os
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
'PySide6.QtDBus',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'pystray._base',
'websockets',
'multipart',
# Linux system tray
'gi',
'gi.repository.Gtk',
'gi.repository.AppIndicator3',
'gi.repository.GdkPixbuf',
# Packaging
'packaging.version',
'packaging.specifiers',
'packaging.requirements',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='macropad',
@@ -29,10 +87,10 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
console=True, # Set to True for debugging
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
)
+53 -6
View File
@@ -1,12 +1,53 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (macOS)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pyperclip',
'pystray',
'websockets',
'multipart',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -14,6 +55,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -39,7 +81,12 @@ exe = EXE(
app = BUNDLE(
exe,
name='macropad.app',
icon=None,
bundle_identifier=None,
)
name='MacroPad Server.app',
icon='Macro Pad.png',
bundle_identifier='com.macropad.server',
info_plist={
'CFBundleShortVersionString': '0.9.2',
'CFBundleName': 'MacroPad Server',
'NSHighResolutionCapable': True,
},
)
+44 -483
View File
@@ -1,502 +1,63 @@
# Main application file for MacroPad Server
# PySide6 version
import tkinter as tk
from tkinter import ttk, messagebox
import os
import sys
import threading
import socket
import qrcode
import webbrowser
import pystray
from PIL import Image, ImageTk, ImageDraw, ImageFont
from config import VERSION, DEFAULT_PORT, THEME
from macro_manager import MacroManager
from web_server import WebServer
from ui_components import MacroDialog, MacroSelector, TabManager
import multiprocessing
class MacroPadServer:
def __init__(self, root):
self.root = root
self.root.title("MacroPad Server")
self.root.geometry("800x600")
self.configure_styles()
# Set up directories
if getattr(sys, 'frozen', False):
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
data_file = os.path.join(base_dir, "macros.json")
images_dir = os.path.join(base_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
def get_app_dir():
"""Get the application directory (where macros.json and macro_images are stored)."""
if getattr(sys, 'frozen', False):
# Running as compiled executable - use executable's directory for user data
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.abspath(__file__))
# Initialize components
self.macro_manager = MacroManager(data_file, images_dir, base_dir)
self.web_server = WebServer(self.macro_manager, base_dir, DEFAULT_PORT)
# UI state
self.current_sort = "name"
self.current_tab = "All"
self.image_cache = {}
# Server state
self.server_running = False
self.flask_thread = None
# Tray state
self.tray_icon = None
self.is_closing = False
# Create UI
self.create_ui()
# Set up window event handlers
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
self.root.bind('<Unmap>', self.on_minimize)
# Initialize tray icon
self.create_tray_icon()
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
# Running as compiled executable - resources are in _MEIPASS
base_path = sys._MEIPASS
else:
# Running as script - resources are in the script directory
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def configure_styles(self):
"""Configure the dark theme styles"""
self.root.configure(bg=THEME['bg_color'])
style = ttk.Style()
style.theme_use("clam")
style.configure("TButton", background=THEME['button_bg'], foreground=THEME['button_fg'])
style.map("TButton", background=[("active", THEME['accent_color'])])
style.configure("TFrame", background=THEME['bg_color'])
style.configure("TLabel", background=THEME['bg_color'], foreground=THEME['fg_color'])
# Configure notebook (tabs) style
style.configure("TNotebook", background=THEME['bg_color'], borderwidth=0)
style.configure("TNotebook.Tab", background=THEME['tab_bg'], foreground=THEME['fg_color'],
padding=[12, 8], borderwidth=0)
style.map("TNotebook.Tab",
background=[("selected", THEME['tab_selected'])],
foreground=[("selected", THEME['fg_color'])],
padding=[("selected", [12, 8])]) # Keep same padding when selected
def main():
"""Main entry point."""
# Required for multiprocessing on Windows
multiprocessing.freeze_support()
def create_ui(self):
"""Create the main user interface"""
# Create main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Get directories
app_dir = get_app_dir()
# Left side: Macro list and controls
left_frame = ttk.Frame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Import PySide6 after freeze_support
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
# Sort controls
self._create_sort_controls(left_frame)
# Create notebook for tabs
self.notebook = ttk.Notebook(left_frame)
self.notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.notebook.bind("<<NotebookTabChanged>>", self.on_tab_change)
# Create application
app = QApplication(sys.argv)
app.setApplicationName("MacroPad Server")
app.setOrganizationName("MacroPad")
# Button controls
self._create_macro_buttons(left_frame)
# Set application icon (from bundled resources)
icon_path = get_resource_path("Macro Pad.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
# Right side: Server controls
right_frame = ttk.Frame(main_frame)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0))
# Import and create main window
from gui.main_window import MainWindow
window = MainWindow(app_dir)
window.show()
self._create_server_controls(right_frame)
# Version label
version_label = tk.Label(self.root, text=VERSION,
bg=THEME['bg_color'], fg=THEME['fg_color'],
font=('Helvetica', 8))
version_label.pack(side=tk.BOTTOM, anchor=tk.SE, padx=5, pady=(0, 2))
# Initialize display
self.setup_tabs()
self.display_macros()
def _create_sort_controls(self, parent):
"""Create sorting controls"""
sort_frame = ttk.Frame(parent)
sort_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(sort_frame, text="Sort by:").pack(side=tk.LEFT, padx=(0, 5))
self.sort_var = tk.StringVar(value=self.current_sort)
sort_combo = ttk.Combobox(sort_frame, textvariable=self.sort_var,
values=["name", "type", "recent"],
state="readonly", width=10)
sort_combo.pack(side=tk.LEFT, padx=(0, 10))
sort_combo.bind("<<ComboboxSelected>>", self.on_sort_change)
# Tab management button
ttk.Button(sort_frame, text="Manage Tabs",
command=self.manage_tabs).pack(side=tk.RIGHT, padx=(5, 0))
def _create_macro_buttons(self, parent):
"""Create macro management buttons"""
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Button(button_frame, text="Add Macro", command=self.add_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Edit Macro", command=self.edit_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Delete Macro", command=self.delete_macro).pack(side=tk.LEFT, padx=2)
def _create_server_controls(self, parent):
"""Create web server controls"""
server_frame = ttk.Frame(parent)
server_frame.pack(fill=tk.X, pady=10)
ttk.Label(server_frame, text="Web Server Control:").grid(row=0, column=0, sticky="w", pady=5)
self.server_button = ttk.Button(server_frame, text="Start Web Server", command=self.toggle_server)
self.server_button.grid(row=0, column=1, padx=5, pady=5)
# Status display
self.status_var = tk.StringVar(value="Web server not running")
ttk.Label(server_frame, textvariable=self.status_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# QR code display
self.qr_label = ttk.Label(parent)
self.qr_label.pack(pady=10)
# URL display
self.url_var = tk.StringVar(value="")
self.url_label = ttk.Label(parent, textvariable=self.url_var)
self.url_label.pack(pady=5)
# Browser button
self.browser_button = ttk.Button(parent, text="Open in Browser",
command=self.open_in_browser, state=tk.DISABLED)
self.browser_button.pack(pady=5)
def setup_tabs(self):
"""Initialize tabs based on macro categories"""
# Clear existing tabs
for tab in self.notebook.tabs():
self.notebook.forget(tab)
# Get unique tabs from macro manager
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
frame = ttk.Frame(self.notebook)
self.notebook.add(frame, text=tab_name)
def get_current_tab_name(self):
"""Get the name of the currently selected tab"""
try:
current_tab_id = self.notebook.select()
return self.notebook.tab(current_tab_id, "text")
except:
return "All"
def on_tab_change(self, event=None):
"""Handle tab change event"""
self.current_tab = self.get_current_tab_name()
self.display_macros()
def on_sort_change(self, event=None):
"""Handle sort option change"""
self.current_sort = self.sort_var.get()
self.display_macros()
def display_macros(self):
"""Display macros in the current tab"""
# Get current tab frame
try:
current_tab_id = self.notebook.select()
current_frame = self.notebook.nametowidget(current_tab_id)
except:
return
# Clear previous content
for widget in current_frame.winfo_children():
widget.destroy()
# Create scrollable canvas
canvas = tk.Canvas(current_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(current_frame, orient="vertical", command=canvas.yview)
scrollable_frame = ttk.Frame(canvas)
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Get sorted and filtered macros
sorted_macros = self.macro_manager.get_sorted_macros(self.current_sort)
filtered_macros = self.macro_manager.filter_macros_by_tab(sorted_macros, self.current_tab)
# Display macros
for macro_id, macro in filtered_macros:
self._create_macro_button(scrollable_frame, macro_id, macro)
# Display message if no macros
if not filtered_macros:
label = tk.Label(scrollable_frame, text="No macros in this category",
bg=THEME['bg_color'], fg=THEME['fg_color'])
label.pack(pady=20)
def _create_macro_button(self, parent, macro_id, macro):
"""Create a button for a single macro"""
frame = ttk.Frame(parent)
frame.pack(fill="x", pady=5, padx=5)
button = tk.Button(
frame, text=macro["name"],
bg=THEME['button_bg'], fg=THEME['button_fg'],
activebackground=THEME['accent_color'], activeforeground=THEME['button_fg'],
relief=tk.RAISED, bd=2, pady=8,
command=lambda: self.macro_manager.execute_macro(macro_id)
)
# Add image if available
if "image_path" in macro and macro["image_path"]:
try:
if macro["image_path"] in self.image_cache:
button_image = self.image_cache[macro["image_path"]]
else:
img_path = os.path.join(self.macro_manager.app_dir, macro["image_path"])
img = Image.open(img_path)
img = img.resize((32, 32))
button_image = ImageTk.PhotoImage(img)
self.image_cache[macro["image_path"]] = button_image
button.config(image=button_image, compound=tk.LEFT)
button.image = button_image # Keep reference
except Exception as e:
print(f"Error loading image for {macro['name']}: {e}")
button.pack(side=tk.LEFT, fill=tk.X, expand=True)
def manage_tabs(self):
"""Open tab management dialog"""
tab_manager = TabManager(self.root, self.macro_manager)
tab_manager.show()
self.setup_tabs()
self.display_macros()
def add_macro(self):
"""Add a new macro"""
dialog = MacroDialog(self.root, self.macro_manager)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def edit_macro(self):
"""Edit an existing macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Edit")
macro_id = selector.show()
if macro_id:
dialog = MacroDialog(self.root, self.macro_manager, macro_id)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def delete_macro(self):
"""Delete a macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Delete")
macro_id = selector.show()
if macro_id:
macro_name = self.macro_manager.macros[macro_id]["name"]
if messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{macro_name}'?"):
self.macro_manager.delete_macro(macro_id)
self.setup_tabs()
self.display_macros()
def toggle_server(self):
"""Toggle web server on/off"""
if self.server_running:
self.stop_server()
else:
self.start_server()
def start_server(self):
"""Start the web server"""
try:
if not self.server_running:
self.server_running = True
self.flask_thread = threading.Thread(target=self.run_web_server)
self.flask_thread.daemon = True
self.flask_thread.start()
self.server_button.config(text="Stop Web Server")
# Get IP address and display info
ip_address = self.get_ip_address()
if ip_address:
url = f"http://{ip_address}:{DEFAULT_PORT}"
url_text = f"Web UI available at:\n{url}"
self.url_var.set(url_text)
self.browser_button.config(state=tk.NORMAL)
self.generate_qr_code(url)
else:
self.url_var.set("No network interfaces found")
except Exception as e:
self.status_var.set(f"Error starting server: {e}")
self.server_running = False
def stop_server(self):
"""Stop the web server"""
if self.server_running:
self.server_running = False
self.status_var.set("Web server stopped")
self.server_button.config(text="Start Web Server")
self.url_var.set("")
self.browser_button.config(state=tk.DISABLED)
self.qr_label.config(image="")
def run_web_server(self):
"""Run the web server in a separate thread"""
self.status_var.set(f"Web server running on port {DEFAULT_PORT}")
try:
self.web_server.run()
except Exception as e:
self.status_var.set(f"Web server error: {e}")
self.server_running = False
def get_ip_address(self):
"""Get the primary internal IPv4 address"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
print(f"Error getting IP address: {e}")
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if not ip.startswith("127."):
return ip
for addr_info in socket.getaddrinfo(hostname, None):
potential_ip = addr_info[4][0]
if '.' in potential_ip and not potential_ip.startswith("127."):
return potential_ip
except:
pass
return "127.0.0.1"
def generate_qr_code(self, url):
"""Generate and display QR code for the URL"""
try:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_photoimg = ImageTk.PhotoImage(qr_img)
self.qr_label.config(image=qr_photoimg)
self.qr_label.image = qr_photoimg
except ImportError:
self.qr_label.config(text="QR code generation requires 'qrcode' package")
except Exception as e:
print(f"Error generating QR code: {e}")
self.qr_label.config(text="Error generating QR code")
def open_in_browser(self):
"""Open the web interface in browser"""
if self.server_running:
webbrowser.open(f"http://localhost:{DEFAULT_PORT}")
def on_minimize(self, event):
"""Handle window minimize event"""
# Only minimize to tray if the window is being iconified, not just unmapped
if event.widget == self.root and self.root.state() == 'iconic':
self.root.withdraw() # Hide window
def create_tray_icon(self):
"""Create system tray icon"""
try:
# Create a simple icon image with M letter
icon_image = Image.new("RGB", (64, 64), THEME['accent_color'])
draw = ImageDraw.Draw(icon_image)
try:
# Try to use a system font
font = ImageFont.truetype("arial.ttf", 40)
except:
try:
# Try other common fonts
font = ImageFont.truetype("calibri.ttf", 40)
except:
# Fall back to default font
font = ImageFont.load_default()
# Draw "MP" in the center
text = "MP"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (64 - text_width) // 2
y = (64 - text_height) // 2
draw.text((x, y), text, fill="white", font=font)
menu = (
pystray.MenuItem('Show', self.show_window),
pystray.MenuItem('Exit', self.exit_app)
)
self.tray_icon = pystray.Icon("macropad", icon_image, "MacroPad Server", menu)
# Run tray icon in a separate thread
tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True)
tray_thread.start()
except Exception as e:
print(f"Error creating tray icon: {e}")
# Tray icon is optional, continue without it
def show_window(self, icon=None, item=None):
"""Show window from tray"""
self.root.deiconify()
self.root.state('normal')
self.root.lift()
self.root.focus_force()
def exit_app(self, icon=None, item=None):
"""Exit the application"""
self.is_closing = True
self.stop_server()
if self.tray_icon:
try:
self.tray_icon.stop()
except:
pass
try:
self.root.quit()
except:
pass
# Force exit if needed
import os
os._exit(0)
def on_closing(self):
"""Handle window close event - exit the application"""
self.exit_app()
# Run application
sys.exit(app.exec())
if __name__ == "__main__":
root = tk.Tk()
app = MacroPadServer(root)
root.mainloop()
main()
-38
View File
@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='main',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+63
View File
@@ -0,0 +1,63 @@
[project]
name = "macropad-server"
version = "0.9.5"
description = "A cross-platform macro management application with desktop and web interfaces"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "jknapp"}
]
keywords = ["macropad", "macro", "automation", "keyboard", "pwa"]
dependencies = [
# Image processing
"pillow>=10.0.0",
# Keyboard/mouse automation
"pyautogui>=0.9.54",
"pyperclip>=1.8.0",
# System tray
"pystray>=0.19.5",
# Web server
"fastapi>=0.104.0",
"uvicorn>=0.24.0",
"websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads
# QR code generation
"qrcode>=7.4.2",
# Desktop GUI
"PySide6>=6.6.0",
# Relay client
"aiohttp>=3.9.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"black>=23.0.0",
"ruff>=0.1.0",
"pyinstaller>=6.0.0",
]
[project.scripts]
macropad-server = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["gui"]
[dependency-groups]
dev = [
"pyinstaller>=6.0.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.black]
line-length = 100
target-version = ["py311"]
+254
View File
@@ -0,0 +1,254 @@
# Relay Client for MacroPad Server
# Connects to relay server and forwards API requests to local server
import asyncio
import json
import threading
import time
from typing import Optional, Callable
import aiohttp
class RelayClient:
"""WebSocket client that connects to relay server and proxies requests."""
def __init__(
self,
relay_url: str,
password: str,
session_id: Optional[str] = None,
local_port: int = 40000,
on_connected: Optional[Callable] = None,
on_disconnected: Optional[Callable] = None,
on_session_id: Optional[Callable[[str], None]] = None,
local_auth_token: str = ""
):
self.relay_url = relay_url.rstrip('/')
if not self.relay_url.endswith('/desktop'):
self.relay_url += '/desktop'
self.password = password
self.session_id = session_id
self.local_url = f"http://localhost:{local_port}"
self.local_auth_token = local_auth_token
# Callbacks
self.on_connected = on_connected
self.on_disconnected = on_disconnected
self.on_session_id = on_session_id
# State
self._ws = None
self._session = None
self._running = False
self._connected = False
self._thread = None
self._loop = None
self._reconnect_delay = 1
def start(self):
"""Start the relay client in a background thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run_async_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop the relay client."""
self._running = False
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread:
self._thread.join(timeout=2)
self._thread = None
def is_connected(self) -> bool:
"""Check if connected to relay server."""
return self._connected
def _run_async_loop(self):
"""Run the asyncio event loop in the background thread."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._connection_loop())
except Exception as e:
print(f"Relay client error: {e}")
finally:
self._loop.close()
async def _connection_loop(self):
"""Main connection loop with reconnection logic."""
while self._running:
try:
await self._connect_and_run()
except Exception as e:
print(f"Relay connection error: {e}")
if self._running:
# Exponential backoff for reconnection
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(self._reconnect_delay * 2, 30)
async def _connect_and_run(self):
"""Connect to relay server and handle messages."""
try:
async with aiohttp.ClientSession() as session:
self._session = session
async with session.ws_connect(self.relay_url) as ws:
self._ws = ws
# Authenticate
if not await self._authenticate():
return
self._connected = True
self._reconnect_delay = 1 # Reset backoff on successful connect
if self.on_connected:
self.on_connected()
# Message handling loop
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_message(json.loads(msg.data))
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f"WebSocket error: {ws.exception()}")
break
elif msg.type == aiohttp.WSMsgType.CLOSED:
break
except aiohttp.ClientError as e:
print(f"Relay connection failed: {e}")
finally:
self._connected = False
self._ws = None
self._session = None
if self.on_disconnected:
self.on_disconnected()
async def _authenticate(self) -> bool:
"""Authenticate with the relay server."""
auth_msg = {
"type": "auth",
"sessionId": self.session_id,
"password": self.password
}
await self._ws.send_json(auth_msg)
# Wait for auth response
response = await self._ws.receive_json()
if response.get("type") == "auth_response":
if response.get("success"):
# Mark as connected before callbacks so update_ip_label works
self._connected = True
new_session_id = response.get("sessionId")
# Always update session_id and trigger callback to ensure URL updates
if new_session_id:
self.session_id = new_session_id
if self.on_session_id:
self.on_session_id(new_session_id)
return True
else:
print(f"Authentication failed: {response.get('error', 'Unknown error')}")
return False
return False
async def _handle_message(self, msg: dict):
"""Handle a message from the relay server."""
msg_type = msg.get("type")
if msg_type == "api_request":
await self._handle_api_request(msg)
elif msg_type == "ws_message":
# Forward WebSocket message from web client
await self._handle_ws_message(msg)
elif msg_type == "ping":
await self._ws.send_json({"type": "pong"})
async def _handle_api_request(self, msg: dict):
"""Forward API request to local server and send response back."""
request_id = msg.get("requestId")
method = msg.get("method", "GET").upper()
path = msg.get("path", "/")
body = msg.get("body")
# Restrict the relay to API routes only. This prevents the relay from
# reaching static/other paths on the local server.
if not path.startswith("/api/"):
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": 403,
"body": {"error": "forbidden path"}
})
return
url = f"{self.local_url}{path}"
# Build a fresh headers dict. We do NOT forward attacker-supplied
# headers from the relay; we inject the local auth token ourselves.
sending_body = bool(body) and method in ("POST", "PUT", "PATCH")
headers = {"X-MacroPad-Token": self.local_auth_token}
if sending_body:
headers["Content-Type"] = "application/json"
try:
# Forward request to local server
async with self._session.request(
method,
url,
json=body if sending_body else None,
headers=headers
) as response:
# Handle binary responses (images)
content_type = response.headers.get("Content-Type", "")
if content_type.startswith("image/"):
# Base64 encode binary data
import base64
data = await response.read()
response_body = {
"base64": base64.b64encode(data).decode("utf-8"),
"contentType": content_type
}
else:
try:
response_body = await response.json()
except:
response_body = {"text": await response.text()}
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": response.status,
"body": response_body
})
except Exception as e:
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": 500,
"body": {"error": str(e)}
})
async def _handle_ws_message(self, msg: dict):
"""Handle WebSocket message from web client."""
data = msg.get("data", {})
# For now, we don't need to forward messages from web clients
# to the local server because the local server broadcasts changes
# The relay will handle broadcasting back to web clients
pass
async def broadcast(self, data: dict):
"""Broadcast a message to all connected web clients via relay."""
if self._ws and self._connected:
await self._ws.send_json({
"type": "ws_broadcast",
"data": data
})
-8
View File
@@ -1,8 +0,0 @@
pillow
pyautogui
pystray
flask
waitress
netifaces
qrcode
tkinter
-283
View File
@@ -1,283 +0,0 @@
# UI components and dialogs for the desktop application
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import os
import uuid
from config import THEME
class MacroDialog:
"""Dialog for adding/editing macros"""
def __init__(self, parent, macro_manager, macro_id=None):
self.parent = parent
self.macro_manager = macro_manager
self.macro_id = macro_id
self.dialog = None
self.result = None
def show(self):
"""Show the dialog and return the result"""
self.dialog = tk.Toplevel(self.parent)
self.dialog.title("Edit Macro" if self.macro_id else "Add Macro")
self.dialog.geometry("450x400")
self.dialog.transient(self.parent)
self.dialog.configure(bg=THEME['bg_color'])
self.dialog.grab_set()
# If editing, get existing macro data
if self.macro_id:
macro = self.macro_manager.macros.get(self.macro_id, {})
else:
macro = {}
self._create_widgets(macro)
# Wait for dialog to close
self.dialog.wait_window()
return self.result
def _create_widgets(self, macro):
"""Create the dialog widgets"""
# Name
tk.Label(self.dialog, text="Macro Name:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.name_entry.grid(row=0, column=1, padx=5, pady=5)
self.name_entry.insert(0, macro.get("name", ""))
# Category
tk.Label(self.dialog, text="Category:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.category_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.category_entry.grid(row=1, column=1, padx=5, pady=5)
self.category_entry.insert(0, macro.get("category", ""))
# Type
tk.Label(self.dialog, text="Type:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.type_var = tk.StringVar(value=macro.get("type", "text"))
radio_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color']}
tk.Radiobutton(self.dialog, text="Text", variable=self.type_var, value="text", **radio_style).grid(row=2, column=1, sticky="w")
tk.Radiobutton(self.dialog, text="Application", variable=self.type_var, value="app", **radio_style).grid(row=3, column=1, sticky="w")
# Command/Text
tk.Label(self.dialog, text="Command/Text:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=4, column=0, padx=5, pady=5, sticky="w")
self.command_text = tk.Text(self.dialog, width=30, height=5, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.command_text.grid(row=4, column=1, padx=5, pady=5)
self.command_text.insert("1.0", macro.get("command", ""))
# Modifiers
self._create_modifiers(macro)
# Image
self.image_path = tk.StringVar()
tk.Label(self.dialog, text="Image:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=6, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(self.dialog, textvariable=self.image_path, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
image_entry.grid(row=6, column=1, padx=5, pady=5)
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(self.dialog, text="Browse...", command=self._browse_image, **button_style).grid(row=6, column=2)
# Buttons
tk.Button(self.dialog, text="Save", command=self._save, **button_style).grid(row=7, column=0, padx=5, pady=20)
tk.Button(self.dialog, text="Cancel", command=self._cancel, **button_style).grid(row=7, column=1, padx=5, pady=20)
def _create_modifiers(self, macro):
"""Create modifier checkboxes"""
mod_frame = tk.Frame(self.dialog, bg=THEME['bg_color'])
mod_frame.grid(row=5, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(side=tk.LEFT, padx=5)
modifiers = macro.get("modifiers", {})
self.ctrl_var = tk.BooleanVar(value=modifiers.get("ctrl", False))
self.alt_var = tk.BooleanVar(value=modifiers.get("alt", False))
self.shift_var = tk.BooleanVar(value=modifiers.get("shift", False))
self.enter_var = tk.BooleanVar(value=modifiers.get("enter", False))
checkbox_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color'],
'activebackground': THEME['bg_color'], 'activeforeground': THEME['fg_color']}
tk.Checkbutton(mod_frame, text="Ctrl", variable=self.ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=self.alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=self.shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=self.enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
def _browse_image(self):
"""Browse for image file"""
filename = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])
if filename:
self.image_path.set(filename)
def _save(self):
"""Save the macro"""
name = self.name_entry.get().strip()
if not name:
messagebox.showerror("Error", "Macro name is required")
return
macro_type = self.type_var.get()
command = self.command_text.get("1.0", tk.END).strip()
category = self.category_entry.get().strip()
modifiers = {
"ctrl": self.ctrl_var.get(),
"alt": self.alt_var.get(),
"shift": self.shift_var.get(),
"enter": self.enter_var.get()
}
if self.macro_id:
# Update existing macro
success = self.macro_manager.update_macro(
self.macro_id, name, macro_type, command, category, modifiers, self.image_path.get())
else:
# Add new macro
self.macro_id = self.macro_manager.add_macro(
name, macro_type, command, category, modifiers, self.image_path.get())
success = bool(self.macro_id)
if success:
self.result = self.macro_id
self.dialog.destroy()
else:
messagebox.showerror("Error", "Failed to save macro")
def _cancel(self):
"""Cancel dialog"""
self.result = None
self.dialog.destroy()
class MacroSelector:
"""Dialog for selecting a macro from a list"""
def __init__(self, parent, macro_manager, title="Select Macro"):
self.parent = parent
self.macro_manager = macro_manager
self.title = title
self.result = None
def show(self):
"""Show the selection dialog"""
if not self.macro_manager.macros:
messagebox.showinfo("No Macros", "There are no macros available.")
return None
dialog = tk.Toplevel(self.parent)
dialog.title(self.title)
dialog.geometry("200x340")
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text=f"{self.title}:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=5)
# Listbox
listbox = tk.Listbox(dialog, bg=THEME['highlight_color'], fg=THEME['fg_color'], selectbackground=THEME['accent_color'])
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate listbox
macro_ids = []
for macro_id, macro in self.macro_manager.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_select():
if not listbox.curselection():
messagebox.showwarning("No Selection", f"Please select a macro.")
return
idx = listbox.curselection()[0]
self.result = macro_ids[idx]
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Select", command=on_select, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(pady=5)
dialog.wait_window()
return self.result
class TabManager:
"""Dialog for managing macro categories/tabs"""
def __init__(self, parent, macro_manager):
self.parent = parent
self.macro_manager = macro_manager
def show(self):
"""Show tab management dialog"""
dialog = tk.Toplevel(self.parent)
dialog.title("Manage Tabs")
dialog.geometry("450x400") # Increased width and height
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text="Assign categories to macros for better organization:",
bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=10)
# Create scrollable frame
list_frame = tk.Frame(dialog, bg=THEME['bg_color'])
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
canvas = tk.Canvas(list_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
scrollable_frame = tk.Frame(canvas, bg=THEME['bg_color'])
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Category entries for each macro
category_vars = {}
for macro_id, macro in self.macro_manager.macros.items():
frame = tk.Frame(scrollable_frame, bg=THEME['bg_color'])
frame.pack(fill="x", pady=2, padx=5)
tk.Label(frame, text=macro["name"], bg=THEME['bg_color'], fg=THEME['fg_color'],
width=20, anchor="w").pack(side=tk.LEFT)
category_var = tk.StringVar(value=macro.get("category", ""))
category_vars[macro_id] = category_var
entry = tk.Entry(frame, textvariable=category_var, width=15,
bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
entry.pack(side=tk.RIGHT, padx=(5, 0))
# Buttons - use a fixed frame at bottom
button_frame = tk.Frame(dialog, bg=THEME['bg_color'])
button_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=10)
def save_categories():
for macro_id, category_var in category_vars.items():
category = category_var.get().strip()
if category:
self.macro_manager.macros[macro_id]["category"] = category
else:
self.macro_manager.macros[macro_id].pop("category", None)
self.macro_manager.save_macros()
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(button_frame, text="Save", command=save_categories, **button_style).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(side=tk.LEFT, padx=5)
dialog.wait_window()
+1 -1
View File
@@ -1 +1 @@
0.8.5
1.1.0
+901
View File
@@ -0,0 +1,901 @@
/* ==========================================================================
MacroPad PWA "Tactile Deck"
A macro deck that reads like a slab of physical keys: warm near-black
surfaces, an electric-violet signature accent, softly-raised cards with a
hairline top highlight, and a satisfying pressed state.
Self-contained (no external fonts/CDNs CSP forbids them).
========================================================================== */
/* ---- Design tokens --------------------------------------------------------
Everything downstream references these. Dark is the default; the
prefers-color-scheme: light block below overrides only the surface/text/
border tokens so both themes stay deliberate and pass WCAG AA. */
:root {
color-scheme: dark;
/* Surfaces — warm near-black, not a flat grey */
--surface: #15161a; /* app background */
--surface-raised: #1f2027; /* cards, header, sheets */
--surface-sunken: #101116; /* inset wells: inputs, command list */
--border: #2c2e38; /* hairline dividers */
--border-strong: #3a3d4a;
/* Text — muted meets 4.5:1 on --surface */
--text: #f3f4f8;
--text-muted: #a9adbd;
/* Signature accent — electric violet */
--accent: #7c5cff;
--accent-hover: #8f73ff;
--accent-2: #ffb057; /* warm amber companion (wake glow, highlights) */
--accent-contrast: #ffffff;
--accent-soft: rgba(124, 92, 255, 0.16);
/* Status */
--danger: #ff5d6c;
--success: #3ddc84;
--warning: #ffc861;
/* Spacing scale — 4px base */
--s-1: 4px;
--s-2: 8px;
--s-3: 12px;
--s-4: 16px;
--s-5: 24px;
--s-6: 32px;
/* Radii */
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--r-card: 14px;
--r-pill: 999px;
/* Elevation — soft, warm-tinted shadows */
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-2: 0 6px 16px rgba(0, 0, 0, 0.40);
--shadow-3: 0 18px 48px rgba(0, 0, 0, 0.55);
/* Hairline top highlight that makes surfaces read as physical keys */
--key-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
/* Type scale */
--fs-xs: 0.75rem;
--fs-sm: 0.85rem;
--fs-base: 1rem;
--fs-lg: 1.2rem;
--fs-xl: 1.6rem;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code',
Menlo, Consolas, monospace;
--tap: 44px; /* minimum comfortable touch target */
--dur: 0.18s;
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* ---- Light theme — clean, deliberate, AA-compliant --------------------- */
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--surface: #eceef4;
--surface-raised: #ffffff;
--surface-sunken: #e3e6ef;
--border: #d7dbe6;
--border-strong: #c3c8d6;
--text: #191b22;
--text-muted: #5a5f70; /* 4.5:1+ on white and on --surface */
--accent: #5b3ee6; /* darkened so white text clears AA */
--accent-hover: #4d31d6;
--accent-2: #c9720a;
--accent-soft: rgba(91, 62, 230, 0.12);
--danger: #d13342;
--success: #17924f;
--warning: #a5730a;
--shadow-1: 0 1px 2px rgba(20, 22, 40, 0.10);
--shadow-2: 0 6px 16px rgba(20, 22, 40, 0.12);
--shadow-3: 0 18px 48px rgba(20, 22, 40, 0.20);
--key-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
}
/* ---- Reset ------------------------------------------------------------- */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* [hidden] must always win over any display rule (keeps the modal closed). */
[hidden] {
display: none !important;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-ui);
background-color: var(--surface);
color: var(--text);
font-size: var(--fs-base);
line-height: 1.45;
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* App-wide keyboard focus ring (the old CSS had none). */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
:focus:not(:focus-visible) {
outline: none;
}
/* ---- Header ------------------------------------------------------------ */
.header {
background:
linear-gradient(180deg, rgba(124, 92, 255, 0.10), transparent 62%),
var(--surface-raised);
padding: var(--s-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--s-3);
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-1);
}
.header h1 {
font-size: var(--fs-xl);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1;
background: linear-gradient(105deg, var(--text) 30%, var(--accent));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--accent); /* fallback if clip unsupported */
}
.header-actions {
display: flex;
align-items: center;
gap: var(--s-2);
}
/* Header buttons */
.header-btn {
background: var(--accent);
color: var(--accent-contrast);
border: none;
padding: 0 var(--s-4);
min-height: var(--tap);
border-radius: var(--r-sm);
cursor: pointer;
font-size: var(--fs-sm);
font-weight: 600;
box-shadow: var(--shadow-1), var(--key-highlight);
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.header-btn:hover { background: var(--accent-hover); }
.header-btn:active { transform: translateY(1px); }
.header-btn.secondary {
background: var(--surface-sunken);
color: var(--text);
border: 1px solid var(--border-strong);
}
.header-btn.secondary:hover { border-color: var(--accent); color: var(--text); }
/* Square icon controls (fullscreen) — meet 44px tap target */
.header-btn.icon-btn {
padding: 0;
width: var(--tap);
height: var(--tap);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--fs-lg);
line-height: 1;
}
/* ---- Connection status ------------------------------------------------- */
.connection-status {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-muted);
padding: 0 var(--s-2);
white-space: nowrap;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 0 0 rgba(255, 93, 108, 0.5);
transition: background var(--dur) var(--ease);
}
.status-dot.connected {
background: var(--success);
animation: pulse-ring 2.4s ease-out infinite;
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(61, 220, 132, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(61, 220, 132, 0); }
100% { box-shadow: 0 0 0 0 rgba(61, 220, 132, 0); }
}
/* ---- Wake-lock toggle -------------------------------------------------- */
.wake-lock-status {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--tap);
height: var(--tap);
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: var(--fs-base);
cursor: pointer;
opacity: 0.55;
transition: opacity var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.wake-lock-status:hover { opacity: 0.85; }
.wake-lock-status.active {
opacity: 1;
border-color: var(--accent-2);
}
.wake-lock-status .wake-icon { color: var(--accent-2); line-height: 1; }
.wake-lock-status.active .wake-icon {
animation: wake-glow 2s ease-in-out infinite;
}
.wake-lock-status.unsupported { opacity: 0.35; cursor: not-allowed; }
.wake-lock-status.unsupported .wake-icon {
color: var(--text-muted);
text-decoration: line-through;
}
@keyframes wake-glow {
0%, 100% { filter: drop-shadow(0 0 0 rgba(255, 176, 87, 0)); opacity: 1; }
50% { filter: drop-shadow(0 0 6px rgba(255, 176, 87, 0.7)); opacity: 0.8; }
}
/* ---- Tabs -------------------------------------------------------------- */
.tabs {
display: flex;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
background: var(--surface);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
background: var(--surface-raised);
color: var(--text-muted);
border: 1px solid var(--border);
padding: 0 var(--s-4);
min-height: var(--tap);
border-radius: var(--r-pill);
cursor: pointer;
white-space: nowrap;
font-size: var(--fs-sm);
font-weight: 600;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease),
background var(--dur) var(--ease);
}
.tab:hover { color: var(--text); border-color: var(--border-strong); }
.tab.active {
background: var(--accent-soft);
color: var(--text);
border-color: var(--accent);
}
/* ---- Macro grid -------------------------------------------------------- */
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: var(--s-4);
padding: var(--s-4);
padding-bottom: calc(var(--s-6) + env(safe-area-inset-bottom));
}
/* Cards read as physical keys: raised slab, hairline top highlight,
satisfying press. */
.macro-card {
position: relative;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--r-card);
padding: var(--s-4) var(--s-3) var(--s-3);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-3);
cursor: pointer;
min-height: 148px;
color: var(--text);
box-shadow: var(--shadow-2), var(--key-highlight);
transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
/* Faint wash of the macro's own color across the top of the key */
.macro-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg,
color-mix(in srgb, var(--macro-color, var(--accent)) 22%, transparent),
transparent 45%);
opacity: 0.55;
pointer-events: none;
}
.macro-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-3), var(--key-highlight);
border-color: var(--border-strong);
}
.macro-card:active {
transform: translateY(1px) scale(0.985);
box-shadow: var(--shadow-1), var(--key-highlight);
}
.macro-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Pressed / executing — accent ring + brief success flash */
.macro-card.executing {
animation: card-press 0.32s var(--ease);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft), var(--shadow-2);
}
@keyframes card-press {
0% { transform: scale(1); }
35% { transform: scale(0.94); }
55% { box-shadow: 0 0 0 4px rgba(61, 220, 132, 0.35), var(--shadow-2); }
100% { transform: scale(1); }
}
.macro-image {
width: 68px;
height: 68px;
object-fit: contain;
border-radius: var(--r-md);
position: relative;
}
/* Placeholder background = the per-card --macro-color set by app.js */
.macro-image-placeholder {
position: relative;
width: 68px;
height: 68px;
background: var(--macro-color, var(--accent));
border-radius: var(--r-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.9rem;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow-1), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.macro-name {
position: relative;
text-align: center;
font-size: var(--fs-sm);
font-weight: 600;
line-height: 1.3;
color: var(--text);
/* 2-line clamp with ellipsis — no mid-word breaks */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: break-word;
}
/* Edit affordance — always faintly visible; solid on hover/focus */
.macro-edit-btn {
position: absolute;
top: var(--s-2);
right: var(--s-2);
z-index: 1;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--surface-sunken) 82%, transparent);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 50%;
cursor: pointer;
font-size: var(--fs-sm);
opacity: 0;
transition: opacity var(--dur) var(--ease), color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.macro-card:hover .macro-edit-btn,
.macro-card:focus-within .macro-edit-btn { opacity: 1; }
.macro-edit-btn:hover { color: var(--text); border-color: var(--accent); }
.macro-edit-btn:focus-visible { opacity: 1; }
/* ---- Floating action button -------------------------------------------- */
.fab {
position: fixed;
right: calc(var(--s-5) + env(safe-area-inset-right));
bottom: calc(var(--s-5) + env(safe-area-inset-bottom));
z-index: 150;
width: 60px;
height: 60px;
border: none;
border-radius: 50%;
background: var(--accent);
color: var(--accent-contrast);
font-size: 2rem;
font-weight: 300;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(124, 92, 255, 0.45), var(--key-highlight);
transition: transform var(--dur) var(--ease), background var(--dur) var(--ease),
box-shadow var(--dur) var(--ease);
}
.fab:hover {
background: var(--accent-hover);
transform: translateY(-2px) scale(1.04);
box-shadow: 0 12px 30px rgba(124, 92, 255, 0.55), var(--key-highlight);
}
.fab:active { transform: translateY(0) scale(0.96); }
/* ---- Modal / editor sheet ---------------------------------------------- */
/* .modal is the fixed scrim; .modal-content is the sheet. */
.modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-4);
background: rgba(8, 9, 14, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
animation: fade-in var(--dur) var(--ease);
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.modal-content {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--r-lg);
width: 100%;
max-width: 520px;
max-height: min(90vh, 780px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow-3), var(--key-highlight);
animation: sheet-rise var(--dur) var(--ease);
}
@keyframes sheet-rise {
from { transform: translateY(12px) scale(0.98); opacity: 0; }
to { transform: none; opacity: 1; }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
position: sticky;
top: 0;
}
.modal-header h2 {
font-size: var(--fs-lg);
font-weight: 700;
letter-spacing: -0.01em;
}
.modal-close {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
border-radius: 50%;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.modal-close:hover { background: var(--surface-sunken); color: var(--text); }
.modal-body {
padding: var(--s-5);
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--s-3);
padding: var(--s-4) var(--s-5);
border-top: 1px solid var(--border);
background: var(--surface-raised);
position: sticky;
bottom: 0;
}
/* Push Delete to the far left when present */
.modal-footer .btn-danger { margin-right: auto; }
/* ---- Form elements ----------------------------------------------------- */
.form-group { margin-bottom: var(--s-5); }
.form-group:last-child { margin-bottom: 0; }
.form-group label {
display: block;
margin-bottom: var(--s-2);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-muted);
}
.form-group input,
.form-group select {
width: 100%;
padding: var(--s-3);
min-height: var(--tap);
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
color: var(--text);
font-family: inherit;
font-size: var(--fs-base);
transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.form-group input::placeholder { color: var(--text-muted); opacity: 0.8; }
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* ---- Command builder --------------------------------------------------- */
.command-list {
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--s-3);
min-height: 96px;
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.command-item {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: var(--s-2);
display: flex;
align-items: center;
gap: var(--s-2);
}
.command-type-select {
flex: 0 0 auto;
min-height: 40px;
padding: 0 var(--s-2);
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
color: var(--text);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
}
.command-type-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.command-value {
flex: 1 1 auto;
min-width: 0;
min-height: 40px;
padding: 0 var(--s-3);
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-sm);
}
.command-value:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.command-value::placeholder { color: var(--text-muted); opacity: 0.7; }
.command-actions {
display: flex;
gap: var(--s-1);
flex: 0 0 auto;
}
.command-actions button {
width: 34px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--surface-sunken);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--r-sm);
cursor: pointer;
font-size: var(--fs-sm);
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.command-actions button:hover { color: var(--text); border-color: var(--border-strong); }
.command-actions button.delete:hover { color: var(--danger); border-color: var(--danger); }
.add-command-btns {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin-top: var(--s-3);
}
.add-command-btn {
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--text-muted);
padding: var(--s-2) var(--s-3);
min-height: 40px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: var(--fs-sm);
font-weight: 600;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease),
background var(--dur) var(--ease);
}
.add-command-btn:hover {
color: var(--accent);
border-color: var(--accent);
border-style: solid;
background: var(--accent-soft);
}
/* ---- Buttons ----------------------------------------------------------- */
.btn {
padding: 0 var(--s-5);
min-height: var(--tap);
border: 1px solid transparent;
border-radius: var(--r-sm);
cursor: pointer;
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
box-shadow: var(--key-highlight);
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: var(--accent-contrast); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary {
background: var(--surface-sunken);
color: var(--text);
border-color: var(--border-strong);
}
.btn-secondary:hover { border-color: var(--accent); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.08); }
/* ---- Toasts ------------------------------------------------------------ */
.toast-container {
position: fixed;
bottom: calc(var(--s-4) + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
width: max-content;
max-width: calc(100vw - 2 * var(--s-4));
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--surface-raised);
color: var(--text);
padding: var(--s-3) var(--s-4);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--r-sm);
font-size: var(--fs-sm);
font-weight: 500;
box-shadow: var(--shadow-2);
animation: toast-in var(--dur) var(--ease);
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
@keyframes toast-in {
from { transform: translateY(12px); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* ---- Empty state ------------------------------------------------------- */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: var(--s-6) var(--s-4);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-3);
}
/* Large signature glyph */
.empty-state::before {
content: "⌘";
font-size: 4rem;
line-height: 1;
color: var(--accent);
opacity: 0.85;
margin-bottom: var(--s-2);
}
.empty-state p {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--text);
}
.empty-state p.hint {
font-size: var(--fs-sm);
font-weight: 400;
color: var(--text-muted);
max-width: 32ch;
}
.empty-state .btn { margin-top: var(--s-3); }
/* ---- Loading ----------------------------------------------------------- */
.loading {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: var(--s-6);
}
.spinner {
width: 42px;
height: 42px;
border: 3px solid var(--border-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- Install banner ---------------------------------------------------- */
.install-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: var(--accent-soft);
color: var(--text);
border-bottom: 1px solid var(--accent);
font-size: var(--fs-sm);
font-weight: 500;
}
.install-banner > div { display: flex; gap: var(--s-2); align-items: center; }
.install-banner button {
background: var(--accent);
color: var(--accent-contrast);
border: none;
padding: var(--s-2) var(--s-4);
min-height: 40px;
border-radius: var(--r-sm);
cursor: pointer;
font-weight: 600;
}
.install-banner button:hover { background: var(--accent-hover); }
.install-banner .dismiss {
background: transparent;
color: var(--text-muted);
padding: var(--s-2);
min-width: 40px;
}
.install-banner .dismiss:hover { color: var(--text); }
/* ---- Fullscreen tweaks ------------------------------------------------- */
:fullscreen .header { padding-top: var(--s-2); padding-bottom: var(--s-2); }
:fullscreen .macro-grid { padding-bottom: var(--s-4); }
/* ---- Mobile ------------------------------------------------------------ */
@media (max-width: 480px) {
.header { padding: var(--s-3); }
.header h1 { font-size: var(--fs-lg); }
/* Keep the status text from crowding; dot + label still fit */
.connection-status { font-size: 0.7rem; padding: 0; }
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: var(--s-3);
padding: var(--s-3);
}
.macro-card { min-height: 128px; padding: var(--s-3) var(--s-2) var(--s-2); }
.macro-image,
.macro-image-placeholder { width: 56px; height: 56px; font-size: 1.6rem; }
.modal { padding: var(--s-2); align-items: flex-end; }
.modal-content { max-width: 100%; max-height: 92vh; }
.modal-body { padding: var(--s-4); }
.modal-header, .modal-footer { padding: var(--s-4); }
.fab {
right: calc(var(--s-4) + env(safe-area-inset-right));
bottom: calc(var(--s-4) + env(safe-area-inset-bottom));
}
}
/* ---- Reduced motion ---------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.macro-card:hover { transform: none; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'">
<meta name="theme-color" content="#15161a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
<meta name="description" content="Remote macro control for your desktop">
<!-- PWA / iOS specific -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<meta name="application-name" content="MacroPad">
<meta name="msapplication-TileColor" content="#7c5cff">
<meta name="msapplication-tap-highlight" content="no">
<title>MacroPad</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icons/icon-512.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" sizes="512x512" href="/static/icons/icon-512.png">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<!-- Header -->
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status" id="connection-status" role="status" aria-live="polite">
<div class="status-dot"></div>
<span>Disconnected</span>
</div>
<button type="button" class="wake-lock-status" id="wake-lock-status" title="Screen wake lock" aria-label="Toggle screen wake lock">
<span class="wake-icon" aria-hidden="true"></span>
</button>
<button type="button" class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen" aria-label="Toggle fullscreen"></button>
<button type="button" class="header-btn secondary" id="refresh-btn">Refresh</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container" role="tablist" aria-label="Macro categories">
<!-- Tabs rendered dynamically -->
</nav>
<!-- Macro Grid -->
<main class="macro-grid" id="macro-grid">
<div class="loading">
<div class="spinner"></div>
</div>
</main>
<!-- Floating action button: create new macro -->
<button type="button" class="fab" id="new-macro-btn" aria-label="New macro"></button>
<!-- Macro create/edit modal -->
<div class="modal" id="macro-modal" role="dialog" aria-modal="true" aria-labelledby="macro-modal-title" hidden>
<div class="modal-content">
<div class="modal-header">
<h2 id="macro-modal-title">New Macro</h2>
<button type="button" class="modal-close" id="macro-modal-close" aria-label="Close dialog">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="macro-name-input">Name</label>
<input type="text" id="macro-name-input" autocomplete="off" placeholder="Macro name">
</div>
<div class="form-group">
<label for="macro-category-input">Category</label>
<input type="text" id="macro-category-input" autocomplete="off" list="macro-category-list" placeholder="Category (optional)">
<datalist id="macro-category-list"></datalist>
</div>
<div class="form-group">
<label>Commands</label>
<div class="command-list" id="command-list"></div>
<div class="add-command-btns">
<button type="button" class="add-command-btn" id="add-command-btn"> Add step</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" id="delete-macro-btn" hidden>Delete</button>
<button type="button" class="btn btn-secondary" id="cancel-macro-btn">Cancel</button>
<button type="button" class="btn btn-primary" id="save-macro-btn">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container" role="status" aria-live="polite"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
+1085
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"id": "/",
"name": "MacroPad Server",
"short_name": "MacroPad",
"description": "Remote macro control for your desktop",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#15161a",
"theme_color": "#7c5cff",
"orientation": "any",
"//": "Maskable icons ideally need ~10% transparent padding so they aren't cropped inside the platform safe zone; the current PNGs are reused as-is without new art.",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["utilities", "productivity"],
"prefer_related_applications": false
}
+90
View File
@@ -0,0 +1,90 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
'/static/js/app.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/manifest.json'
];
// Install event - pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Caching app assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Network-first for navigations + app code so deployed fixes reach clients.
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (err) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Offline fallback for navigations: serve the cached shell.
if (request.mode === 'navigate') {
const shell = await cache.match('/');
if (shell) {
return shell;
}
}
return new Response('Offline', {
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/plain' }
});
}
}
self.addEventListener('fetch', (event) => {
const request = event.request;
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
// API requests always go to the network; never serve stale data.
// Return a synthetic offline JSON response instead of an unhandled undefined.
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request).catch(() => new Response(
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
))
);
return;
}
// Navigations and static app assets: network-first with cache fallback.
event.respondWith(networkFirst(request));
});
+392 -85
View File
@@ -1,99 +1,406 @@
# Web server component for MacroPad
# FastAPI web server for MacroPad
from flask import Flask, render_template_string, request, jsonify, send_file
from waitress import serve
import logging
import io
import os
from web_templates import INDEX_HTML
import sys
import uuid
import hmac
import asyncio
from typing import List, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
from PIL import Image
import uvicorn
from config import DEFAULT_PORT, VERSION
# Max accepted upload size for images (bytes).
MAX_IMAGE_UPLOAD_BYTES = 5 * 1024 * 1024
# Map Pillow image formats to safe file extensions we're willing to persist.
_IMAGE_FORMAT_EXT = {
"PNG": ".png",
"JPEG": ".jpg",
"GIF": ".gif",
"BMP": ".bmp",
"WEBP": ".webp",
}
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
class Command(BaseModel):
"""Single command in a macro sequence."""
type: str # text, key, hotkey, wait, app
value: Optional[str] = None
keys: Optional[List[str]] = None
ms: Optional[int] = None
command: Optional[str] = None
class MacroCreate(BaseModel):
"""Request model for creating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class MacroUpdate(BaseModel):
"""Request model for updating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class ExecuteRequest(BaseModel):
"""Request model for executing a macro."""
macro_id: str
class TabCreate(BaseModel):
"""Request model for creating a tab."""
name: str
class ConnectionManager:
"""Manages WebSocket connections for real-time updates."""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients, pruning dead sockets."""
dead = []
# Iterate over a snapshot so the list isn't mutated during iteration.
for connection in list(self.active_connections):
try:
await connection.send_json(message)
except Exception:
dead.append(connection)
for connection in dead:
self.disconnect(connection)
class WebServer:
def __init__(self, macro_manager, app_dir, port=40000):
"""FastAPI-based web server for MacroPad."""
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT,
auth_token: str = "", host: str = "127.0.0.1"):
self.macro_manager = macro_manager
self.app_dir = app_dir
self.app_dir = app_dir
self.port = port
self.auth_token = auth_token
self.host = host
self.app = None
def create_app(self):
"""Create and configure Flask application"""
app = Flask(__name__)
# Disable Flask's logging except for errors
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
@app.route('/')
def index():
return render_template_string(INDEX_HTML)
@app.route('/api/tabs')
def get_tabs():
"""Get all available tabs (similar to setup_tabs logic)"""
tabs = ["All"]
# Add tabs based on macro types and custom categories
unique_types = set()
for macro in self.macro_manager.macros.values():
if macro.get("type"):
unique_types.add(macro["type"].title())
# Check for custom category
if macro.get("category"):
unique_types.add(macro["category"])
for tab_type in sorted(unique_types):
if tab_type not in ["All"]:
tabs.append(tab_type)
return jsonify(tabs)
@app.route('/api/macros')
def get_macros():
return jsonify(self.macro_manager.macros)
@app.route('/api/macros/<tab_name>')
def get_macros_by_tab(tab_name):
"""Filter macros by tab (similar to filter_macros_by_tab logic)"""
if tab_name == "All":
return jsonify(self.macro_manager.macros)
filtered_macros = {}
for macro_id, macro in self.macro_manager.macros.items():
# Check type match
if macro.get("type", "").title() == tab_name:
filtered_macros[macro_id] = macro
# Check custom category match
elif macro.get("category") == tab_name:
filtered_macros[macro_id] = macro
return jsonify(filtered_macros)
@app.route('/api/image/<path:image_path>')
def get_image(image_path):
self.manager = ConnectionManager()
self.server = None
def create_app(self) -> FastAPI:
"""Create FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title="MacroPad Server",
version=VERSION,
lifespan=lifespan
)
# Serve static files from web directory (bundled with app)
web_dir = get_resource_path("web")
if os.path.exists(web_dir):
app.mount("/static", StaticFiles(directory=web_dir), name="static")
# Serve macro images (user data directory)
images_dir = os.path.join(self.app_dir, "macro_images")
if os.path.exists(images_dir):
app.mount("/images", StaticFiles(directory=images_dir), name="images")
# Paths served without a token (PWA shell + static assets)
public_paths = {"/", "/manifest.json", "/service-worker.js"}
def _add_security_headers(response):
# Anti-clickjacking / MIME-sniffing. frame-ancestors can only be set
# via a real response header (it is ignored inside a <meta> CSP), so
# we enforce it here rather than in index.html.
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
return response
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Require a valid auth token for all non-public paths."""
path = request.url.path
if path in public_paths or path.startswith("/static/"):
return _add_security_headers(await call_next(request))
# Fail-open only when no token is configured (shouldn't happen in prod)
if self.auth_token:
provided = request.headers.get("X-MacroPad-Token") \
or request.query_params.get("token") or ""
if not hmac.compare_digest(provided, self.auth_token):
return _add_security_headers(
JSONResponse({"detail": "Unauthorized"}, status_code=401)
)
return _add_security_headers(await call_next(request))
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the main PWA page."""
index_path = os.path.join(web_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path, media_type="text/html")
return HTMLResponse("<h1>MacroPad Server</h1><p>Web interface not found.</p>")
@app.get("/manifest.json")
async def manifest():
"""Serve PWA manifest."""
manifest_path = os.path.join(web_dir, "manifest.json")
if os.path.exists(manifest_path):
return FileResponse(manifest_path, media_type="application/json")
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/service-worker.js")
async def service_worker():
"""Serve service worker."""
sw_path = os.path.join(web_dir, "service-worker.js")
if os.path.exists(sw_path):
return FileResponse(sw_path, media_type="application/javascript")
raise HTTPException(status_code=404, detail="Service worker not found")
@app.get("/api/tabs")
async def get_tabs():
"""Get available tab categories."""
return {"tabs": self.macro_manager.get_unique_tabs()}
@app.get("/api/macros")
async def get_macros():
"""Get all macros."""
macros = self.macro_manager.get_all_macros()
return {"macros": macros}
@app.get("/api/macros/{tab}")
async def get_macros_by_tab(tab: str):
"""Get macros filtered by tab/category."""
all_macros = self.macro_manager.get_sorted_macros()
filtered = self.macro_manager.filter_macros_by_tab(all_macros, tab)
return {"macros": dict(filtered)}
@app.get("/api/macro/{macro_id}")
async def get_macro(macro_id: str):
"""Get a single macro by ID."""
macro = self.macro_manager.get_macro(macro_id)
if macro:
return {"macro": macro}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/execute")
async def execute_macro(request: ExecuteRequest):
"""Execute a macro by ID."""
# execute_macro is synchronous and may sleep (wait steps); run it in
# a thread executor so it doesn't block the event loop / other traffic.
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None, self.macro_manager.execute_macro, request.macro_id
)
if success:
# Broadcast execution to all connected clients
await self.manager.broadcast({
"type": "executed",
"macro_id": request.macro_id
})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found or execution failed")
@app.post("/api/macros")
async def create_macro(macro: MacroCreate):
"""Create a new macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
macro_id = self.macro_manager.add_macro(
name=macro.name,
commands=commands,
category=macro.category or ""
)
# Broadcast update
await self.manager.broadcast({"type": "macro_created", "macro_id": macro_id})
return {"success": True, "macro_id": macro_id}
@app.put("/api/macros/{macro_id}")
async def update_macro(macro_id: str, macro: MacroUpdate):
"""Update an existing macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
success = self.macro_manager.update_macro(
macro_id=macro_id,
name=macro.name,
commands=commands,
category=macro.category or ""
)
if success:
await self.manager.broadcast({"type": "macro_updated", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.delete("/api/macros/{macro_id}")
async def delete_macro(macro_id: str):
"""Delete a macro."""
success = self.macro_manager.delete_macro(macro_id)
if success:
await self.manager.broadcast({"type": "macro_deleted", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for a macro.
Hardened: enforces a size cap, validates the bytes are a real image
with Pillow (content_type is untrusted), and saves into the app's
macro_images/ directory under a server-generated uuid name. Returns
only a relative reference, never an absolute server path.
"""
# Read with a hard size cap (reject oversize before buffering it all).
data = b""
chunk_size = 64 * 1024
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
data += chunk
if len(data) > MAX_IMAGE_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image too large")
if not data:
raise HTTPException(status_code=400, detail="Empty upload")
# Validate it's really an image (don't trust content_type).
try:
image_path = os.path.join(self.app_dir, image_path)
return send_file(image_path)
except Exception as e:
return str(e), 404
@app.route('/api/execute', methods=['POST'])
def execute_macro():
data = request.get_json()
if not data or 'macro_id' not in data:
return jsonify({"success": False, "error": "Invalid request"})
macro_id = data['macro_id']
success = self.macro_manager.execute_macro(macro_id)
return jsonify({"success": success})
Image.open(io.BytesIO(data)).verify()
# verify() leaves the image unusable; reopen to read the format.
img_format = (Image.open(io.BytesIO(data)).format or "").upper()
except Exception:
raise HTTPException(status_code=400, detail="File is not a valid image")
ext = _IMAGE_FORMAT_EXT.get(img_format)
if not ext:
raise HTTPException(status_code=400, detail="Unsupported image format")
images_dir = os.path.join(self.app_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(images_dir, filename)
rel_path = os.path.join("macro_images", filename)
try:
with open(dest_path, "wb") as out:
out.write(data)
except Exception:
# Clean up a partial file on failure.
try:
if os.path.exists(dest_path):
os.remove(dest_path)
except OSError:
pass
raise HTTPException(status_code=500, detail="Failed to save image")
# Return only a relative, opaque reference.
return {"path": rel_path.replace(os.sep, "/")}
@app.get("/api/image/{image_path:path}")
async def get_image(image_path: str):
"""Get macro image (legacy compatibility, path-traversal safe)."""
images_dir = os.path.join(self.app_dir, "macro_images")
requested = os.path.realpath(os.path.join(self.app_dir, image_path))
images_real = os.path.realpath(images_dir)
# Only serve files that resolve to inside the macro_images directory
if (os.path.commonpath([requested, images_real]) == images_real
and os.path.isfile(requested)):
return FileResponse(requested)
raise HTTPException(status_code=404, detail="Image not found")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time updates."""
# Require a valid token before accepting the connection
if self.auth_token:
provided = websocket.query_params.get("token") or ""
if not hmac.compare_digest(provided, self.auth_token):
await websocket.close(code=1008)
return
await self.manager.connect(websocket)
try:
while True:
# receive_json raises on malformed input / non-JSON frames;
# any such error must still tear the connection down cleanly.
data = await websocket.receive_json()
# Handle incoming messages if needed
if isinstance(data, dict) and data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception:
# Malformed input or any other error: don't leak the connection.
pass
finally:
self.manager.disconnect(websocket)
self.app = app
return app
def run(self):
"""Run the web server"""
"""Run the web server (blocking)."""
if not self.app:
self.create_app()
try:
serve(self.app, host='0.0.0.0', port=self.port, threads=4)
except Exception as e:
raise e
config = uvicorn.Config(
self.app,
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
)
self.server = uvicorn.Server(config)
self.server.run()
def stop(self):
"""Stop the web server."""
if self.server:
self.server.should_exit = True
async def run_async(self):
"""Run the web server asynchronously."""
if not self.app:
self.create_app()
config = uvicorn.Config(
self.app,
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
)
self.server = uvicorn.Server(config)
await self.server.serve()
-292
View File
@@ -1,292 +0,0 @@
# HTML templates for the web interface
INDEX_HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad Web Interface</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #2e2e2e;
color: #ffffff;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
h1 {
color: #007acc;
margin: 0;
}
.refresh-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
}
.refresh-button:hover {
background-color: #007acc;
}
.refresh-button svg {
margin-right: 5px;
}
.tab-container {
margin-bottom: 20px;
border-bottom: 2px solid #404040;
}
.tab-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 0;
}
.tab-button {
background-color: #404040;
color: #ffffff;
border: none;
border-radius: 8px 8px 0 0;
padding: 10px 15px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
border-bottom: 3px solid transparent;
}
.tab-button:hover {
background-color: #505050;
}
.tab-button.active {
background-color: #007acc;
border-bottom-color: #007acc;
}
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.macro-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 15px 10px;
text-align: center;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100px;
}
.macro-button:hover, .macro-button:active {
background-color: #007acc;
}
.macro-button img {
max-width: 64px;
max-height: 64px;
margin-bottom: 10px;
}
.status {
text-align: center;
margin: 20px 0;
padding: 10px;
border-radius: 5px;
}
.success {
background-color: #4CAF50;
color: white;
display: none;
}
.error {
background-color: #f44336;
color: white;
display: none;
}
@media (max-width: 600px) {
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.macro-button {
padding: 10px 5px;
font-size: 14px;
}
h1 {
font-size: 24px;
}
.refresh-button {
padding: 8px 12px;
font-size: 14px;
}
.tab-button {
padding: 8px 12px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div class="header-container">
<h1>MacroPad Web Interface</h1>
<button class="refresh-button" onclick="loadTabs()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Refresh
</button>
</div>
<div class="status success" id="success-status">Macro executed successfully!</div>
<div class="status error" id="error-status">Failed to execute macro</div>
<div class="tab-container">
<div class="tab-list" id="tab-list">
<!-- Tabs will be loaded here -->
</div>
</div>
<div class="macro-grid" id="macro-grid">
<!-- Macros will be loaded here -->
</div>
<script>
let currentTab = 'All';
let allMacros = {};
document.addEventListener('DOMContentLoaded', function() {
loadTabs();
});
function loadTabs() {
fetch('/api/tabs')
.then(response => response.json())
.then(tabs => {
const tabList = document.getElementById('tab-list');
tabList.innerHTML = '';
tabs.forEach(tab => {
const button = document.createElement('button');
button.className = 'tab-button';
button.textContent = tab;
button.onclick = function() { switchTab(tab); };
if (tab === currentTab) {
button.classList.add('active');
}
tabList.appendChild(button);
});
// Load macros for current tab
loadMacros(currentTab);
})
.catch(error => {
console.error('Error loading tabs:', error);
});
}
function switchTab(tabName) {
currentTab = tabName;
// Update tab button states
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.classList.remove('active');
if (button.textContent === tabName) {
button.classList.add('active');
}
});
// Load macros for selected tab
loadMacros(tabName);
}
function loadMacros(tab = 'All') {
const url = tab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(tab)}`;
fetch(url)
.then(response => response.json())
.then(macros => {
allMacros = macros;
displayMacros(macros);
})
.catch(error => {
console.error('Error loading macros:', error);
});
}
function displayMacros(macros) {
const macroGrid = document.getElementById('macro-grid');
macroGrid.innerHTML = '';
if (Object.keys(macros).length === 0) {
macroGrid.innerHTML = '<p style="grid-column: 1 / -1; text-align: center;">No macros in this category.</p>';
return;
}
for (const [macroId, macro] of Object.entries(macros)) {
const button = document.createElement('button');
button.className = 'macro-button';
button.onclick = function() { executeMacro(macroId); };
// Add image if available
if (macro.image_path) {
const img = document.createElement('img');
img.src = `/api/image/${encodeURIComponent(macro.image_path)}`;
img.alt = macro.name;
img.onerror = function() {
this.style.display = 'none';
};
button.appendChild(img);
}
const text = document.createTextNode(macro.name);
button.appendChild(text);
macroGrid.appendChild(button);
}
}
function executeMacro(macroId) {
fetch('/api/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ macro_id: macroId })
})
.then(response => response.json())
.then(data => {
const successStatus = document.getElementById('success-status');
const errorStatus = document.getElementById('error-status');
if (data.success) {
successStatus.style.display = 'block';
errorStatus.style.display = 'none';
setTimeout(() => { successStatus.style.display = 'none'; }, 2000);
} else {
errorStatus.style.display = 'block';
successStatus.style.display = 'none';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
}
})
.catch(error => {
console.error('Error executing macro:', error);
const errorStatus = document.getElementById('error-status');
errorStatus.style.display = 'block';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
});
}
</script>
</body>
</html>
'''