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>
This commit is contained in:
2026-01-03 16:57:14 -08:00
parent ded281cc64
commit 5888aeb603
29 changed files with 3436 additions and 1604 deletions

546
web/css/styles.css Normal file
View File

@@ -0,0 +1,546 @@
/* MacroPad PWA Styles */
: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;
--warning-color: #ffc107;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
overflow-x: hidden;
}
/* Header */
.header {
background-color: var(--highlight-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.header-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.header-btn:hover {
background: #0096ff;
}
.header-btn.secondary {
background: var(--button-bg);
}
.header-btn.secondary:hover {
background: var(--button-hover);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-color);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
background: var(--tab-bg);
color: var(--fg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.tab:hover {
background: var(--button-hover);
}
.tab.active {
background: var(--tab-selected);
}
/* Macro Grid */
.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;
}
.macro-edit-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.macro-card {
position: relative;
}
.macro-card:hover .macro-edit-btn {
opacity: 1;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--highlight-color);
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--bg-color);
}
.modal-header h2 {
font-size: 1.2rem;
}
.modal-close {
background: none;
border: none;
color: var(--fg-color);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
}
.modal-body {
padding: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--bg-color);
}
/* Form Elements */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: #aaa;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem;
background: var(--bg-color);
border: 1px solid var(--button-bg);
border-radius: 4px;
color: var(--fg-color);
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
}
/* Command Builder */
.command-list {
background: var(--bg-color);
border-radius: 4px;
padding: 0.5rem;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
}
.command-item {
background: var(--button-bg);
border-radius: 4px;
padding: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.command-item:last-child {
margin-bottom: 0;
}
.command-type {
background: var(--accent-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
min-width: 50px;
text-align: center;
}
.command-value {
flex: 1;
font-family: monospace;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-actions {
display: flex;
gap: 0.25rem;
}
.command-actions button {
background: var(--highlight-color);
border: none;
color: var(--fg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.command-actions button:hover {
background: var(--button-hover);
}
.command-actions button.delete {
color: var(--danger-color);
}
.add-command-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.add-command-btn {
background: var(--button-bg);
border: none;
color: var(--fg-color);
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.add-command-btn:hover {
background: var(--accent-color);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: #0096ff;
}
.btn-secondary {
background: var(--button-bg);
color: white;
}
.btn-secondary:hover {
background: var(--button-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #c82333;
}
/* Status/Toast Messages */
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 300;
}
.toast {
background: var(--highlight-color);
color: var(--fg-color);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
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;
}
}
/* Connection Status */
.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);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #888;
}
.empty-state p {
margin-bottom: 1rem;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.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); }
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.header h1 {
font-size: 1.2rem;
}
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
}
.macro-card {
padding: 0.75rem;
min-height: 100px;
}
.macro-image,
.macro-image-placeholder {
width: 48px;
height: 48px;
}
.modal {
max-width: 100%;
margin: 0.5rem;
}
}
/* Install Banner */
.install-banner {
background: var(--accent-color);
color: white;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.install-banner button {
background: white;
color: var(--accent-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.install-banner .dismiss {
background: transparent;
color: white;
padding: 0.5rem;
}

BIN
web/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
web/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

91
web/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#007acc">
<meta name="description" content="Remote macro control for your desktop">
<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>
<link rel="manifest" href="/manifest.json">
<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">
<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">
<div class="status-dot"></div>
<span>Disconnected</span>
</div>
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
<button class="header-btn" onclick="app.openAddModal()">+ Add</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container">
<!-- Tabs rendered dynamically -->
</nav>
<!-- Macro Grid -->
<main class="macro-grid" id="macro-grid">
<div class="loading">
<div class="spinner"></div>
</div>
</main>
<!-- Modal -->
<div class="modal-overlay" id="modal-overlay" style="display: none;">
<div class="modal">
<div class="modal-header">
<h2 id="modal-title">Add Macro</h2>
<button class="modal-close" onclick="app.closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="macro-name">Name</label>
<input type="text" id="macro-name" placeholder="Macro name">
</div>
<div class="form-group">
<label for="macro-category">Category (optional)</label>
<input type="text" id="macro-category" placeholder="Category">
</div>
<div class="form-group">
<label>Commands</label>
<div class="command-list" id="command-list">
<div class="empty-state"><p>No commands added yet</p></div>
</div>
<div class="add-command-btns">
<button class="add-command-btn" onclick="app.addCommand('text')">+ Text</button>
<button class="add-command-btn" onclick="app.addCommand('key')">+ Key</button>
<button class="add-command-btn" onclick="app.addCommand('hotkey')">+ Hotkey</button>
<button class="add-command-btn" onclick="app.addCommand('wait')">+ Wait</button>
<button class="add-command-btn" onclick="app.addCommand('app')">+ App</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="delete-btn" style="display: none;"
onclick="app.deleteMacro(app.editingMacroId); app.closeModal();">
Delete
</button>
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="app.saveMacro()">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

481
web/js/app.js Normal file
View File

@@ -0,0 +1,481 @@
// MacroPad PWA Application
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.editingMacroId = null;
this.commands = [];
this.init();
}
async init() {
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.checkInstallPrompt();
}
// API Methods
async loadTabs() {
try {
const response = await fetch('/api/tabs');
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
} catch (error) {
console.error('Error loading tabs:', error);
this.showToast('Error loading tabs', 'error');
}
}
async loadMacros() {
try {
const url = this.currentTab === 'All'
? '/api/macros'
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(url);
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const response = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Execution failed');
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
}
}
async saveMacro() {
const name = document.getElementById('macro-name').value.trim();
const category = document.getElementById('macro-category').value.trim();
if (!name) {
this.showToast('Please enter a macro name', 'error');
return;
}
if (this.commands.length === 0) {
this.showToast('Please add at least one command', 'error');
return;
}
const macroData = {
name,
category,
commands: this.commands
};
try {
let response;
if (this.editingMacroId) {
response = await fetch(`/api/macros/${this.editingMacroId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
} else {
response = await fetch('/api/macros', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
}
if (!response.ok) throw new Error('Save failed');
this.closeModal();
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro saved successfully', 'success');
} catch (error) {
console.error('Error saving macro:', error);
this.showToast('Error saving macro', 'error');
}
}
async deleteMacro(macroId) {
if (!confirm('Are you sure you want to delete this macro?')) return;
try {
const response = await fetch(`/api/macros/${macroId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro deleted', 'success');
} catch (error) {
console.error('Error deleting macro:', error);
this.showToast('Error deleting macro', 'error');
}
}
// WebSocket
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateConnectionStatus(true);
};
this.ws.onclose = () => {
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
}
}
handleWebSocketMessage(data) {
switch (data.type) {
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';
}
}
// Rendering
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<button class="btn btn-primary" onclick="app.openAddModal()">
Add Your First Macro
</button>
</div>
`;
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
const imageSrc = macro.image_path
? `/api/image/${macro.image_path}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
<button class="macro-edit-btn" onclick="event.stopPropagation(); app.openEditModal('${id}')">
Edit
</button>
${imageSrc
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
${firstChar}
</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
renderCommandList() {
const container = document.getElementById('command-list');
if (!container) return;
if (this.commands.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No commands added yet</p></div>';
return;
}
container.innerHTML = this.commands.map((cmd, index) => {
let displayValue = '';
switch (cmd.type) {
case 'text':
displayValue = cmd.value || '';
break;
case 'key':
displayValue = cmd.value || '';
break;
case 'hotkey':
displayValue = (cmd.keys || []).join(' + ');
break;
case 'wait':
displayValue = `${cmd.ms || 0}ms`;
break;
case 'app':
displayValue = cmd.command || '';
break;
}
return `
<div class="command-item" data-index="${index}">
<span class="command-type">${cmd.type}</span>
<span class="command-value">${displayValue}</span>
<div class="command-actions">
<button onclick="app.moveCommand(${index}, -1)">Up</button>
<button onclick="app.moveCommand(${index}, 1)">Down</button>
<button class="delete" onclick="app.removeCommand(${index})">X</button>
</div>
</div>
`;
}).join('');
}
// Command Builder
addCommand(type) {
let command = { type };
switch (type) {
case 'text':
const text = prompt('Enter text to type:');
if (!text) return;
command.value = text;
break;
case 'key':
const key = prompt('Enter key to press (e.g., enter, tab, escape, space):');
if (!key) return;
command.value = key.toLowerCase();
break;
case 'hotkey':
const keys = prompt('Enter key combination (comma separated, e.g., ctrl,c):');
if (!keys) return;
command.keys = keys.split(',').map(k => k.trim().toLowerCase());
break;
case 'wait':
const ms = prompt('Enter delay in milliseconds:');
if (!ms) return;
command.ms = parseInt(ms, 10) || 0;
break;
case 'app':
const appCmd = prompt('Enter application command:');
if (!appCmd) return;
command.command = appCmd;
break;
}
this.commands.push(command);
this.renderCommandList();
}
removeCommand(index) {
this.commands.splice(index, 1);
this.renderCommandList();
}
moveCommand(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.commands.length) return;
[this.commands[index], this.commands[newIndex]] =
[this.commands[newIndex], this.commands[index]];
this.renderCommandList();
}
// Modal
openAddModal() {
this.editingMacroId = null;
this.commands = [];
document.getElementById('macro-name').value = '';
document.getElementById('macro-category').value = '';
document.getElementById('modal-title').textContent = 'Add Macro';
document.getElementById('delete-btn').style.display = 'none';
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
async openEditModal(macroId) {
this.editingMacroId = macroId;
const macro = this.macros[macroId];
if (!macro) return;
document.getElementById('macro-name').value = macro.name || '';
document.getElementById('macro-category').value = macro.category || '';
document.getElementById('modal-title').textContent = 'Edit Macro';
document.getElementById('delete-btn').style.display = 'block';
this.commands = JSON.parse(JSON.stringify(macro.commands || []));
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
this.editingMacroId = null;
this.commands = [];
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
// Modal close on overlay click
document.getElementById('modal-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') {
this.closeModal();
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// PWA Install Prompt
checkInstallPrompt() {
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showInstallBanner(deferredPrompt);
});
}
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
async installPWA() {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
if (outcome === 'accepted') {
document.querySelector('.install-banner')?.remove();
}
this.deferredPrompt = null;
}
// Refresh
refresh() {
this.loadTabs();
this.loadMacros();
}
}
// Initialize app
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MacroPadApp();
});
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}

25
web/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "MacroPad Server",
"short_name": "MacroPad",
"description": "Remote macro control for your desktop",
"start_url": "/",
"display": "standalone",
"background_color": "#2e2e2e",
"theme_color": "#007acc",
"orientation": "any",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["utilities", "productivity"]
}

72
web/service-worker.js Normal file
View File

@@ -0,0 +1,72 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v1';
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 - cache assets
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())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Always fetch API requests from network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
});