Alta Video Camera Proxy

+
@@ -77,6 +81,30 @@ + + + diff --git a/main.js b/main.js index 0bec52b..6c1179e 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const axios = require('axios'); +const https = require('https'); const { spawn } = require('child_process'); const http = require('http'); @@ -413,3 +414,181 @@ ipcMain.handle('camera-proxy-stop', async (event, { processId }) => { } }); +// --- Self-Update Functionality --- + +// Compare semver versions: returns -1 if a < b, 0 if equal, 1 if a > b +function compareVersions(a, b) { + // Strip pre-release tags (e.g. "1.2.3-beta.1" → "1.2.3") + const cleanA = a.replace(/-.*$/, ''); + const cleanB = b.replace(/-.*$/, ''); + const partsA = cleanA.split('.').map(Number); + const partsB = cleanB.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const numA = partsA[i] || 0; + const numB = partsB[i] || 0; + if (numA < numB) return -1; + if (numA > numB) return 1; + } + return 0; +} + +// Follow HTTPS redirects and return the final response (for GitHub asset downloads) +function httpsGetFollowRedirects(url, callback, redirectCount = 0) { + if (redirectCount >= 5) { + return callback(null, new Error('Too many redirects')); + } + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + return callback(null, new Error('Only HTTPS URLs are allowed')); + } + https.get(url, { headers: { 'User-Agent': 'Alta-Proxy-Tool' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + const redirectUrl = new URL(res.headers.location, url).href; + httpsGetFollowRedirects(redirectUrl, callback, redirectCount + 1); + } else { + callback(res); + } + }).on('error', (err) => { + callback(null, err); + }); +} + +ipcMain.handle('get-current-version', async () => { + return { success: true, version: app.getVersion() }; +}); + +ipcMain.handle('check-for-updates', async () => { + try { + const response = await axios.get( + 'https://api.github.com/repos/PageZ948/Alta-Proxy-Tool/releases/latest', + { + timeout: 10000, + headers: { 'User-Agent': 'Alta-Proxy-Tool', 'Accept': 'application/vnd.github.v3+json' } + } + ); + + const release = response.data; + const latestVersion = release.tag_name.replace(/^v/, ''); + const currentVersion = app.getVersion(); + + const updateAvailable = compareVersions(currentVersion, latestVersion) < 0; + + // Find the portable .exe asset + const exeAsset = release.assets.find(a => /AltaCameraProxy-.*-portable\.exe$/i.test(a.name)); + const downloadUrl = exeAsset ? exeAsset.browser_download_url : null; + + return { + success: true, + updateAvailable, + currentVersion, + latestVersion, + downloadUrl, + releaseNotes: release.body || '', + releaseName: release.name || `v${latestVersion}` + }; + } catch (error) { + if (error.response && error.response.status === 404) { + return { success: true, updateAvailable: false, currentVersion: app.getVersion(), message: 'No releases available yet' }; + } + if (error.response && error.response.status === 403) { + return { success: false, message: 'GitHub API rate limit exceeded. Try again later.' }; + } + console.error('Check for updates error:', error.message); + return { success: false, message: error.message || 'Failed to check for updates' }; + } +}); + +ipcMain.handle('download-and-install-update', async (event, { downloadUrl }) => { + try { + // Determine the path to the currently running executable + const currentExePath = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'); + const currentDir = path.dirname(currentExePath); + const currentExeName = path.basename(currentExePath); + + // Check write permission on the app directory + try { + fs.accessSync(currentDir, fs.constants.W_OK); + } catch { + return { success: false, message: 'No write permission to the application directory. Try running as administrator.' }; + } + + const tempDir = os.tmpdir(); + const tempExePath = path.join(tempDir, `AltaCameraProxy-update-${Date.now()}.exe`); + + // Download the file with progress reporting + await new Promise((resolve, reject) => { + httpsGetFollowRedirects(downloadUrl, (res, err) => { + if (err) return reject(err); + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`Download failed with status ${res.statusCode}`)); + } + + const totalSize = parseInt(res.headers['content-length'], 10) || 0; + let downloadedSize = 0; + const fileStream = fs.createWriteStream(tempExePath); + + res.on('data', (chunk) => { + downloadedSize += chunk.length; + if (totalSize > 0 && mainWindow && !mainWindow.isDestroyed()) { + const percent = Math.round((downloadedSize / totalSize) * 100); + mainWindow.webContents.send('update-download-progress', { percent, downloadedSize, totalSize }); + } + }); + + res.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + + fileStream.on('error', (err) => { + fs.unlink(tempExePath, () => {}); + reject(err); + }); + }); + }); + + // Verify downloaded file size (sanity check: > 10MB for an Electron portable exe) + const stats = fs.statSync(tempExePath); + if (stats.size < 10 * 1024 * 1024) { + fs.unlinkSync(tempExePath); + return { success: false, message: 'Downloaded file is too small — update may be corrupt.' }; + } + + // Create batch script to replace the exe after this process exits + const batchPath = path.join(tempDir, `apt-update-${Date.now()}.bat`); + const pid = process.pid; + const batchContent = `@echo off\r\ntitle APT-Updater\r\necho Waiting for Alta Proxy Tool to close...\r\n:waitloop\r\ntasklist /fi "PID eq ${pid}" 2>nul | find "${pid}" >nul\r\nif not errorlevel 1 (\r\n timeout /t 1 /nobreak >nul\r\n goto waitloop\r\n)\r\necho Applying update...\r\ncopy /y "${tempExePath}" "${path.join(currentDir, currentExeName)}"\r\nif errorlevel 1 (\r\n echo Update failed! Could not copy new version.\r\n pause\r\n del "${tempExePath}" >nul 2>&1\r\n del "%~f0" >nul 2>&1\r\n exit /b 1\r\n)\r\necho Update complete. Launching new version...\r\nstart "" "${path.join(currentDir, currentExeName)}"\r\ndel "${tempExePath}" >nul 2>&1\r\ndel "%~f0" >nul 2>&1\r\n`; + + fs.writeFileSync(batchPath, batchContent); + + // Spawn the updater batch script detached + let updater; + try { + updater = spawn('cmd', ['/c', batchPath], { + detached: true, + stdio: 'ignore', + windowsHide: true + }); + updater.unref(); + } catch (spawnError) { + console.error('Failed to spawn updater:', spawnError); + try { fs.unlinkSync(tempExePath); } catch {} + try { fs.unlinkSync(batchPath); } catch {} + return { success: false, message: 'Failed to start updater process.' }; + } + + // Quit the app after a delay to let the IPC response return to renderer + setTimeout(() => { + app.quit(); + }, 1500); + + return { success: true, message: 'Update is being installed. The app will restart shortly.' }; + } catch (error) { + console.error('Download and install update error:', error); + return { success: false, message: error.message || 'Failed to download and install update' }; + } +}); + diff --git a/preload.js b/preload.js index d0b3531..0dad6df 100644 --- a/preload.js +++ b/preload.js @@ -19,5 +19,19 @@ contextBridge.exposeInMainWorld('electronAPI', { console.error('Extension cookie handler error:', error); } }); + }, + + // Self-update functionality + checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), + downloadAndInstallUpdate: (params) => ipcRenderer.invoke('download-and-install-update', params), + getCurrentVersion: () => ipcRenderer.invoke('get-current-version'), + onUpdateDownloadProgress: (callback) => { + ipcRenderer.on('update-download-progress', (event, data) => { + try { + callback(data); + } catch (error) { + console.error('Update download progress handler error:', error); + } + }); } }); \ No newline at end of file diff --git a/renderer.js b/renderer.js index b9887f9..07df1d0 100644 --- a/renderer.js +++ b/renderer.js @@ -21,10 +21,23 @@ const cookieKey = document.getElementById('cookieKey'); const startCookieProxyBtn = document.getElementById('startCookieProxyBtn'); const stopCookieProxyBtn = document.getElementById('stopCookieProxyBtn'); +// Update elements +const checkUpdateBtn = document.getElementById('checkUpdateBtn'); +const updateModalOverlay = document.getElementById('updateModalOverlay'); +const updateModalCloseBtn = document.getElementById('updateModalCloseBtn'); +const updateModalMessage = document.getElementById('updateModalMessage'); +const updateModalNotes = document.getElementById('updateModalNotes'); +const updateProgressContainer = document.getElementById('updateProgressContainer'); +const updateProgressFill = document.getElementById('updateProgressFill'); +const updateProgressText = document.getElementById('updateProgressText'); +const updateInstallBtn = document.getElementById('updateInstallBtn'); +const updateLaterBtn = document.getElementById('updateLaterBtn'); + // Track selected device let selectedDevice = null; let activeCookieProxyConnections = new Map(); // Track cookie-based proxy connections let allDevices = []; // Store all devices for search functionality +let pendingUpdateInfo = null; // Store update info for install action // Event listeners disconnectBtn.addEventListener('click', handleDisconnect); @@ -39,6 +52,12 @@ cookieKey.addEventListener('input', updateCookieProxyButtonStates); // Device search event listener deviceSearch.addEventListener('input', handleDeviceSearch); +// Update event listeners +checkUpdateBtn.addEventListener('click', handleCheckForUpdates); +updateInstallBtn.addEventListener('click', handleInstallUpdate); +updateLaterBtn.addEventListener('click', closeUpdateModal); +updateModalCloseBtn.addEventListener('click', closeUpdateModal); + // Handle disconnect function handleDisconnect() { sessionData.isConnected = false; @@ -432,6 +451,118 @@ function escapeHtml(text) { return div.innerHTML; } +// --- Self-Update Functions --- + +async function handleCheckForUpdates() { + checkUpdateBtn.disabled = true; + + try { + const result = await window.electronAPI.checkForUpdates(); + + if (!result.success) { + showStatus(connectionStatus, result.message || 'Failed to check for updates', 'error'); + return; + } + + if (result.message === 'No releases available yet') { + showStatus(connectionStatus, 'No releases available yet', 'info'); + return; + } + + if (result.updateAvailable) { + showUpdateModal(result); + } else { + showStatus(connectionStatus, `You're on the latest version (v${result.currentVersion})`, 'success'); + } + } catch (error) { + console.error('Check for updates error:', error); + showStatus(connectionStatus, 'Could not check for updates. Check your internet connection.', 'error'); + } finally { + checkUpdateBtn.disabled = false; + } +} + +function showUpdateModal(updateInfo) { + pendingUpdateInfo = updateInfo; + updateModalMessage.textContent = `A new version is available: v${updateInfo.latestVersion} (current: v${updateInfo.currentVersion})`; + updateModalNotes.textContent = updateInfo.releaseNotes || ''; + + // Reset progress state + updateProgressContainer.style.display = 'none'; + updateProgressFill.style.width = '0%'; + updateProgressText.textContent = '0%'; + + // Reset button states + updateInstallBtn.disabled = !updateInfo.downloadUrl; + updateLaterBtn.disabled = false; + updateInstallBtn.textContent = 'Install Update'; + + if (!updateInfo.downloadUrl) { + updateModalMessage.textContent += '\n(No downloadable asset found for this release)'; + } + + updateModalOverlay.style.display = 'flex'; +} + +async function handleInstallUpdate() { + if (!pendingUpdateInfo || !pendingUpdateInfo.downloadUrl) return; + + // Disable controls during download + updateInstallBtn.disabled = true; + updateInstallBtn.textContent = 'Downloading...'; + updateLaterBtn.disabled = true; + updateModalCloseBtn.style.display = 'none'; + updateProgressContainer.style.display = 'flex'; + + try { + const result = await window.electronAPI.downloadAndInstallUpdate({ + downloadUrl: pendingUpdateInfo.downloadUrl + }); + + if (result.success) { + updateInstallBtn.textContent = 'Restarting...'; + updateProgressFill.style.width = '100%'; + updateProgressText.textContent = '100%'; + } else { + showStatus(connectionStatus, `Update failed: ${result.message}`, 'error'); + closeUpdateModal(); + } + } catch (error) { + console.error('Install update error:', error); + showStatus(connectionStatus, 'Update failed. Please try again.', 'error'); + closeUpdateModal(); + } +} + +function closeUpdateModal() { + updateModalOverlay.style.display = 'none'; + pendingUpdateInfo = null; + updateProgressContainer.style.display = 'none'; + updateProgressFill.style.width = '0%'; + updateProgressText.textContent = '0%'; + updateInstallBtn.textContent = 'Install Update'; + updateInstallBtn.disabled = false; + updateLaterBtn.disabled = false; + updateModalCloseBtn.style.display = ''; +} + +async function checkForUpdatesOnStartup() { + try { + const result = await window.electronAPI.checkForUpdates(); + + if (result.success && result.updateAvailable) { + checkUpdateBtn.classList.add('update-available'); + checkUpdateBtn.title = `Update available: v${result.latestVersion}`; + showStatus(connectionStatus, `Update available: v${result.latestVersion}`, 'info'); + // Store for quick modal open + pendingUpdateInfo = result; + } + } catch (error) { + // Silent fail on startup check + console.log('Startup update check failed:', error.message); + } +} + // Handle cookie received from Chrome extension via local HTTP bridge async function handleExtensionCookie(data) { const { deploymentUrl, cookies, cookieValue } = data; @@ -473,4 +604,13 @@ document.addEventListener('DOMContentLoaded', async () => { // Listen for cookies pushed from Chrome extension window.electronAPI.onExtensionCookie(handleExtensionCookie); + + // Listen for update download progress + window.electronAPI.onUpdateDownloadProgress((data) => { + updateProgressFill.style.width = `${data.percent}%`; + updateProgressText.textContent = `${data.percent}%`; + }); + + // Auto-check for updates after 2 seconds + setTimeout(checkForUpdatesOnStartup, 2000); }); diff --git a/styles.css b/styles.css index 5bfc2fe..46372cc 100644 --- a/styles.css +++ b/styles.css @@ -185,7 +185,9 @@ body { .content-header { margin-bottom: 24px; - text-align: center; + display: flex; + align-items: center; + justify-content: space-between; } .content-header h1 { @@ -424,6 +426,187 @@ button:disabled { animation: pulse 2s infinite; } +/* Update Button */ +.btn-update { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--button-outline); + padding: 6px 12px; + font-size: 12px; + font-weight: bold; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + position: relative; +} + +.btn-update:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.btn-update:hover:not(:disabled) .update-icon { + animation: spin 0.6s ease; +} + +.btn-update:disabled .update-icon { + animation: spin 1s linear infinite; +} + +.btn-update.update-available { + color: var(--success); + border-color: var(--success); + box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); +} + +.btn-update.update-available::after { + content: ''; + position: absolute; + top: -3px; + right: -3px; + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + box-shadow: 0 0 6px rgba(76, 175, 80, 0.8); +} + +.update-icon { + font-size: 14px; + display: inline-block; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Update Modal */ +.update-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.update-modal-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + width: 480px; + max-width: 90%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.update-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.update-modal-header h3 { + font-size: 16px; + color: var(--text-primary); + margin: 0; +} + +.update-modal-close { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.update-modal-close:hover { + color: var(--text-primary); +} + +.update-modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.update-modal-message { + font-size: 14px; + color: var(--text-primary); + margin: 0 0 12px 0; +} + +.update-modal-notes { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + padding: 12px; + background: var(--bg-primary); + border-radius: 4px; + border: 1px solid var(--border); +} + +.update-modal-notes:empty { + display: none; +} + +.update-progress-container { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.update-progress-track { + flex: 1; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border); +} + +.update-progress-fill { + height: 100%; + background: var(--accent-primary); + border-radius: 4px; + transition: width 0.3s ease; +} + +.update-progress-text { + font-size: 12px; + font-weight: bold; + color: var(--text-secondary); + min-width: 36px; + text-align: right; +} + +.update-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + /* Responsive Design */ @media (max-width: 768px) { .devices-sidebar {