- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
687 lines
26 KiB
JavaScript
687 lines
26 KiB
JavaScript
// modules/api-manager/PermissionManager.js
|
|
import { Logger } from '../../core/logger.js';
|
|
|
|
/**
|
|
* Permission Management System for Web APIs
|
|
*/
|
|
export class PermissionManager {
|
|
constructor(config = {}) {
|
|
this.config = config;
|
|
this.permissionCache = new Map();
|
|
this.permissionWatchers = new Map();
|
|
this.requestQueue = new Map();
|
|
|
|
// Check Permissions API support
|
|
this.support = {
|
|
permissions: 'permissions' in navigator,
|
|
query: navigator.permissions?.query !== undefined,
|
|
request: 'requestPermission' in Notification || 'getUserMedia' in (navigator.mediaDevices || {}),
|
|
geolocation: 'geolocation' in navigator,
|
|
notifications: 'Notification' in window,
|
|
camera: navigator.mediaDevices !== undefined,
|
|
microphone: navigator.mediaDevices !== undefined,
|
|
clipboard: 'clipboard' in navigator,
|
|
vibration: 'vibrate' in navigator
|
|
};
|
|
|
|
// Permission mappings for different APIs
|
|
this.permissionMap = {
|
|
camera: { name: 'camera', api: 'mediaDevices' },
|
|
microphone: { name: 'microphone', api: 'mediaDevices' },
|
|
geolocation: { name: 'geolocation', api: 'geolocation' },
|
|
notifications: { name: 'notifications', api: 'notification' },
|
|
'clipboard-read': { name: 'clipboard-read', api: 'clipboard' },
|
|
'clipboard-write': { name: 'clipboard-write', api: 'clipboard' },
|
|
'background-sync': { name: 'background-sync', api: 'serviceWorker' },
|
|
'persistent-storage': { name: 'persistent-storage', api: 'storage' },
|
|
'push': { name: 'push', api: 'serviceWorker' },
|
|
'midi': { name: 'midi', api: 'midi' },
|
|
'payment-handler': { name: 'payment-handler', api: 'payment' }
|
|
};
|
|
|
|
Logger.info('[PermissionManager] Initialized with support:', this.support);
|
|
|
|
// Auto-cache current permissions
|
|
this.cacheCurrentPermissions();
|
|
}
|
|
|
|
/**
|
|
* Check permission status for a specific permission
|
|
*/
|
|
async check(permission) {
|
|
if (!this.support.permissions || !this.support.query) {
|
|
Logger.warn('[PermissionManager] Permissions API not supported, using fallback');
|
|
return this.checkFallback(permission);
|
|
}
|
|
|
|
try {
|
|
// Check cache first
|
|
const cached = this.permissionCache.get(permission);
|
|
if (cached && Date.now() - cached.timestamp < 30000) { // 30s cache
|
|
return cached.status;
|
|
}
|
|
|
|
const permissionDescriptor = this.getPermissionDescriptor(permission);
|
|
const status = await navigator.permissions.query(permissionDescriptor);
|
|
|
|
const result = {
|
|
name: permission,
|
|
state: status.state,
|
|
timestamp: Date.now(),
|
|
supported: true
|
|
};
|
|
|
|
this.permissionCache.set(permission, result);
|
|
|
|
// Watch for changes
|
|
status.addEventListener('change', () => {
|
|
this.onPermissionChange(permission, status.state);
|
|
});
|
|
|
|
Logger.info(`[PermissionManager] Permission checked: ${permission} = ${status.state}`);
|
|
return result;
|
|
|
|
} catch (error) {
|
|
Logger.warn(`[PermissionManager] Permission check failed for ${permission}:`, error.message);
|
|
return this.checkFallback(permission);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request permission for a specific API
|
|
*/
|
|
async request(permission, options = {}) {
|
|
const {
|
|
showRationale = true,
|
|
fallbackMessage = null,
|
|
timeout = 30000
|
|
} = options;
|
|
|
|
try {
|
|
// Check current status first
|
|
const currentStatus = await this.check(permission);
|
|
if (currentStatus.state === 'granted') {
|
|
return { granted: true, state: 'granted', fromCache: true };
|
|
}
|
|
|
|
if (currentStatus.state === 'denied') {
|
|
if (showRationale) {
|
|
await this.showPermissionRationale(permission, fallbackMessage);
|
|
}
|
|
return { granted: false, state: 'denied', reason: 'previously-denied' };
|
|
}
|
|
|
|
// Add to request queue to prevent multiple simultaneous requests
|
|
const queueKey = permission;
|
|
if (this.requestQueue.has(queueKey)) {
|
|
Logger.info(`[PermissionManager] Permission request already in progress: ${permission}`);
|
|
return this.requestQueue.get(queueKey);
|
|
}
|
|
|
|
const requestPromise = this.executePermissionRequest(permission, timeout);
|
|
this.requestQueue.set(queueKey, requestPromise);
|
|
|
|
const result = await requestPromise;
|
|
this.requestQueue.delete(queueKey);
|
|
|
|
// Update cache
|
|
this.permissionCache.set(permission, {
|
|
name: permission,
|
|
state: result.state,
|
|
timestamp: Date.now(),
|
|
supported: true
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
Logger.error(`[PermissionManager] Permission request failed: ${permission}`, error);
|
|
this.requestQueue.delete(permission);
|
|
return { granted: false, state: 'error', error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request multiple permissions at once
|
|
*/
|
|
async requestMultiple(permissions, options = {}) {
|
|
const results = {};
|
|
|
|
if (options.sequential) {
|
|
// Request permissions one by one
|
|
for (const permission of permissions) {
|
|
results[permission] = await this.request(permission, options);
|
|
|
|
// Stop if any critical permission is denied
|
|
if (options.requireAll && !results[permission].granted) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Request permissions in parallel
|
|
const requests = permissions.map(permission =>
|
|
this.request(permission, options).then(result => [permission, result])
|
|
);
|
|
|
|
const responses = await Promise.allSettled(requests);
|
|
responses.forEach(response => {
|
|
if (response.status === 'fulfilled') {
|
|
const [permission, result] = response.value;
|
|
results[permission] = result;
|
|
} else {
|
|
Logger.error('[PermissionManager] Batch permission request failed:', response.reason);
|
|
}
|
|
});
|
|
}
|
|
|
|
const summary = {
|
|
results,
|
|
granted: Object.values(results).filter(r => r.granted).length,
|
|
denied: Object.values(results).filter(r => !r.granted).length,
|
|
total: Object.keys(results).length
|
|
};
|
|
|
|
Logger.info(`[PermissionManager] Batch permissions: ${summary.granted}/${summary.total} granted`);
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Check if all required permissions are granted
|
|
*/
|
|
async checkRequired(requiredPermissions) {
|
|
const statuses = {};
|
|
const missing = [];
|
|
|
|
for (const permission of requiredPermissions) {
|
|
const status = await this.check(permission);
|
|
statuses[permission] = status;
|
|
|
|
if (status.state !== 'granted') {
|
|
missing.push(permission);
|
|
}
|
|
}
|
|
|
|
return {
|
|
allGranted: missing.length === 0,
|
|
granted: Object.keys(statuses).filter(p => statuses[p].state === 'granted'),
|
|
missing,
|
|
statuses
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Watch permission changes
|
|
*/
|
|
watch(permission, callback) {
|
|
const watcherId = this.generateId('watcher');
|
|
|
|
this.permissionWatchers.set(watcherId, {
|
|
permission,
|
|
callback,
|
|
active: true
|
|
});
|
|
|
|
// Set up the watcher
|
|
this.setupPermissionWatcher(permission, callback);
|
|
|
|
Logger.info(`[PermissionManager] Permission watcher created: ${permission}`);
|
|
|
|
return {
|
|
id: watcherId,
|
|
stop: () => {
|
|
const watcher = this.permissionWatchers.get(watcherId);
|
|
if (watcher) {
|
|
watcher.active = false;
|
|
this.permissionWatchers.delete(watcherId);
|
|
Logger.info(`[PermissionManager] Permission watcher stopped: ${permission}`);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get permission recommendations based on app features
|
|
*/
|
|
getRecommendations(features = []) {
|
|
const recommendations = {
|
|
essential: [],
|
|
recommended: [],
|
|
optional: []
|
|
};
|
|
|
|
const featurePermissionMap = {
|
|
camera: { permissions: ['camera'], priority: 'essential' },
|
|
microphone: { permissions: ['microphone'], priority: 'essential' },
|
|
location: { permissions: ['geolocation'], priority: 'recommended' },
|
|
notifications: { permissions: ['notifications'], priority: 'recommended' },
|
|
clipboard: { permissions: ['clipboard-read', 'clipboard-write'], priority: 'optional' },
|
|
offline: { permissions: ['persistent-storage'], priority: 'recommended' },
|
|
background: { permissions: ['background-sync', 'push'], priority: 'optional' }
|
|
};
|
|
|
|
features.forEach(feature => {
|
|
const mapping = featurePermissionMap[feature];
|
|
if (mapping) {
|
|
recommendations[mapping.priority].push(...mapping.permissions);
|
|
}
|
|
});
|
|
|
|
// Remove duplicates
|
|
Object.keys(recommendations).forEach(key => {
|
|
recommendations[key] = [...new Set(recommendations[key])];
|
|
});
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
/**
|
|
* Create permission onboarding flow
|
|
*/
|
|
createOnboardingFlow(permissions, options = {}) {
|
|
const {
|
|
title = 'Permissions Required',
|
|
descriptions = {},
|
|
onComplete = null,
|
|
onSkip = null
|
|
} = options;
|
|
|
|
return {
|
|
permissions,
|
|
title,
|
|
descriptions,
|
|
|
|
async start() {
|
|
Logger.info('[PermissionManager] Starting onboarding flow');
|
|
|
|
const results = [];
|
|
|
|
for (const permission of permissions) {
|
|
const description = descriptions[permission] || this.getDefaultDescription(permission);
|
|
|
|
// Show permission explanation
|
|
const userChoice = await this.showPermissionDialog(permission, description);
|
|
|
|
if (userChoice === 'grant') {
|
|
const result = await this.request(permission);
|
|
results.push({ permission, ...result });
|
|
} else if (userChoice === 'skip') {
|
|
results.push({ permission, granted: false, skipped: true });
|
|
} else {
|
|
// User cancelled the entire flow
|
|
if (onSkip) onSkip(results);
|
|
return { cancelled: true, results };
|
|
}
|
|
}
|
|
|
|
const summary = {
|
|
completed: true,
|
|
granted: results.filter(r => r.granted).length,
|
|
total: results.length,
|
|
results
|
|
};
|
|
|
|
if (onComplete) onComplete(summary);
|
|
return summary;
|
|
},
|
|
|
|
getDefaultDescription(permission) {
|
|
const descriptions = {
|
|
camera: 'Take photos and record videos for enhanced functionality',
|
|
microphone: 'Record audio for voice features and communication',
|
|
geolocation: 'Provide location-based services and content',
|
|
notifications: 'Send you important updates and reminders',
|
|
'clipboard-read': 'Access clipboard content for convenience features',
|
|
'clipboard-write': 'Copy content to clipboard for easy sharing'
|
|
};
|
|
|
|
return descriptions[permission] || `Allow access to ${permission} functionality`;
|
|
},
|
|
|
|
async showPermissionDialog(permission, description) {
|
|
return new Promise(resolve => {
|
|
// Create modal dialog
|
|
const modal = document.createElement('div');
|
|
modal.className = 'permission-dialog-modal';
|
|
modal.innerHTML = `
|
|
<div class="permission-dialog-backdrop" style="
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 10000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
">
|
|
<div class="permission-dialog" style="
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
max-width: 400px;
|
|
margin: 1rem;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
">
|
|
<h3 style="margin: 0 0 1rem 0; color: #333;">
|
|
${this.getPermissionIcon(permission)} ${this.getPermissionTitle(permission)}
|
|
</h3>
|
|
<p style="margin: 0 0 2rem 0; color: #666; line-height: 1.5;">
|
|
${description}
|
|
</p>
|
|
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
<button class="btn-skip" style="
|
|
background: #f5f5f5;
|
|
border: 1px solid #ddd;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
">Skip</button>
|
|
<button class="btn-grant" style="
|
|
background: #007bff;
|
|
color: white;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
">Allow</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
const skipBtn = modal.querySelector('.btn-skip');
|
|
const grantBtn = modal.querySelector('.btn-grant');
|
|
|
|
const cleanup = () => document.body.removeChild(modal);
|
|
|
|
skipBtn.onclick = () => {
|
|
cleanup();
|
|
resolve('skip');
|
|
};
|
|
|
|
grantBtn.onclick = () => {
|
|
cleanup();
|
|
resolve('grant');
|
|
};
|
|
|
|
// Close on backdrop click
|
|
modal.querySelector('.permission-dialog-backdrop').onclick = (e) => {
|
|
if (e.target === e.currentTarget) {
|
|
cleanup();
|
|
resolve('cancel');
|
|
}
|
|
};
|
|
});
|
|
},
|
|
|
|
getPermissionIcon(permission) {
|
|
const icons = {
|
|
camera: '📷',
|
|
microphone: '🎤',
|
|
geolocation: '📍',
|
|
notifications: '🔔',
|
|
'clipboard-read': '📋',
|
|
'clipboard-write': '📋'
|
|
};
|
|
return icons[permission] || '🔐';
|
|
},
|
|
|
|
getPermissionTitle(permission) {
|
|
const titles = {
|
|
camera: 'Camera Access',
|
|
microphone: 'Microphone Access',
|
|
geolocation: 'Location Access',
|
|
notifications: 'Notifications',
|
|
'clipboard-read': 'Clipboard Access',
|
|
'clipboard-write': 'Clipboard Access'
|
|
};
|
|
return titles[permission] || `${permission} Permission`;
|
|
}
|
|
};
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
async cacheCurrentPermissions() {
|
|
if (!this.support.permissions) return;
|
|
|
|
const commonPermissions = ['camera', 'microphone', 'geolocation', 'notifications'];
|
|
|
|
for (const permission of commonPermissions) {
|
|
try {
|
|
await this.check(permission);
|
|
} catch (error) {
|
|
// Ignore errors during initial caching
|
|
}
|
|
}
|
|
}
|
|
|
|
getPermissionDescriptor(permission) {
|
|
// Handle different permission descriptor formats
|
|
switch (permission) {
|
|
case 'camera':
|
|
return { name: 'camera' };
|
|
case 'microphone':
|
|
return { name: 'microphone' };
|
|
case 'geolocation':
|
|
return { name: 'geolocation' };
|
|
case 'notifications':
|
|
return { name: 'notifications' };
|
|
case 'clipboard-read':
|
|
return { name: 'clipboard-read' };
|
|
case 'clipboard-write':
|
|
return { name: 'clipboard-write' };
|
|
case 'persistent-storage':
|
|
return { name: 'persistent-storage' };
|
|
case 'background-sync':
|
|
return { name: 'background-sync' };
|
|
case 'push':
|
|
return { name: 'push', userVisibleOnly: true };
|
|
default:
|
|
return { name: permission };
|
|
}
|
|
}
|
|
|
|
async checkFallback(permission) {
|
|
// Fallback checks for browsers without Permissions API
|
|
const fallbacks = {
|
|
camera: () => navigator.mediaDevices !== undefined,
|
|
microphone: () => navigator.mediaDevices !== undefined,
|
|
geolocation: () => 'geolocation' in navigator,
|
|
notifications: () => 'Notification' in window,
|
|
'clipboard-read': () => 'clipboard' in navigator,
|
|
'clipboard-write': () => 'clipboard' in navigator
|
|
};
|
|
|
|
const check = fallbacks[permission];
|
|
const supported = check ? check() : false;
|
|
|
|
return {
|
|
name: permission,
|
|
state: supported ? 'prompt' : 'unsupported',
|
|
timestamp: Date.now(),
|
|
supported,
|
|
fallback: true
|
|
};
|
|
}
|
|
|
|
async executePermissionRequest(permission, timeout) {
|
|
return new Promise(async (resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
reject(new Error('Permission request timeout'));
|
|
}, timeout);
|
|
|
|
try {
|
|
let result;
|
|
|
|
switch (permission) {
|
|
case 'notifications':
|
|
if ('Notification' in window) {
|
|
const notificationPermission = await Notification.requestPermission();
|
|
result = { granted: notificationPermission === 'granted', state: notificationPermission };
|
|
} else {
|
|
result = { granted: false, state: 'unsupported' };
|
|
}
|
|
break;
|
|
|
|
case 'camera':
|
|
case 'microphone':
|
|
try {
|
|
const constraints = {};
|
|
constraints[permission === 'camera' ? 'video' : 'audio'] = true;
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
stream.getTracks().forEach(track => track.stop()); // Stop immediately
|
|
|
|
result = { granted: true, state: 'granted' };
|
|
} catch (error) {
|
|
result = {
|
|
granted: false,
|
|
state: error.name === 'NotAllowedError' ? 'denied' : 'error',
|
|
error: error.message
|
|
};
|
|
}
|
|
break;
|
|
|
|
case 'geolocation':
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
timeout: 10000,
|
|
maximumAge: 0
|
|
});
|
|
});
|
|
result = { granted: true, state: 'granted' };
|
|
} catch (error) {
|
|
result = {
|
|
granted: false,
|
|
state: error.code === 1 ? 'denied' : 'error',
|
|
error: error.message
|
|
};
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// Try generic permission request
|
|
if (this.support.permissions) {
|
|
const status = await navigator.permissions.query(this.getPermissionDescriptor(permission));
|
|
result = { granted: status.state === 'granted', state: status.state };
|
|
} else {
|
|
result = { granted: false, state: 'unsupported' };
|
|
}
|
|
}
|
|
|
|
clearTimeout(timeoutId);
|
|
resolve(result);
|
|
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async setupPermissionWatcher(permission, callback) {
|
|
if (!this.support.permissions) return;
|
|
|
|
try {
|
|
const status = await navigator.permissions.query(this.getPermissionDescriptor(permission));
|
|
status.addEventListener('change', () => {
|
|
callback({
|
|
permission,
|
|
state: status.state,
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
} catch (error) {
|
|
Logger.warn(`[PermissionManager] Could not set up watcher for ${permission}:`, error);
|
|
}
|
|
}
|
|
|
|
onPermissionChange(permission, newState) {
|
|
Logger.info(`[PermissionManager] Permission changed: ${permission} → ${newState}`);
|
|
|
|
// Update cache
|
|
this.permissionCache.set(permission, {
|
|
name: permission,
|
|
state: newState,
|
|
timestamp: Date.now(),
|
|
supported: true
|
|
});
|
|
|
|
// Notify watchers
|
|
this.permissionWatchers.forEach(watcher => {
|
|
if (watcher.permission === permission && watcher.active) {
|
|
watcher.callback({
|
|
permission,
|
|
state: newState,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async showPermissionRationale(permission, customMessage = null) {
|
|
const rationales = {
|
|
camera: 'Camera access is needed to take photos and record videos. You can enable this in your browser settings.',
|
|
microphone: 'Microphone access is needed for audio recording and voice features. Please check your browser settings.',
|
|
geolocation: 'Location access helps provide relevant local content. You can manage this in your browser settings.',
|
|
notifications: 'Notifications keep you updated with important information. You can change this anytime in settings.'
|
|
};
|
|
|
|
const message = customMessage || rationales[permission] || `${permission} permission was denied. Please enable it in your browser settings to use this feature.`;
|
|
|
|
// Simple alert for now - could be enhanced with custom UI
|
|
if (typeof window !== 'undefined' && window.confirm) {
|
|
return window.confirm(message + '\n\nWould you like to try again?');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
generateId(prefix = 'perm') {
|
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive permission status report
|
|
*/
|
|
async getPermissionReport() {
|
|
const report = {
|
|
support: this.support,
|
|
cached: Array.from(this.permissionCache.entries()).map(([name, data]) => ({
|
|
name,
|
|
...data
|
|
})),
|
|
watchers: Array.from(this.permissionWatchers.keys()),
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Clear all cached permissions
|
|
*/
|
|
clearCache() {
|
|
this.permissionCache.clear();
|
|
Logger.info('[PermissionManager] Permission cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Stop all watchers and cleanup
|
|
*/
|
|
cleanup() {
|
|
this.permissionWatchers.forEach(watcher => {
|
|
watcher.active = false;
|
|
});
|
|
this.permissionWatchers.clear();
|
|
this.requestQueue.clear();
|
|
this.permissionCache.clear();
|
|
|
|
Logger.info('[PermissionManager] Cleanup completed');
|
|
}
|
|
} |