version updates
This commit is contained in:
@@ -12,6 +12,7 @@ from curl_cffi import requests as impersonate_requests
|
|||||||
import threading
|
import threading
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
|
import hashlib
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
# Browser to impersonate at the TLS/HTTP layer. Some origins (e.g. the
|
# Browser to impersonate at the TLS/HTTP layer. Some origins (e.g. the
|
||||||
@@ -355,6 +356,63 @@ def index():
|
|||||||
def favicon():
|
def favicon():
|
||||||
return send_from_directory(app.static_folder, 'favicon.ico')
|
return send_from_directory(app.static_folder, 'favicon.ico')
|
||||||
|
|
||||||
|
# --- Frontend asset version tracking -------------------------------------
|
||||||
|
# The client polls /api/version and, when a tracked file's content hash
|
||||||
|
# changes, hot-swaps CSS in place or reloads the page. This lets a deploy
|
||||||
|
# reach already-open tabs without a manual refresh.
|
||||||
|
_FRONTEND_DIR = os.path.abspath(app.static_folder)
|
||||||
|
_VERSION_EXTS = ('.html', '.css', '.js')
|
||||||
|
_version_cache = {'mtime': None, 'payload': None}
|
||||||
|
_version_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_frontend_files():
|
||||||
|
"""Map served relative paths -> absolute paths for tracked frontend files."""
|
||||||
|
files = {}
|
||||||
|
for root, _dirs, names in os.walk(_FRONTEND_DIR):
|
||||||
|
for name in names:
|
||||||
|
if os.path.splitext(name)[1].lower() not in _VERSION_EXTS:
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
rel = os.path.relpath(path, _FRONTEND_DIR).replace(os.sep, '/')
|
||||||
|
files[rel] = path
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_version_payload(files):
|
||||||
|
"""Hash each tracked file's contents plus a combined version fingerprint."""
|
||||||
|
file_hashes = {}
|
||||||
|
combined = hashlib.md5()
|
||||||
|
for rel in sorted(files):
|
||||||
|
try:
|
||||||
|
with open(files[rel], 'rb') as fh:
|
||||||
|
digest = hashlib.md5(fh.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
file_hashes[rel] = digest
|
||||||
|
combined.update(rel.encode('utf-8'))
|
||||||
|
combined.update(digest.encode('utf-8'))
|
||||||
|
return {'version': combined.hexdigest(), 'files': file_hashes}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/version', methods=['GET'])
|
||||||
|
def frontend_version():
|
||||||
|
files = _scan_frontend_files()
|
||||||
|
# Use the newest mtime across tracked files as a cheap cache key so frequent
|
||||||
|
# polls only re-hash contents when something on disk actually changed.
|
||||||
|
try:
|
||||||
|
latest_mtime = max((os.path.getmtime(p) for p in files.values()), default=0)
|
||||||
|
except OSError:
|
||||||
|
latest_mtime = 0
|
||||||
|
with _version_lock:
|
||||||
|
if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None:
|
||||||
|
_version_cache['payload'] = _compute_version_payload(files)
|
||||||
|
_version_cache['mtime'] = latest_mtime
|
||||||
|
payload = _version_cache['payload']
|
||||||
|
resp = jsonify(payload)
|
||||||
|
resp.headers['Cache-Control'] = 'no-store'
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
|
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
|
||||||
def stream_video():
|
def stream_video():
|
||||||
# Note: <video> tags perform GET. To support your POST requirement,
|
# Note: <video> tags perform GET. To support your POST requirement,
|
||||||
|
|||||||
@@ -1379,6 +1379,56 @@ body.theme-light .error-toast {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 20px;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
max-width: min(420px, 92vw);
|
||||||
|
background: rgba(0, 0, 0, 0.88);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 3100;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-banner.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-banner button {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-light .update-banner {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-light .update-banner button {
|
||||||
|
background: #111111;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -161,6 +161,11 @@
|
|||||||
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="update-banner" class="update-banner" role="status" aria-live="polite">
|
||||||
|
<span>A new version is available.</span>
|
||||||
|
<button id="update-banner-btn" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="static/js/state.js"></script>
|
<script src="static/js/state.js"></script>
|
||||||
<script src="static/js/storage.js"></script>
|
<script src="static/js/storage.js"></script>
|
||||||
<script src="static/js/player.js"></script>
|
<script src="static/js/player.js"></script>
|
||||||
@@ -168,6 +173,7 @@
|
|||||||
<script src="static/js/videos.js"></script>
|
<script src="static/js/videos.js"></script>
|
||||||
<script src="static/js/feed.js"></script>
|
<script src="static/js/feed.js"></script>
|
||||||
<script src="static/js/ui.js"></script>
|
<script src="static/js/ui.js"></script>
|
||||||
|
<script src="static/js/version.js"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ window.App = window.App || {};
|
|||||||
// or failing status endpoint. Now fetch fresh status in the background and
|
// or failing status endpoint. Now fetch fresh status in the background and
|
||||||
// reconcile the UI with whatever comes back.
|
// reconcile the UI with whatever comes back.
|
||||||
App.storage.refreshServerStatusInBackground();
|
App.storage.refreshServerStatusInBackground();
|
||||||
|
|
||||||
|
// Watch for frontend deploys and seamlessly reload/hot-swap changed assets.
|
||||||
|
if (App.version && App.version.start) {
|
||||||
|
App.version.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initApp();
|
initApp();
|
||||||
|
|||||||
138
frontend/js/version.js
Normal file
138
frontend/js/version.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
window.App = window.App || {};
|
||||||
|
App.version = App.version || {};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const VERSION_URL = '/api/version';
|
||||||
|
const POLL_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
// Baseline manifest captured on startup: { version, files: { rel: hash } }.
|
||||||
|
let baseline = null;
|
||||||
|
let timer = null;
|
||||||
|
// Set once a JS/HTML change is detected; the page reloads at a safe moment.
|
||||||
|
let reloadPending = false;
|
||||||
|
let checking = false;
|
||||||
|
|
||||||
|
async function fetchVersion() {
|
||||||
|
const resp = await fetch(VERSION_URL, { cache: 'no-store' });
|
||||||
|
if (!resp.ok) throw new Error('version fetch failed: ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap a stylesheet <link> in place using a cache-busted href so updated CSS
|
||||||
|
// applies instantly. The old link is removed only after the new one loads to
|
||||||
|
// avoid a flash of unstyled content.
|
||||||
|
function hotReloadCss(relPath, hash) {
|
||||||
|
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||||
|
const match = links.find((l) => {
|
||||||
|
const href = (l.getAttribute('href') || '').split('?')[0];
|
||||||
|
return href.endsWith(relPath) || href.endsWith('/' + relPath);
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
const base = (match.getAttribute('href') || '').split('?')[0];
|
||||||
|
const fresh = match.cloneNode(false);
|
||||||
|
fresh.setAttribute('href', base + '?v=' + hash);
|
||||||
|
fresh.addEventListener('load', () => { if (match.parentNode) match.remove(); });
|
||||||
|
fresh.addEventListener('error', () => { if (fresh.parentNode) fresh.remove(); });
|
||||||
|
match.parentNode.insertBefore(fresh, match.nextSibling);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffFiles(oldFiles, newFiles) {
|
||||||
|
const changed = [];
|
||||||
|
const keys = new Set([
|
||||||
|
...Object.keys(oldFiles || {}),
|
||||||
|
...Object.keys(newFiles || {})
|
||||||
|
]);
|
||||||
|
keys.forEach((k) => {
|
||||||
|
if ((oldFiles || {})[k] !== (newFiles || {})[k]) changed.push(k);
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reload is "safe" when the user isn't mid-playback: no open video modal,
|
||||||
|
// no active reels feed, and no playing <video>. App state survives a reload
|
||||||
|
// because it is restored from localStorage on boot.
|
||||||
|
function isSafeToReload() {
|
||||||
|
if (App.state && App.state.feedOpen) return false;
|
||||||
|
const modal = document.getElementById('video-modal');
|
||||||
|
if (modal && modal.style.display && modal.style.display !== 'none') return false;
|
||||||
|
const player = document.getElementById('player');
|
||||||
|
if (player && !player.paused && !player.ended) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateBanner() {
|
||||||
|
const banner = document.getElementById('update-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
banner.classList.add('show');
|
||||||
|
const btn = document.getElementById('update-banner-btn');
|
||||||
|
if (btn) btn.onclick = () => window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryReloadWhenSafe() {
|
||||||
|
if (!reloadPending) return;
|
||||||
|
if (isSafeToReload()) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showUpdateBanner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(latest) {
|
||||||
|
const changed = diffFiles(baseline.files, latest.files);
|
||||||
|
if (!changed.length) return;
|
||||||
|
|
||||||
|
let needsReload = false;
|
||||||
|
changed.forEach((file) => {
|
||||||
|
if (file.endsWith('.css') && hotReloadCss(file, latest.files[file])) {
|
||||||
|
return; // hot-swapped without reload
|
||||||
|
}
|
||||||
|
// JS and HTML can't be safely live-patched; they require a reload.
|
||||||
|
needsReload = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adopt the new manifest so we don't re-trigger on the same change.
|
||||||
|
baseline = latest;
|
||||||
|
|
||||||
|
if (needsReload) {
|
||||||
|
reloadPending = true;
|
||||||
|
tryReloadWhenSafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
if (checking || !baseline) return;
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
const latest = await fetchVersion();
|
||||||
|
apply(latest);
|
||||||
|
} catch (e) {
|
||||||
|
// Network blips are non-fatal; we retry on the next tick.
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
App.version.start = async function() {
|
||||||
|
try {
|
||||||
|
baseline = await fetchVersion();
|
||||||
|
} catch (e) {
|
||||||
|
return; // endpoint unavailable; skip version checking entirely
|
||||||
|
}
|
||||||
|
timer = setInterval(check, POLL_INTERVAL_MS);
|
||||||
|
// Check promptly when the user returns to the tab so updates land while
|
||||||
|
// they were away, and retry a pending reload once playback stops.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
tryReloadWhenSafe();
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Re-attempt a deferred reload whenever a video finishes/pauses.
|
||||||
|
const player = document.getElementById('player');
|
||||||
|
if (player) {
|
||||||
|
player.addEventListener('pause', tryReloadWhenSafe);
|
||||||
|
player.addEventListener('ended', tryReloadWhenSafe);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user