generate_manifests.py (9.7 KB)
1 #!/usr/bin/env python3 2 """ 3 Creates manifest.json, resource-manifest.json, and service-worker.js 4 based on the contents of tracks.json 5 """ 6 7 import json 8 import re 9 from pathlib import Path 10 11 def get_configuration(): 12 """Prompt user for configuration values""" 13 print("=" * 60) 14 print("PWA Configuration") 15 print("=" * 60) 16 print() 17 18 # Get app name 19 app_name = input("Enter a name for your mixapp: ").strip() 20 if not app_name: 21 print("Error: App name is required") 22 exit(1) 23 24 # Get base path with smart default 25 default_path = app_name.lower().replace(" ", "_") 26 print() 27 print(f"Enter the deployment path (or press Return/Enter for default)") 28 print(f"Default: /{default_path}/") 29 base_path_input = input("Path: ").strip() 30 31 if base_path_input: 32 # User provided a path - ensure it has leading/trailing slashes 33 base_path = base_path_input 34 if not base_path.startswith("/"): 35 base_path = "/" + base_path 36 if not base_path.endswith("/"): 37 base_path = base_path + "/" 38 else: 39 # Use default 40 base_path = f"/{default_path}/" 41 print(f"Using default path: {base_path}") 42 43 print() 44 print(f"Configuration:") 45 print(f" App Name: {app_name}") 46 print(f" Base Path: {base_path}") 47 print() 48 49 return app_name, base_path 50 51 # File paths (no need to edit these) 52 SCRIPT_DIR = Path(__file__).parent.absolute() 53 TRACKS_JSON = SCRIPT_DIR / "mix" / "tracks.json" 54 STYLES_CSS = SCRIPT_DIR / "resources" / "styles.css" 55 56 57 def get_background_color(): 58 """Extract the --background CSS variable from styles.css""" 59 if not STYLES_CSS.exists(): 60 print("Warning: styles.css not found. Using default color.") 61 return "#080a0c" 62 63 with open(STYLES_CSS, 'r', encoding='utf-8') as f: 64 content = f.read() 65 66 # Look for --background: <color>; pattern 67 match = re.search( 68 r'--background:\s*([#a-zA-Z0-9(),.\s]+?)\s*;', 69 content 70 ) 71 if match: 72 color = match.group(1).strip() 73 print(f"Found background color in styles.css: {color}") 74 return color 75 76 print("Warning: --background not found in styles.css. Using default color.") 77 return "#080a0c" 78 79 80 def _next_cache_name(app_name, manifest_path): 81 """Determine the next cache name by incrementing the version in an existing manifest. 82 83 If manifest.json doesn't exist or has no versioned cache_name, returns "{app_name}-v1". 84 If it already has e.g. "heaven-v1", returns "heaven-v1.1". 85 If it already has e.g. "heaven-v1.3", returns "heaven-v1.4". 86 """ 87 if manifest_path.exists(): 88 try: 89 with open(manifest_path, 'r', encoding='utf-8') as f: 90 existing = json.load(f) 91 old_cache = existing.get("cache_name", "") 92 # Match pattern like "appname-v1" or "appname-v1.3" 93 match = re.match(r'^(.+)-v(\d+)(?:\.(\d+))?$', old_cache) 94 if match: 95 prefix = match.group(1) 96 major = int(match.group(2)) 97 minor = int(match.group(3)) if match.group(3) is not None else 0 98 # If app was renamed, start fresh with new name 99 if prefix != app_name: 100 return f"{app_name}-v1" 101 return f"{app_name}-v{major}.{minor + 1}" 102 except (json.JSONDecodeError, KeyError): 103 pass 104 return f"{app_name}-v1" 105 106 107 def generate_pwa_manifests(app_name=None, base_path=None): 108 """Generate PWA manifest files based on tracks.json 109 110 Args: 111 app_name: Name of the app. If None, will be prompted via get_configuration() 112 base_path: Base path for the app. If None, will be prompted via get_configuration() 113 """ 114 # Get configuration if not provided 115 if app_name is None or base_path is None: 116 app_name, base_path = get_configuration() 117 118 # Derived values 119 short_name = app_name 120 cache_name = _next_cache_name(app_name, SCRIPT_DIR / "manifest.json") 121 app_description = f"{app_name}" 122 123 print("Generating PWA manifests...") 124 print(f" Cache name: {cache_name}") 125 126 # Load tracks.json 127 if not TRACKS_JSON.exists(): 128 print("Error: tracks.json not found. Run build.py first.") 129 return 130 131 with open(TRACKS_JSON, 'r', encoding='utf-8') as f: 132 tracks = json.load(f) 133 134 # Get background color from styles.css 135 background_color = get_background_color() 136 137 # Generate manifest.json 138 manifest = { 139 "id": base_path, 140 "name": app_name, 141 "short_name": short_name, 142 "description": app_description, 143 "start_url": base_path, 144 "scope": base_path, 145 "display": "standalone", 146 "background_color": background_color, 147 "theme_color": background_color, 148 "cache_name": cache_name, # Custom field for script.js to use 149 "icons": [ 150 { 151 "src": f"{base_path}resources/icon.png", 152 "sizes": "640x640", 153 "type": "image/png", 154 "purpose": "any maskable" 155 } 156 ] 157 } 158 159 with open(SCRIPT_DIR / "manifest.json", 'w', encoding='utf-8') as f: 160 json.dump(manifest, f, indent=2) 161 print("✓ Generated manifest.json") 162 163 # Generate resource-manifest.json 164 static_files = [ 165 "./", 166 "index.html", 167 "resources/styles.css", 168 "resources/script.js", 169 "mix/tracks.json", 170 "resources/icon.png", 171 "resources/play.svg", 172 "resources/pause.svg", 173 "resources/prev.svg", 174 "resources/next.svg", 175 "resources/repeat.svg", 176 ] 177 178 # Conditionally include optional files if they exist 179 for optional_file in ["album_art.jpg", "custom.css", "custom.js"]: 180 if (SCRIPT_DIR / "mix" / optional_file).exists(): 181 static_files.append(f"mix/{optional_file}") 182 183 resource_manifest = { 184 "static_files": static_files, 185 "tracks": [f"mix/{track['filename']}" for track in tracks] 186 } 187 188 with open(SCRIPT_DIR / "resource-manifest.json", 'w', encoding='utf-8') as f: 189 json.dump(resource_manifest, f, indent=2) 190 print("✓ Generated resource-manifest.json") 191 192 # Generate service-worker.js 193 static_files = resource_manifest["static_files"] 194 service_worker_content = f'''// Auto-generated service worker for {app_name} PWA 195 const CACHE_NAME = '{cache_name}'; 196 const staticFilesToCache = {json.dumps(static_files, indent=2)}; 197 198 // Get the base path from the service worker location 199 const getBasePath = () => {{ 200 const swPath = self.location.pathname; 201 return swPath.substring(0, swPath.lastIndexOf('/') + 1); 202 }}; 203 204 const basePath = getBasePath(); 205 206 // Install event - cache only static resources (not audio files) 207 // Audio files will be cached by the main app's blob preloading system 208 self.addEventListener('install', (event) => {{ 209 console.log('Service Worker installing...', 'Base path:', basePath); 210 event.waitUntil( 211 caches.open(CACHE_NAME) 212 .then((cache) => {{ 213 console.log('Opened cache'); 214 // Make URLs absolute relative to service worker location 215 const absoluteUrls = staticFilesToCache.map(url => {{ 216 if (url === './') return basePath; 217 return new URL(url, basePath + 'index.html').href; 218 }}); 219 console.log('Caching', absoluteUrls.length, 'static resources'); 220 console.log('URLs to cache:', absoluteUrls); 221 222 // Cache files individually with better error handling 223 // Using Promise.allSettled to continue even if some fail 224 return Promise.allSettled( 225 absoluteUrls.map(url => 226 fetch(url, {{ cache: 'no-cache' }}) 227 .then(response => {{ 228 if (!response.ok) {{ 229 throw new Error(`HTTP error! status: ${{response.status}}`); 230 }} 231 return cache.put(url, response); 232 }}) 233 .then(() => console.log('✓ Cached:', url)) 234 .catch(err => {{ 235 console.error('✗ Failed to cache:', url, err); 236 throw err; 237 }}) 238 ) 239 ).then(results => {{ 240 const failed = results.filter(r => r.status === 'rejected'); 241 const succeeded = results.filter(r => r.status === 'fulfilled'); 242 console.log(`Cached ${{succeeded.length}}/${{results.length}} static resources`); 243 if (failed.length > 0) {{ 244 console.warn(`Failed to cache ${{failed.length}} resources`); 245 }} 246 }}); 247 }}) 248 .then(() => {{ 249 console.log('Service Worker installation complete'); 250 return self.skipWaiting(); 251 }}) 252 .catch(error => {{ 253 console.error('Service Worker installation failed:', error); 254 }}) 255 ); 256 }}); 257 258 // Activate event - clean up old caches 259 self.addEventListener('activate', (event) => {{ 260 console.log('Service Worker activating...'); 261 event.waitUntil( 262 caches.keys().then((cacheNames) => {{ 263 return Promise.all( 264 cacheNames.map((cacheName) => {{ 265 if (cacheName !== CACHE_NAME) {{ 266 console.log('Deleting old cache:', cacheName); 267 return caches.delete(cacheName); 268 }} 269 }}) 270 ); 271 }}).then(() => self.clients.claim()) 272 ); 273 }}); 274 275 // Fetch event - cache first, network fallback 276 self.addEventListener('fetch', (event) => {{ 277 // Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc. 278 if (!event.request.url.startsWith('http')) {{ 279 return; 280 }} 281 282 event.respondWith( 283 caches.match(event.request) 284 .then((cachedResponse) => {{ 285 if (cachedResponse) {{ 286 console.log('✓ Serving from cache:', event.request.url); 287 return cachedResponse; 288 }} 289 290 // Not in cache - try network 291 console.log('⟳ Fetching from network:', event.request.url); 292 return fetch(event.request) 293 .then((networkResponse) => {{ 294 // Check if valid response 295 if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{ 296 return networkResponse; 297 }} 298 299 // Clone and cache for future offline use 300 const responseToCache = networkResponse.clone(); 301 caches.open(CACHE_NAME) 302 .then((cache) => {{ 303 cache.put(event.request, responseToCache); 304 console.log('✓ Cached from network:', event.request.url); 305 }}) 306 .catch(err => console.error('Failed to cache:', err)); 307 308 return networkResponse; 309 }}) 310 .catch((error) => {{ 311 console.error('✗ Network fetch failed for:', event.request.url, error); 312 throw error; 313 }}); 314 }}) 315 ); 316 }}); 317 ''' 318 319 with open(SCRIPT_DIR / "service-worker.js", 'w', encoding='utf-8') as f: 320 f.write(service_worker_content) 321 print("✓ Generated service-worker.js") 322 print() 323 print("PWA manifests generated successfully!") 324 325 326 if __name__ == "__main__": 327 # When run directly, get configuration and generate manifests 328 app_name, base_path = get_configuration() 329 generate_pwa_manifests(app_name, base_path)