This commit is contained in:
cpu
2025-03-28 22:36:08 +01:00
parent 08632ee711
commit 5763407edc
33 changed files with 664 additions and 491 deletions

View File

@@ -0,0 +1,283 @@
// pushFlicIntegration.js
import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from '../config.js';
let pushSubscription = null; // Keep track locally if needed
let actionHandlers = {}; // Store handlers for different Flic actions
let lastBatteryWarningTimestamp = 0; // Track when last battery warning was shown
// --- Helper Functions ---
// Get stored basic auth credentials or prompt user for them
function getBasicAuthCredentials() {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
try {
const credentials = JSON.parse(storedAuth);
// Check if the credentials are valid
if (credentials.username && credentials.password) {
console.log('Using stored basic auth credentials.');
return credentials;
}
} catch (error) {
console.error('Failed to parse stored credentials:', error);
}
}
// No valid stored credentials found
// The function will return null and the caller should handle prompting if needed
console.log('No valid stored credentials found.');
return null;
}
// Prompt the user for credentials after permissions are granted
function promptForCredentials() {
console.log('Prompting user for auth credentials.');
const username = prompt('Please enter your username for backend authentication:');
if (!username) return null;
const password = prompt('Please enter your password:');
if (!password) return null;
const credentials = { username, password };
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
return credentials;
}
// Create Basic Auth header string
function createBasicAuthHeader(credentials) {
if (!credentials?.username || !credentials.password) return null;
return 'Basic ' + btoa(`${credentials.username}:${credentials.password}`);
}
// Convert URL-safe base64 string to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Convert ArrayBuffer to URL-safe Base64 string
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); }
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Show a popup notification to the user
function showBatteryWarning(batteryLevel) {
// Only show warning once every 4 hours (to avoid annoying users)
const now = Date.now();
const fourHoursInMs = 4 * 60 * 60 * 1000;
if (now - lastBatteryWarningTimestamp < fourHoursInMs) {
console.log(`[PushFlic] Battery warning suppressed (shown recently): ${batteryLevel}%`);
return;
}
lastBatteryWarningTimestamp = now;
// Show the notification
const message = `Flic button battery is low (${batteryLevel}%). Please replace the battery soon.`;
// Show browser notification if permission granted
if (Notification.permission === 'granted') {
new Notification('Flic Button Low Battery', {
body: message,
icon: '/public/favicon.ico'
});
}
}
// --- Push Subscription Logic ---
async function subscribeToPush() {
const buttonId = FLIC_BUTTON_ID; // Use configured button ID
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.error('Push Messaging is not supported.');
alert('Push Notifications are not supported by your browser.');
return;
}
try {
// First request notification permission
console.log('Requesting notification permission...');
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission denied.');
alert('Please enable notifications to link the Flic button.');
return;
}
console.log('Notification permission granted.');
// After permission is granted, check for stored credentials or prompt user
let credentials = getBasicAuthCredentials();
if (!credentials) {
const confirmAuth = confirm('Do you want to set up credentials for push notifications now?');
if (!confirmAuth) {
console.log('User declined to provide auth credentials.');
return;
}
credentials = promptForCredentials();
if (!credentials) {
console.log('User canceled credential input.');
alert('Authentication required to set up push notifications.');
return;
}
}
const registration = await navigator.serviceWorker.ready;
let existingSubscription = await registration.pushManager.getSubscription();
let needsResubscribe = !existingSubscription;
if (existingSubscription) {
const existingKey = existingSubscription.options?.applicationServerKey;
if (!existingKey || arrayBufferToBase64(existingKey) !== PUBLIC_VAPID_KEY) {
console.log('VAPID key mismatch or missing. Unsubscribing old subscription.');
await existingSubscription.unsubscribe();
existingSubscription = null;
needsResubscribe = true;
} else {
console.log('Existing valid subscription found.');
pushSubscription = existingSubscription; // Store it
}
}
let finalSubscription = existingSubscription;
if (needsResubscribe) {
console.log('Subscribing for push notifications...');
const applicationServerKey = urlBase64ToUint8Array(PUBLIC_VAPID_KEY);
finalSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('New push subscription obtained:', finalSubscription);
pushSubscription = finalSubscription; // Store it
}
if (!finalSubscription) {
console.error("Failed to obtain a subscription object.");
alert("Could not get subscription details.");
return;
}
await sendSubscriptionToServer(finalSubscription, buttonId);
} catch (error) {
console.error('Error during push subscription:', error);
alert(`Subscription failed: ${error.message}`);
}
}
async function sendSubscriptionToServer(subscription, buttonId) {
console.log(`Sending subscription for button "${buttonId}" to backend...`);
const credentials = getBasicAuthCredentials();
if (!credentials) {
// One more chance to enter credentials if needed
const confirmAuth = confirm('Authentication required to complete setup. Provide credentials now?');
if (!confirmAuth) {
alert('Authentication required to save button link.');
return;
}
const newCredentials = promptForCredentials();
if (!newCredentials) {
alert('Authentication required to save button link.');
return;
}
credentials = newCredentials;
}
const headers = { 'Content-Type': 'application/json' };
const authHeader = createBasicAuthHeader(credentials);
if (authHeader) headers['Authorization'] = authHeader;
try {
// Add support for handling CORS preflight with credentials
const response = await fetch(`${BACKEND_URL}/subscribe`, {
method: 'POST',
body: JSON.stringify({ button_id: buttonId, subscription: subscription }),
headers: headers,
credentials: 'include' // This ensures credentials are sent with OPTIONS requests too
});
if (response.ok) {
const result = await response.json();
console.log('Subscription sent successfully:', result.message);
alert('Push notification setup completed successfully!');
} else {
let errorMsg = `Server error: ${response.status}`;
if (response.status === 401 || response.status === 403) {
localStorage.removeItem('basicAuthCredentials'); // Clear bad creds
errorMsg = 'Authentication failed. Please try again.';
} else {
try { errorMsg = (await response.json()).message || errorMsg; } catch (e) { /* use default */ }
}
console.error('Failed to send subscription:', errorMsg);
alert(`Failed to save link: ${errorMsg}`);
}
} catch (error) {
console.error('Network error sending subscription:', error);
alert(`Network error: ${error.message}`);
}
}
// --- Flic Action Handling ---
// Called by app.js when a message is received from the service worker
export function handleFlicAction(action, buttonId, timestamp, batteryLevel) {
console.log(`[PushFlic] Received Action: ${action} from Button: ${buttonId} battery: ${batteryLevel}% at ${timestamp}`);
// Check if battery is below threshold and show warning if needed
if (batteryLevel < FLIC_BATTERY_THRESHOLD) {
showBatteryWarning(batteryLevel);
}
// Ignore actions from buttons other than the configured one
if (buttonId !== FLIC_BUTTON_ID) {
console.warn(`[PushFlic] Ignoring action from unknown button: ${buttonId}`);
return;
}
// Find the registered handler for this action
const handler = actionHandlers[action];
if (handler && typeof handler === 'function') {
console.log(`[PushFlic] Executing handler for ${action}`);
// Execute the handler registered in app.js
handler(); // Use the handler function directly instead of hardcoded function calls
} else {
console.warn(`[PushFlic] No handler registered for action: ${action}`);
}
}
// --- Initialization ---
export function initPushFlic(handlers) {
actionHandlers = handlers; // Store the handlers passed from app.js
// Example: handlers = { SingleClick: handleNextPlayer, Hold: handleTogglePause }
// Attempt to subscribe immediately if permission might already be granted
// Or trigger subscription on a user action (e.g., a "Link Flic Button" button)
// For simplicity, let's try subscribing if SW is ready and permission allows
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
console.log('[PushFlic] Permission granted, attempting subscription.');
subscribeToPush();
} else {
console.log('[PushFlic] Notification permission not granted.');
// Optionally provide a button for the user to trigger subscription later
}
});
});
}
}

