diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 2249343..e7f8243 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -16,7 +16,7 @@ from .config import ( SOCKETIO_CORS_ALLOWED_ORIGINS, ) from .db import init_db -from .services.frontend_assets import asset_path, bootstrap_css_path, static_hash, validate_offline_assets +from .services.frontend_assets import asset_path, bootstrap_css_path, initialize_static_hash, static_hash, validate_offline_assets socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading") @@ -56,6 +56,7 @@ def register_error_pages(app: Flask) -> None: def create_app() -> Flask: validate_offline_assets() app = Flask(__name__) + initialize_static_hash(Path(app.static_folder or "")) from .logging_config import configure_logging configure_logging(app) if PROXY_FIX_ENABLE: @@ -83,7 +84,7 @@ def create_app() -> Flask: path = Path(app.static_folder or "") / filename try: path.stat() - # Note: A single static hash keeps module imports, CSS and local libraries on the same cache version. + # Note: A single JS/CSS hash keeps module imports, stylesheets and local libraries on the same cache version. return url_for("static", filename=filename, v=current_static_hash()) except OSError: return url_for("static", filename=filename) diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 9cb2d28..b69e485 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -7499,8 +7499,8 @@ "tags": [ "System" ], - "summary": "Get static asset version hash", - "description": "Returns the current hash for app static assets. Clients can compare it with window.PYTORRENT.staticHash and reload when it changes.", + "summary": "Get current frontend JS/CSS hash", + "description": "Returns the startup-computed hash for app JavaScript and CSS assets. The value is kept in memory and returned without scanning static files per request.", "responses": { "200": { "description": "Static asset hash", @@ -7513,10 +7513,12 @@ "type": "boolean" }, "static_hash": { - "type": "string" + "type": "string", + "description": "Short SHA-256-based hash of frontend JavaScript and CSS files computed once at app startup." }, "version": { - "type": "string" + "type": "string", + "description": "Alias of static_hash for simple client version checks." } } } diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index 93994da..b6a2162 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -1,8 +1,6 @@ from __future__ import annotations from ._shared import * -from flask import current_app -from pathlib import Path from ..services import operation_logs from ..services.frontend_assets import static_hash @@ -51,8 +49,8 @@ def system_status(): @bp.get("/static_hash") def static_hash_get(): - # Note: The frontend uses this lightweight version to detect changed static assets on mobile browsers. - value = static_hash(Path(current_app.static_folder or "")) + # Note: This returns the startup-computed JS/CSS version without scanning files per request. + value = static_hash() return ok({"static_hash": value, "version": value}) diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index a1a75cc..5c3f99f 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -190,34 +190,68 @@ def validate_offline_assets() -> None: ) -_STATIC_HASH_CACHE: dict[tuple[str, int], str] = {} +_STATIC_HASH_VALUE = "dev" +_STATIC_HASH_READY = False -def static_hash(static_root: Path | None = None) -> str: - """Return one short hash for all app static files. - Note: This value is used as the shared browser-cache version, so any static file - change invalidates app.js imports, CSS and local frontend assets together. +def _versioned_static_files(root: Path) -> list[Path]: + """Return static files that should invalidate frontend JS/CSS caches. + + Note: Only JavaScript and CSS affect the executable frontend version. Images, + favicons and user-provided tracker icons stay outside this lightweight hash. + """ + return [ + path + for path in root.rglob("*") + if path.is_file() + and path.suffix.lower() in {".js", ".css"} + and "tracker_favicons" not in path.parts + ] + + +def compute_static_hash(static_root: Path | None = None) -> str: + """Compute one short startup hash for frontend JavaScript and CSS files. + + Note: This function reads JS/CSS files and should be called during app + startup, not from frequent request handlers. """ import hashlib root = static_root or (BASE_DIR / "pytorrent" / "static") - files = [path for path in root.rglob("*") if path.is_file() and "tracker_favicons" not in path.parts] - fingerprint = f"{root}:{sum(path.stat().st_mtime_ns for path in files)}:{sum(path.stat().st_size for path in files)}" - cached = _STATIC_HASH_CACHE.get((fingerprint, len(files))) - if cached: - return cached digest = hashlib.sha256() - for path in sorted(files): + files = sorted(_versioned_static_files(root), key=lambda item: item.as_posix()) + for path in files: rel = path.relative_to(root).as_posix() - stat = path.stat() - digest.update(rel.encode("utf-8")) - digest.update(str(stat.st_size).encode("ascii")) - digest.update(str(stat.st_mtime_ns).encode("ascii")) try: - digest.update(path.read_bytes()) + stat = path.stat() + content = path.read_bytes() except OSError: continue + digest.update(rel.encode("utf-8")) + digest.update(str(stat.st_size).encode("ascii")) + digest.update(content) value = digest.hexdigest()[:16] - _STATIC_HASH_CACHE.clear() - _STATIC_HASH_CACHE[(fingerprint, len(files))] = value - return value + return value or "dev" + + +def initialize_static_hash(static_root: Path | None = None) -> str: + """Compute and store the frontend static hash once for this process. + + Note: The API endpoint and template helpers only return this in-memory value, + which keeps mobile version checks ultra-light. + """ + global _STATIC_HASH_VALUE, _STATIC_HASH_READY + _STATIC_HASH_VALUE = compute_static_hash(static_root) + _STATIC_HASH_READY = True + return _STATIC_HASH_VALUE + + +def static_hash(static_root: Path | None = None) -> str: + """Return the startup frontend static hash without rescanning files. + + Note: The optional argument is kept for compatibility with existing callers; + it is only used for a lazy fallback before app startup initialization. + """ + if not _STATIC_HASH_READY: + return initialize_static_hash(static_root) + return _STATIC_HASH_VALUE diff --git a/pytorrent/static/js/app.js b/pytorrent/static/js/app.js index 134eea8..02d9036 100644 --- a/pytorrent/static/js/app.js +++ b/pytorrent/static/js/app.js @@ -99,9 +99,16 @@ async function loadModuleSources(){ return moduleSourcesPromise; } +function normalizeRuntimeSource(source){ + const text = String(source || ''); + // Note: Some generated source chunks may end with a literal \\n marker; + // normalize only this trailing marker to avoid invalid Function() source. + return text.endsWith('\\n') ? `${text.slice(0, -2)}\n` : text; +} + export async function buildRuntimeSource(){ const sources = await loadModuleSources(); - return `(() => {\n${sources.join('\n')}\n})();\n`; + return `(() => {\n${sources.map(normalizeRuntimeSource).join('\n')}\n})();\n`; } export async function startApp(){ diff --git a/pytorrent/static/js/bootstrapRuntime.js b/pytorrent/static/js/bootstrapRuntime.js index 1932350..a2ee7f4 100644 --- a/pytorrent/static/js/bootstrapRuntime.js +++ b/pytorrent/static/js/bootstrapRuntime.js @@ -1 +1 @@ -export const bootstrapRuntimeSource = " async function checkStaticAssetVersion(){ try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(checkStaticAssetVersion, 120000);\n window.addEventListener('focus', checkStaticAssetVersion);\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\\n"; +export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";