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>
This commit is contained in:
@@ -581,6 +581,15 @@ body {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wake-lock-status.unsupported {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.wake-lock-status.unsupported .wake-icon {
|
||||
color: #888;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
|
||||
217
web/js/app.js
217
web/js/app.js
@@ -8,9 +8,64 @@ class MacroPadApp {
|
||||
this.ws = null;
|
||||
this.wakeLock = null;
|
||||
|
||||
// Relay mode detection
|
||||
this.relayMode = this.detectRelayMode();
|
||||
this.sessionId = null;
|
||||
this.password = null;
|
||||
this.desktopConnected = true;
|
||||
this.wsAuthenticated = false;
|
||||
|
||||
if (this.relayMode) {
|
||||
this.initRelayMode();
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
detectRelayMode() {
|
||||
// Check if URL matches relay pattern: /sessionId/app or /sessionId
|
||||
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})(\/app)?$/);
|
||||
return pathMatch !== null;
|
||||
}
|
||||
|
||||
initRelayMode() {
|
||||
// Extract session ID from URL
|
||||
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})/);
|
||||
if (pathMatch) {
|
||||
this.sessionId = pathMatch[1];
|
||||
}
|
||||
|
||||
// Get password from URL query param or sessionStorage
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
|
||||
|
||||
if (this.password) {
|
||||
// Store password for future use
|
||||
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
||||
// Clear from URL for security
|
||||
if (urlParams.has('auth')) {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Relay mode enabled, session:', this.sessionId);
|
||||
}
|
||||
|
||||
getApiUrl(path) {
|
||||
if (this.relayMode && this.sessionId) {
|
||||
return `/${this.sessionId}${path}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
getApiHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (this.relayMode && this.password) {
|
||||
headers['X-MacroPad-Password'] = this.password;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadTabs();
|
||||
await this.loadMacros();
|
||||
@@ -23,7 +78,16 @@ class MacroPadApp {
|
||||
// API Methods
|
||||
async loadTabs() {
|
||||
try {
|
||||
const response = await fetch('/api/tabs');
|
||||
const response = await fetch(this.getApiUrl('/api/tabs'), {
|
||||
headers: this.getApiHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.handleAuthError();
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load tabs');
|
||||
}
|
||||
const data = await response.json();
|
||||
this.tabs = data.tabs || [];
|
||||
this.renderTabs();
|
||||
@@ -35,10 +99,23 @@ class MacroPadApp {
|
||||
|
||||
async loadMacros() {
|
||||
try {
|
||||
const url = this.currentTab === 'All'
|
||||
const path = this.currentTab === 'All'
|
||||
? '/api/macros'
|
||||
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(this.getApiUrl(path), {
|
||||
headers: this.getApiHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.handleAuthError();
|
||||
return;
|
||||
}
|
||||
if (response.status === 503) {
|
||||
this.handleDesktopDisconnected();
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load macros');
|
||||
}
|
||||
const data = await response.json();
|
||||
this.macros = data.macros || {};
|
||||
this.renderMacros();
|
||||
@@ -53,13 +130,18 @@ class MacroPadApp {
|
||||
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
||||
if (card) card.classList.add('executing');
|
||||
|
||||
const response = await fetch('/api/execute', {
|
||||
const response = await fetch(this.getApiUrl('/api/execute'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: this.getApiHeaders(),
|
||||
body: JSON.stringify({ macro_id: macroId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Execution failed');
|
||||
if (!response.ok) {
|
||||
if (response.status === 503) {
|
||||
this.handleDesktopDisconnected();
|
||||
}
|
||||
throw new Error('Execution failed');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (card) card.classList.remove('executing');
|
||||
@@ -70,19 +152,44 @@ class MacroPadApp {
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthError() {
|
||||
this.showToast('Authentication failed', 'error');
|
||||
if (this.relayMode) {
|
||||
// Clear stored password and redirect to login
|
||||
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
||||
window.location.href = `/${this.sessionId}`;
|
||||
}
|
||||
}
|
||||
|
||||
handleDesktopDisconnected() {
|
||||
this.desktopConnected = false;
|
||||
this.updateConnectionStatus(false, 'Desktop offline');
|
||||
this.showToast('Desktop app is not connected', 'error');
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
setupWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
let wsUrl;
|
||||
|
||||
if (this.relayMode && this.sessionId) {
|
||||
wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
||||
} else {
|
||||
wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.updateConnectionStatus(true);
|
||||
if (!this.relayMode) {
|
||||
this.updateConnectionStatus(true);
|
||||
}
|
||||
// In relay mode, wait for auth before showing connected
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.wsAuthenticated = false;
|
||||
this.updateConnectionStatus(false);
|
||||
setTimeout(() => this.setupWebSocket(), 3000);
|
||||
};
|
||||
@@ -102,12 +209,46 @@ class MacroPadApp {
|
||||
|
||||
handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
// Relay-specific messages
|
||||
case 'auth_required':
|
||||
// Send authentication
|
||||
if (this.password) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
password: this.password
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth_response':
|
||||
if (data.success) {
|
||||
this.wsAuthenticated = true;
|
||||
this.updateConnectionStatus(this.desktopConnected);
|
||||
} else {
|
||||
this.handleAuthError();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'desktop_status':
|
||||
this.desktopConnected = data.status === 'connected';
|
||||
this.updateConnectionStatus(this.desktopConnected);
|
||||
if (!this.desktopConnected) {
|
||||
this.showToast('Desktop disconnected', 'error');
|
||||
} else {
|
||||
this.showToast('Desktop connected', 'success');
|
||||
this.loadTabs();
|
||||
this.loadMacros();
|
||||
}
|
||||
break;
|
||||
|
||||
// Standard MacroPad messages
|
||||
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) {
|
||||
@@ -115,17 +256,27 @@ class MacroPadApp {
|
||||
setTimeout(() => card.classList.remove('executing'), 300);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Keep-alive response
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
updateConnectionStatus(connected, customText = null) {
|
||||
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';
|
||||
if (customText) {
|
||||
text.textContent = customText;
|
||||
} else if (this.relayMode) {
|
||||
text.textContent = connected ? 'Connected (Relay)' : 'Disconnected';
|
||||
} else {
|
||||
text.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,26 +412,57 @@ class MacroPadApp {
|
||||
|
||||
// Wake Lock - prevents screen from sleeping
|
||||
async setupWakeLock() {
|
||||
const status = document.getElementById('wake-lock-status');
|
||||
|
||||
if (!('wakeLock' in navigator)) {
|
||||
console.log('Wake Lock API not supported');
|
||||
document.getElementById('wake-lock-status')?.remove();
|
||||
// Don't remove the icon - show it as unsupported instead
|
||||
if (status) {
|
||||
status.classList.add('unsupported');
|
||||
status.title = 'Wake lock not available (requires HTTPS)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Request wake lock
|
||||
// Make the icon clickable to toggle wake lock
|
||||
if (status) {
|
||||
status.style.cursor = 'pointer';
|
||||
status.addEventListener('click', () => this.toggleWakeLock());
|
||||
}
|
||||
|
||||
// Request wake lock automatically
|
||||
await this.requestWakeLock();
|
||||
|
||||
// Re-acquire wake lock when page becomes visible again
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
|
||||
await this.requestWakeLock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async toggleWakeLock() {
|
||||
if (this.wakeLock) {
|
||||
// Release wake lock
|
||||
await this.wakeLock.release();
|
||||
this.wakeLock = null;
|
||||
this.wakeLockEnabled = false;
|
||||
this.updateWakeLockStatus(false);
|
||||
this.showToast('Screen can now sleep', 'info');
|
||||
} else {
|
||||
// Request wake lock
|
||||
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', () => {
|
||||
@@ -289,6 +471,11 @@ class MacroPadApp {
|
||||
} catch (err) {
|
||||
console.log('Wake Lock error:', err);
|
||||
this.updateWakeLockStatus(false);
|
||||
// Show error only if user explicitly tried to enable
|
||||
const status = document.getElementById('wake-lock-status');
|
||||
if (status && !status.classList.contains('unsupported')) {
|
||||
status.title = 'Wake lock failed: ' + err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +483,9 @@ class MacroPadApp {
|
||||
const status = document.getElementById('wake-lock-status');
|
||||
if (status) {
|
||||
status.classList.toggle('active', active);
|
||||
status.title = active ? 'Screen will stay on' : 'Screen may sleep';
|
||||
if (!status.classList.contains('unsupported')) {
|
||||
status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user