View File

@@ -0,0 +1,58 @@
// serviceWorkerManager.js - Service worker registration and Flic integration
import * as config from '../config.js';
import * as pushFlic from './pushFlicIntegration.js';
// Store the action handlers passed from app.js
let flicActionHandlers = {};
export function setFlicActionHandlers(handlers) {
flicActionHandlers = handlers;
}
// --- Flic Integration Setup ---
export function initFlic() {
// This function is used by setupServiceWorker and relies on
// flicActionHandlers being set before this is called
pushFlic.initPushFlic(flicActionHandlers);
}
export function handleServiceWorkerMessage(event) {
console.log('[App] Message received from Service Worker:', event.data);
if (event.data?.type === 'flic-action') {
const { action, button, timestamp, batteryLevel } = event.data;
if (flicActionHandlers[action]) {
flicActionHandlers[action]();
} else {
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
}
}
}
// --- Service Worker and PWA Setup ---
export function setupServiceWorker(messageHandler) {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/public/sw.js')
.then(registration => {
console.log('ServiceWorker registered successfully.');
// Listen for messages FROM the Service Worker (e.g., Flic actions)
navigator.serviceWorker.addEventListener('message', messageHandler);
// Initialize Flic integration (which will try to subscribe)
initFlic();
})
.catch(error => {
console.error('ServiceWorker registration failed:', error);
});
});
// Listen for SW controller changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker controller changed, potentially updated.');
// window.location.reload(); // Consider prompting user to reload
});
} else {
console.warn('ServiceWorker not supported.');
}
}