FAB overlaps PWA install prompt and update notifications on small screens. Mobile users now access feedback via the navbar drawer instead. Install prompt dismissal persists in localStorage and is skipped in standalone mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
429 lines
14 KiB
JavaScript
429 lines
14 KiB
JavaScript
/**
|
|
* Tractatus Version Manager
|
|
* - Registers service worker
|
|
* - Checks for updates every hour
|
|
* - Shows update notifications
|
|
* - Manages PWA install prompts
|
|
*/
|
|
|
|
class VersionManager {
|
|
constructor() {
|
|
this.serviceWorker = null;
|
|
this.deferredInstallPrompt = null;
|
|
this.updateCheckInterval = null;
|
|
this.currentVersion = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
// Only run in browsers that support service workers
|
|
if (!('serviceWorker' in navigator)) {
|
|
console.log('[VersionManager] Service workers not supported');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Register service worker
|
|
await this.registerServiceWorker();
|
|
|
|
// Check for updates immediately
|
|
await this.checkForUpdates();
|
|
|
|
// Check for updates every hour
|
|
this.updateCheckInterval = setInterval(() => {
|
|
this.checkForUpdates();
|
|
}, 3600000); // 1 hour
|
|
|
|
// Listen for PWA install prompt
|
|
this.setupInstallPrompt();
|
|
|
|
// Listen for service worker messages
|
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
if (event.data.type === 'UPDATE_AVAILABLE') {
|
|
this.showUpdateNotification(event.data);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[VersionManager] Initialization failed:', error);
|
|
}
|
|
}
|
|
|
|
async registerServiceWorker() {
|
|
try {
|
|
// Cache-bust service worker to force update: v0.1.5 nuclear reset
|
|
const registration = await navigator.serviceWorker.register('/service-worker.js?v=0.1.6');
|
|
this.serviceWorker = registration;
|
|
console.log('[VersionManager] Service worker registered');
|
|
|
|
// Check for updates when service worker updates
|
|
registration.addEventListener('updatefound', () => {
|
|
const newWorker = registration.installing;
|
|
newWorker.addEventListener('statechange', () => {
|
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
// New service worker available
|
|
this.showUpdateNotification({
|
|
updateAvailable: true,
|
|
currentVersion: this.currentVersion,
|
|
serverVersion: 'latest'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[VersionManager] Service worker registration failed:', error);
|
|
}
|
|
}
|
|
|
|
async checkForUpdates() {
|
|
try {
|
|
const response = await fetch('/version.json', { cache: 'no-store' });
|
|
const versionInfo = await response.json();
|
|
|
|
// Get current version from localStorage or default
|
|
const storedVersion = localStorage.getItem('tractatus_version') || '0.0.0';
|
|
this.currentVersion = storedVersion;
|
|
|
|
if (storedVersion !== versionInfo.version) {
|
|
console.log('[VersionManager] Update available:', versionInfo.version);
|
|
this.showUpdateNotification({
|
|
updateAvailable: true,
|
|
currentVersion: storedVersion,
|
|
serverVersion: versionInfo.version,
|
|
changelog: versionInfo.changelog,
|
|
forceUpdate: versionInfo.forceUpdate
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[VersionManager] Version check failed:', error);
|
|
}
|
|
}
|
|
|
|
showUpdateNotification(versionInfo) {
|
|
// Don't show if notification already visible
|
|
if (document.getElementById('tractatus-update-notification')) {
|
|
return;
|
|
}
|
|
|
|
const notification = document.createElement('div');
|
|
notification.id = 'tractatus-update-notification';
|
|
notification.className = 'fixed bottom-0 left-0 right-0 bg-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
notification.innerHTML = `
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
<div class="flex items-start flex-1">
|
|
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold">Update Available</p>
|
|
<p class="text-sm text-blue-100">
|
|
A new version of Tractatus Framework is available
|
|
${versionInfo.serverVersion ? `(v${versionInfo.serverVersion})` : ''}
|
|
</p>
|
|
${versionInfo.changelog ? `
|
|
<details class="mt-2 text-sm">
|
|
<summary class="cursor-pointer text-blue-100 hover:text-white">What's new?</summary>
|
|
<ul class="mt-2 ml-4 list-disc text-blue-100">
|
|
${versionInfo.changelog.map(item => `<li>${item}</li>`).join('')}
|
|
</ul>
|
|
</details>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
${versionInfo.forceUpdate ? `
|
|
<button id="update-now-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
|
|
Update Now
|
|
</button>
|
|
` : `
|
|
<button id="update-later-btn" class="text-white hover:text-blue-100 transition px-3">
|
|
Later
|
|
</button>
|
|
<button id="update-reload-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
|
|
Reload
|
|
</button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Add event listeners (CSP compliant)
|
|
const updateNowBtn = document.getElementById('update-now-btn');
|
|
const updateLaterBtn = document.getElementById('update-later-btn');
|
|
const updateReloadBtn = document.getElementById('update-reload-btn');
|
|
|
|
if (updateNowBtn) {
|
|
updateNowBtn.addEventListener('click', () => this.applyUpdate());
|
|
}
|
|
if (updateLaterBtn) {
|
|
updateLaterBtn.addEventListener('click', () => this.dismissUpdate());
|
|
}
|
|
if (updateReloadBtn) {
|
|
updateReloadBtn.addEventListener('click', () => this.applyUpdate());
|
|
}
|
|
|
|
// Animate in
|
|
setTimeout(() => {
|
|
notification.classList.remove('translate-y-full');
|
|
}, 100);
|
|
|
|
// Auto-reload for forced updates after 10 seconds
|
|
if (versionInfo.forceUpdate) {
|
|
setTimeout(() => {
|
|
this.applyUpdate();
|
|
}, 10000);
|
|
}
|
|
}
|
|
|
|
applyUpdate() {
|
|
// Store new version
|
|
fetch('/version.json', { cache: 'no-store' })
|
|
.then(response => response.json())
|
|
.then(versionInfo => {
|
|
localStorage.setItem('tractatus_version', versionInfo.version);
|
|
|
|
// Tell service worker to skip waiting
|
|
if (this.serviceWorker) {
|
|
this.serviceWorker.waiting?.postMessage({ type: 'SKIP_WAITING' });
|
|
}
|
|
|
|
// Reload page
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
dismissUpdate() {
|
|
const notification = document.getElementById('tractatus-update-notification');
|
|
if (notification) {
|
|
notification.classList.add('translate-y-full');
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
setupInstallPrompt() {
|
|
// Skip entirely if already running as installed PWA
|
|
if (window.matchMedia('(display-mode: standalone)').matches ||
|
|
window.navigator.standalone === true) {
|
|
return;
|
|
}
|
|
|
|
// Listen for beforeinstallprompt event
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
e.preventDefault();
|
|
|
|
// Stash the event so it can be triggered later
|
|
this.deferredInstallPrompt = e;
|
|
|
|
// Check if user has dismissed install prompt before
|
|
const dismissed = localStorage.getItem('tractatus_install_dismissed');
|
|
if (!dismissed) {
|
|
// Show install prompt after 30 seconds
|
|
setTimeout(() => {
|
|
this.showInstallPrompt();
|
|
}, 30000);
|
|
}
|
|
});
|
|
|
|
// Detect if app was installed
|
|
window.addEventListener('appinstalled', () => {
|
|
console.log('[VersionManager] PWA installed');
|
|
this.deferredInstallPrompt = null;
|
|
localStorage.setItem('tractatus_install_dismissed', 'true');
|
|
|
|
// Hide install prompt if visible
|
|
const prompt = document.getElementById('tractatus-install-prompt');
|
|
if (prompt) {
|
|
prompt.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
showInstallPrompt() {
|
|
if (!this.deferredInstallPrompt) {
|
|
return;
|
|
}
|
|
|
|
// Don't show if already installed or on iOS Safari (handles differently)
|
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
|
return;
|
|
}
|
|
|
|
const prompt = document.createElement('div');
|
|
prompt.id = 'tractatus-install-prompt';
|
|
prompt.className = 'fixed bottom-0 left-0 right-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
prompt.innerHTML = `
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
<div class="flex items-start flex-1">
|
|
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold">Install Tractatus App</p>
|
|
<p class="text-sm text-purple-100">
|
|
Add to your home screen for quick access and offline support
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button id="dismiss-install-btn" class="text-white hover:text-purple-100 transition px-3">
|
|
Not Now
|
|
</button>
|
|
<button id="install-app-btn" class="bg-white text-purple-600 px-6 py-2 rounded-lg font-semibold hover:bg-purple-50 transition">
|
|
Install
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(prompt);
|
|
|
|
// Add event listeners (CSP compliant)
|
|
const dismissBtn = document.getElementById('dismiss-install-btn');
|
|
const installBtn = document.getElementById('install-app-btn');
|
|
|
|
if (dismissBtn) {
|
|
dismissBtn.addEventListener('click', () => this.dismissInstallPrompt());
|
|
}
|
|
if (installBtn) {
|
|
installBtn.addEventListener('click', () => this.installApp());
|
|
}
|
|
|
|
// Animate in
|
|
setTimeout(() => {
|
|
prompt.classList.remove('translate-y-full');
|
|
}, 100);
|
|
}
|
|
|
|
async installApp() {
|
|
if (!this.deferredInstallPrompt) {
|
|
// Show helpful feedback if installation isn't available
|
|
this.showInstallUnavailableMessage();
|
|
return;
|
|
}
|
|
|
|
// Show the install prompt
|
|
this.deferredInstallPrompt.prompt();
|
|
|
|
// Wait for the user to respond to the prompt
|
|
const { outcome } = await this.deferredInstallPrompt.userChoice;
|
|
console.log(`[VersionManager] User response: ${outcome}`);
|
|
|
|
// Clear the deferredInstallPrompt
|
|
this.deferredInstallPrompt = null;
|
|
|
|
// Hide the prompt
|
|
this.dismissInstallPrompt();
|
|
}
|
|
|
|
showInstallUnavailableMessage() {
|
|
// Check if app is already installed
|
|
const isInstalled = window.matchMedia('(display-mode: standalone)').matches;
|
|
|
|
// Don't show message if it already exists
|
|
if (document.getElementById('tractatus-install-unavailable')) {
|
|
return;
|
|
}
|
|
|
|
const message = document.createElement('div');
|
|
message.id = 'tractatus-install-unavailable';
|
|
message.className = 'fixed bottom-0 left-0 right-0 bg-gray-800 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
|
|
if (isInstalled) {
|
|
message.innerHTML = `
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
<div class="flex items-start flex-1">
|
|
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold">Already Installed</p>
|
|
<p class="text-sm text-gray-300">
|
|
Tractatus is already installed on your device. You're using it right now!
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
|
|
Okay
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
message.innerHTML = `
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
<div class="flex items-start flex-1">
|
|
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold">Installation Not Available</p>
|
|
<p class="text-sm text-gray-300">
|
|
Your browser doesn't currently support app installation. Try using Chrome, Edge, or Safari on a supported device.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
|
|
Okay
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
document.body.appendChild(message);
|
|
|
|
// Add event listener for dismiss button
|
|
const dismissBtn = document.getElementById('dismiss-unavailable-btn');
|
|
if (dismissBtn) {
|
|
dismissBtn.addEventListener('click', () => {
|
|
message.classList.add('translate-y-full');
|
|
setTimeout(() => {
|
|
message.remove();
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// Animate in
|
|
setTimeout(() => {
|
|
message.classList.remove('translate-y-full');
|
|
}, 100);
|
|
|
|
// Auto-dismiss after 8 seconds
|
|
setTimeout(() => {
|
|
if (message.parentElement) {
|
|
message.classList.add('translate-y-full');
|
|
setTimeout(() => {
|
|
message.remove();
|
|
}, 300);
|
|
}
|
|
}, 8000);
|
|
}
|
|
|
|
dismissInstallPrompt() {
|
|
const prompt = document.getElementById('tractatus-install-prompt');
|
|
if (prompt) {
|
|
prompt.classList.add('translate-y-full');
|
|
setTimeout(() => {
|
|
prompt.remove();
|
|
}, 300);
|
|
}
|
|
|
|
// Remember dismissal persistently across sessions
|
|
localStorage.setItem('tractatus_install_dismissed', 'true');
|
|
}
|
|
}
|
|
|
|
// Initialize version manager on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.versionManager = new VersionManager();
|
|
});
|
|
} else {
|
|
window.versionManager = new VersionManager();
|
|
}
|