UNPKG

capacitor-native-update

Version:
304 lines 11.9 kB
import { ConfigManager } from '../core/config'; import { Logger } from '../core/logger'; import { DownloadError, ErrorCode, ValidationError } from '../core/errors'; import { Directory } from '@capacitor/filesystem'; /** * Manages file downloads with progress tracking and resume capability */ export class DownloadManager { constructor() { this.activeDownloads = new Map(); this.filesystem = null; this.logger = Logger.getInstance(); this.configManager = ConfigManager.getInstance(); } /** * Initialize the download manager */ async initialize() { this.filesystem = this.configManager.get('filesystem'); if (!this.filesystem) { throw new DownloadError(ErrorCode.MISSING_DEPENDENCY, 'Filesystem not configured. Please configure the plugin first.'); } } /** * Validate URL against allowed hosts */ validateUrl(url) { try { const parsedUrl = new URL(url); // Ensure HTTPS if (parsedUrl.protocol !== 'https:') { throw new ValidationError(ErrorCode.INVALID_URL, 'Only HTTPS URLs are allowed for security reasons'); } // Check against allowed hosts if configured const allowedHosts = this.configManager.get('allowedHosts'); if (allowedHosts.length > 0 && !allowedHosts.includes(parsedUrl.hostname)) { throw new ValidationError(ErrorCode.UNAUTHORIZED_HOST, `Host ${parsedUrl.hostname} is not in the allowed hosts list`); } } catch (error) { if (error instanceof ValidationError) throw error; throw new ValidationError(ErrorCode.INVALID_URL, 'Invalid URL format'); } } /** * Download a file with progress tracking */ async download(url, bundleId, onProgress) { // Validate inputs this.validateUrl(url); if (!bundleId) { throw new ValidationError(ErrorCode.INVALID_BUNDLE_FORMAT, 'Bundle ID is required'); } // Check if already downloading if (this.activeDownloads.has(bundleId)) { throw new DownloadError(ErrorCode.DOWNLOAD_FAILED, `Download already in progress for bundle ${bundleId}`); } // Create abort controller for this download const abortController = new AbortController(); const downloadState = { controller: abortController, startTime: Date.now(), }; this.activeDownloads.set(bundleId, downloadState); try { const timeout = this.configManager.get('downloadTimeout'); const timeoutId = setTimeout(() => abortController.abort(), timeout); const response = await fetch(url, { signal: abortController.signal, headers: { 'Cache-Control': 'no-cache', 'Accept': 'application/octet-stream, application/zip', }, }); clearTimeout(timeoutId); if (!response.ok) { throw new DownloadError(ErrorCode.DOWNLOAD_FAILED, `Download failed: ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); } // Validate content type const contentType = response.headers.get('content-type'); if (contentType && !this.isValidContentType(contentType)) { throw new ValidationError(ErrorCode.INVALID_BUNDLE_FORMAT, `Invalid content type: ${contentType}`); } // Get total size from headers const contentLength = response.headers.get('content-length'); const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; // Check size limit if (totalBytes > this.configManager.get('maxBundleSize')) { throw new ValidationError(ErrorCode.BUNDLE_TOO_LARGE, `Bundle size ${totalBytes} exceeds maximum allowed size`); } // If no content length, fall back to simple download if (!totalBytes || !response.body) { const blob = await response.blob(); this.validateBlobSize(blob); return blob; } // Stream the response with progress tracking const reader = response.body.getReader(); const chunks = []; let receivedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedBytes += value.length; // Check if size exceeds limit during download if (receivedBytes > this.configManager.get('maxBundleSize')) { throw new ValidationError(ErrorCode.BUNDLE_TOO_LARGE, `Download size exceeds maximum allowed size`); } // Report progress if (onProgress) { const percent = Math.round((receivedBytes / totalBytes) * 100); onProgress({ percent, bytesDownloaded: receivedBytes, totalBytes, bundleId, }); } } // Combine chunks into a single blob const blob = new Blob(chunks); this.validateBlobSize(blob); this.logger.info('Download completed', { bundleId, size: blob.size, duration: Date.now() - downloadState.startTime }); return blob; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { const isTimeout = Date.now() - downloadState.startTime >= this.configManager.get('downloadTimeout'); throw new DownloadError(isTimeout ? ErrorCode.DOWNLOAD_TIMEOUT : ErrorCode.DOWNLOAD_FAILED, isTimeout ? 'Download timed out' : 'Download cancelled', undefined, error); } throw error; } finally { // Clean up this.activeDownloads.delete(bundleId); } } /** * Validate content type */ isValidContentType(contentType) { const validTypes = [ 'application/octet-stream', 'application/zip', 'application/x-zip-compressed', 'application/x-zip', ]; return validTypes.some(type => contentType.includes(type)); } /** * Validate blob size */ validateBlobSize(blob) { if (blob.size === 0) { throw new ValidationError(ErrorCode.INVALID_BUNDLE_FORMAT, 'Downloaded file is empty'); } if (blob.size > this.configManager.get('maxBundleSize')) { throw new ValidationError(ErrorCode.BUNDLE_TOO_LARGE, `File size ${blob.size} exceeds maximum allowed size`); } } /** * Cancel a download */ cancelDownload(bundleId) { const state = this.activeDownloads.get(bundleId); if (state) { state.controller.abort(); this.activeDownloads.delete(bundleId); this.logger.info('Download cancelled', { bundleId }); } } /** * Cancel all active downloads */ cancelAllDownloads() { for (const state of this.activeDownloads.values()) { state.controller.abort(); } const count = this.activeDownloads.size; this.activeDownloads.clear(); if (count > 0) { this.logger.info('All downloads cancelled', { count }); } } /** * Check if a download is active */ isDownloading(bundleId) { return this.activeDownloads.has(bundleId); } /** * Get active download count */ getActiveDownloadCount() { return this.activeDownloads.size; } /** * Download with retry logic */ async downloadWithRetry(url, bundleId, onProgress) { const maxRetries = this.configManager.get('retryAttempts'); const retryDelay = this.configManager.get('retryDelay'); let lastError = null; for (let attempt = 0; attempt < maxRetries; attempt++) { try { // Add delay between retries (exponential backoff) if (attempt > 0) { const delay = Math.min(retryDelay * Math.pow(2, attempt - 1), 30000); await new Promise((resolve) => setTimeout(resolve, delay)); this.logger.debug('Retrying download', { bundleId, attempt, delay }); } return await this.download(url, bundleId, onProgress); } catch (error) { lastError = error; // Don't retry if cancelled or validation error if (error instanceof ValidationError || (error instanceof Error && error.name === 'AbortError')) { throw error; } this.logger.warn(`Download attempt ${attempt + 1} failed`, { bundleId, error }); } } throw new DownloadError(ErrorCode.DOWNLOAD_FAILED, 'Download failed after all retries', { attempts: maxRetries }, lastError || undefined); } /** * Convert blob to ArrayBuffer */ async blobToArrayBuffer(blob) { return blob.arrayBuffer(); } /** * Save blob to filesystem */ async saveBlob(bundleId, blob) { if (!this.filesystem) { throw new DownloadError(ErrorCode.MISSING_DEPENDENCY, 'Filesystem not initialized'); } const arrayBuffer = await this.blobToArrayBuffer(blob); const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); const path = `bundles/${bundleId}/bundle.zip`; await this.filesystem.writeFile({ path, data: base64, directory: Directory.Data, recursive: true, }); this.logger.debug('Bundle saved to filesystem', { bundleId, path, size: blob.size }); return path; } /** * Load blob from filesystem */ async loadBlob(bundleId) { if (!this.filesystem) { throw new DownloadError(ErrorCode.MISSING_DEPENDENCY, 'Filesystem not initialized'); } try { const path = `bundles/${bundleId}/bundle.zip`; const result = await this.filesystem.readFile({ path, directory: Directory.Data, }); const binaryString = atob(result.data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return new Blob([bytes], { type: 'application/zip' }); } catch (error) { this.logger.debug('Failed to load bundle from filesystem', { bundleId, error }); return null; } } /** * Delete blob from filesystem */ async deleteBlob(bundleId) { if (!this.filesystem) { throw new DownloadError(ErrorCode.MISSING_DEPENDENCY, 'Filesystem not initialized'); } try { const path = `bundles/${bundleId}`; await this.filesystem.rmdir({ path, directory: Directory.Data, recursive: true, }); this.logger.debug('Bundle deleted from filesystem', { bundleId }); } catch (error) { this.logger.warn('Failed to delete bundle from filesystem', { bundleId, error }); } } } //# sourceMappingURL=download-manager.js.map