diff --git a/desktop/preload.js b/desktop/preload.js index ae9d90e..dfcc665 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -3,9 +3,15 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text), getConfig: () => ipcRenderer.invoke('get-config'), + storageGet: (key) => ipcRenderer.invoke('storage-get', key), + storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), + storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), + getUpdateState: () => ipcRenderer.invoke('get-update-state'), + checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'), checkForUpdates: () => ipcRenderer.send('check-for-updates'), downloadUpdate: () => ipcRenderer.send('download-update'), quitAndInstall: () => ipcRenderer.send('quit-and-install'), + onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)), onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) diff --git a/main.js b/main.js index 0f0878d..009d449 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,82 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); +const fs = require('fs'); const url = require('url'); +const updateState = { + status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error + info: null, + error: null, +}; + +function broadcastUpdateState() { + const wins = BrowserWindow.getAllWindows(); + for (const win of wins) { + if (!win.isDestroyed()) { + win.webContents.send('update-state', updateState); + } + } +} + +function getStorageFilePath() { + return path.join(app.getPath('userData'), 'renderer-storage.json'); +} + +function readPersistentStore() { + const filePath = getStorageFilePath(); + try { + if (!fs.existsSync(filePath)) return {}; + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (err) { + console.error('Failed to read persistent store', err); + return {}; + } +} + +function writePersistentStore(store) { + const filePath = getStorageFilePath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); + } catch (err) { + console.error('Failed to write persistent store', err); + } +} + +function isNewerVersionAvailable(info) { + const next = info && typeof info.version === 'string' ? info.version : ''; + return Boolean(next) && next !== app.getVersion(); +} + +async function runUpdateCheck(reason = 'manual') { + updateState.status = 'checking'; + updateState.error = null; + broadcastUpdateState(); + try { + const result = await autoUpdater.checkForUpdates(); + const info = result && result.updateInfo ? result.updateInfo : null; + if (isNewerVersionAvailable(info)) { + updateState.status = 'available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); + } else { + updateState.status = 'not-available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); + } + } catch (err) { + updateState.status = 'error'; + updateState.error = err && err.message ? err.message : String(err); + broadcastUpdateState(); + console.error(`Update check failed (${reason})`, err); + } +} + function createWindow() { // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); @@ -34,6 +108,34 @@ function createWindow() { return true; }); + ipcMain.handle('storage-get', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return null; + const store = readPersistentStore(); + return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; + }); + + ipcMain.handle('storage-set', (event, key, value) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + store[key] = value; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('storage-remove', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + delete store[key]; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('get-update-state', () => ({ ...updateState })); + ipcMain.handle('check-for-updates-now', async () => { + await runUpdateCheck('renderer-direct'); + return { ...updateState }; + }); + // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { @@ -112,6 +214,14 @@ function createWindow() { loadDesktopApp(); + // Ensure renderer receives latest updater state after any (re)load. + win.webContents.on('did-finish-load', () => { + broadcastUpdateState(); + // Login flow swaps pages; force a check after desktop UI reloads so + // renderer always gets fresh updater state. + void runUpdateCheck('did-finish-load'); + }); + // Uncomment to debug // win.webContents.openDevTools(); } @@ -127,27 +237,46 @@ app.whenReady().then(() => { autoUpdater.logger = console; autoUpdater.on('update-available', (info) => { + updateState.status = 'available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-available', info); }); + autoUpdater.on('update-not-available', (info) => { + updateState.status = 'not-available'; + updateState.info = info || null; + updateState.error = null; + broadcastUpdateState(); + }); + autoUpdater.on('update-downloaded', (info) => { + updateState.status = 'downloaded'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-downloaded', info); }); autoUpdater.on('error', (err) => { + updateState.status = 'error'; + updateState.error = err.message; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-error', err.message); }); ipcMain.on('check-for-updates', () => { - autoUpdater.checkForUpdatesAndNotify().catch(err => { - console.error("Manual update check failed", err); - }); + void runUpdateCheck('manual'); }); ipcMain.on('download-update', () => { + updateState.status = 'downloading'; + updateState.error = null; + broadcastUpdateState(); autoUpdater.downloadUpdate(); }); @@ -157,7 +286,7 @@ app.whenReady().then(() => { // Check once on startup setTimeout(() => { - autoUpdater.checkForUpdatesAndNotify().catch(() => { }); + void runUpdateCheck('startup'); }, 5000); app.on('activate', () => { diff --git a/static/shared/app-core.js b/static/shared/app-core.js index 2b5fa49..97d405d 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -40,6 +40,80 @@ const state = { // --- Desktop Backend Configuration --- let API_BASE_URL = ''; // Will be initialized via IPC +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; +const PERSISTED_STORAGE_KEYS = [ + "chattz_token", + "chattz_refresh_token", + LAST_GUILD_STORAGE_KEY, + "active_voice_channel", +]; + +function storageGet(key) { + return localStorage.getItem(key); +} + +function storageSet(key, value) { + localStorage.setItem(key, value); + if (window.electronAPI?.storageSet) { + void window.electronAPI.storageSet(key, value).catch((err) => { + console.warn("Failed to persist storage key", key, err); + }); + } +} + +function storageRemove(key) { + localStorage.removeItem(key); + if (window.electronAPI?.storageRemove) { + void window.electronAPI.storageRemove(key).catch((err) => { + console.warn("Failed to remove persisted storage key", key, err); + }); + } +} + +async function storageSetCritical(key, value) { + localStorage.setItem(key, value); + if (window.electronAPI?.storageSet) { + try { + await window.electronAPI.storageSet(key, value); + } catch (err) { + console.warn("Failed to persist critical storage key", key, err); + } + } +} + +async function storageRemoveCritical(key) { + localStorage.removeItem(key); + if (window.electronAPI?.storageRemove) { + try { + await window.electronAPI.storageRemove(key); + } catch (err) { + console.warn("Failed to remove critical storage key", key, err); + } + } +} + +async function hydrateDesktopStorage() { + if (!window.electronAPI?.storageGet) return; + for (const key of PERSISTED_STORAGE_KEYS) { + const localVal = localStorage.getItem(key); + if (localVal !== null) { + try { + await window.electronAPI.storageSet(key, localVal); + } catch (err) { + console.warn("Failed to backfill storage key", key, err); + } + continue; + } + try { + const val = await window.electronAPI.storageGet(key); + if (typeof val === "string") { + localStorage.setItem(key, val); + } + } catch (err) { + console.warn("Failed to hydrate storage key", key, err); + } + } +} async function initializeConfig() { try { @@ -73,7 +147,7 @@ function getWsUrl(path) { urlStr = `${proto}://${url.host}${normalizedPath}`; } - const token = localStorage.getItem("chattz_token"); + const token = storageGet("chattz_token"); if (token) { urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; } @@ -180,7 +254,7 @@ async function api(path, options = {}) { ...(options.headers || {}), }; - const token = localStorage.getItem("chattz_token"); + const token = storageGet("chattz_token"); if (token) { headers["Authorization"] = `Bearer ${token}`; } @@ -192,7 +266,7 @@ async function api(path, options = {}) { // Handle 401 Unauthorized via Refresh Token if (res.status === 401 && !path.includes('/auth/refresh')) { - const refreshToken = localStorage.getItem("chattz_refresh_token"); + const refreshToken = storageGet("chattz_refresh_token"); if (refreshToken) { try { const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; @@ -204,8 +278,8 @@ async function api(path, options = {}) { if (refreshRes.ok) { const newTokens = await refreshRes.json(); - localStorage.setItem("chattz_token", newTokens.access_token); - localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + await storageSetCritical("chattz_token", newTokens.access_token); + await storageSetCritical("chattz_refresh_token", newTokens.refresh_token); // Retry original request with new token headers["Authorization"] = `Bearer ${newTokens.access_token}`; @@ -215,8 +289,8 @@ async function api(path, options = {}) { throw new Error("Refresh token expired or invalid"); } } catch (err) { - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); + await storageRemoveCritical("chattz_token"); + await storageRemoveCritical("chattz_refresh_token"); location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; throw new Error("Session expired, please log in again"); } @@ -330,8 +404,6 @@ function playSound(type) { console.warn("playSound failed", err); } } -const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; - // --- Renderers --- function renderGuilds() { @@ -348,7 +420,7 @@ function renderGuilds() { state.selectedVoiceChannelId = null; state.selectedDmUserId = null; state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); renderDMs(); await loadChannels(); @@ -1258,7 +1330,7 @@ async function joinVoice() { state.voice.ws = ws; state.voice.joinedChannelId = state.selectedVoiceChannelId; - localStorage.setItem("active_voice_channel", state.selectedVoiceChannelId); + storageSet("active_voice_channel", state.selectedVoiceChannelId); // Start mic setup in parallel so channel join is not blocked by device init. createLocalVoiceStream() @@ -1390,7 +1462,7 @@ async function joinVoice() { } async function leaveVoice() { - localStorage.removeItem("active_voice_channel"); + storageRemove("active_voice_channel"); if (state.voice.ws) state.voice.ws.close(); } @@ -1612,6 +1684,7 @@ function closeMobileMenus() { let inactivityTimer = null; let isCurrentlyIdle = false; +let updaterInitialized = false; function resetInactivityTimer() { if (isCurrentlyIdle) { @@ -1636,6 +1709,8 @@ document.addEventListener('click', resetInactivityTimer); async function init() { lucide.createIcons(); + await hydrateDesktopStorage(); + initUpdater(); // 1. Handle tokens in URL (from successful login redirects) try { @@ -1644,8 +1719,8 @@ async function init() { const refreshToken = searchParams.get("refresh_token"); if (jwtToken || refreshToken) { - if (jwtToken) localStorage.setItem("chattz_token", jwtToken); - if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); + if (jwtToken) await storageSetCritical("chattz_token", jwtToken); + if (refreshToken) await storageSetCritical("chattz_refresh_token", refreshToken); searchParams.delete("token"); searchParams.delete("refresh_token"); @@ -1665,8 +1740,8 @@ async function init() { el.logoutBtn.onclick = async () => { await leaveVoice(); - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); + await storageRemoveCritical("chattz_token"); + await storageRemoveCritical("chattz_refresh_token"); try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); }; @@ -1705,7 +1780,7 @@ async function init() { state.selectedVoiceChannelId = null; state.selectedDmUserId = null; state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); await loadChannels(); await loadGuildMembers(); @@ -1889,7 +1964,7 @@ async function init() { if (inviteCode) { try { const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { method: "POST" }); - localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); + storageSet(LAST_GUILD_STORAGE_KEY, joinedGuild.id); } catch (err) { alert(`Failed to join invite: ${err.message}`); } finally { params.delete("invite"); const nextQuery = params.toString(); @@ -1908,10 +1983,10 @@ async function init() { } catch (err) { console.warn("presence sync failed", err); } if (state.guilds.length > 0) { - const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const lastGuildId = storageGet(LAST_GUILD_STORAGE_KEY); const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; state.selectedGuildId = guild.id; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); await loadChannels(); await loadGuildMembers(); @@ -1927,7 +2002,7 @@ async function init() { startVoicePresencePolling(); // Auto-reconnect to voice if previously joined - const savedVoiceChannel = localStorage.getItem("active_voice_channel"); + const savedVoiceChannel = storageGet("active_voice_channel"); if (savedVoiceChannel) { console.log("Auto-reconnecting to voice channel:", savedVoiceChannel); state.selectedVoiceChannelId = savedVoiceChannel; @@ -1936,7 +2011,6 @@ async function init() { }, 800); } - initUpdater(); lucide.createIcons(); } catch (err) { console.error("init failed", err); @@ -1947,6 +2021,53 @@ async function init() { function initUpdater() { if (!window.electronAPI || !el.updateNotifier || !el.updateDownloadBtn || !el.updateInstallBtn) return; + if (updaterInitialized) return; + updaterInitialized = true; + + const applyUpdateState = (state) => { + if (!state || !state.status) return; + if (state.status === 'available') { + const v = state.info?.version ? ` ${state.info.version}` : ''; + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.remove("hidden"); + el.updateInstallBtn.classList.add("hidden"); + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + el.updateDownloadBtn.title = `Download Update${v}`; + return; + } + if (state.status === 'downloading') { + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.remove("hidden"); + el.updateInstallBtn.classList.add("hidden"); + el.updateDownloadBtn.disabled = true; + el.updateDownloadBtn.style.opacity = "0.5"; + el.updateDownloadBtn.title = "Downloading update..."; + return; + } + if (state.status === 'downloaded') { + const v = state.info?.version ? ` ${state.info.version}` : ''; + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.add("hidden"); + el.updateInstallBtn.classList.remove("hidden"); + el.updateInstallBtn.title = `Install Update${v}`; + return; + } + if (state.status === 'not-available') { + el.updateNotifier.classList.add("hidden"); + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + return; + } + if (state.status === 'error') { + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + } + }; + + window.electronAPI.onUpdateState((state) => { + applyUpdateState(state); + }); window.electronAPI.onUpdateAvailable((info) => { console.log("Update available:", info.version); @@ -1978,6 +2099,20 @@ function initUpdater() { el.updateInstallBtn.onclick = () => { window.electronAPI.quitAndInstall(); }; + + // Recover missed startup events and then run a fresh check now that listeners exist. + window.electronAPI.getUpdateState?.() + .then((state) => applyUpdateState(state)) + .catch((err) => console.warn("Failed to fetch initial update state", err)) + .finally(() => { + if (window.electronAPI.checkForUpdatesNow) { + void window.electronAPI.checkForUpdatesNow() + .then((state) => applyUpdateState(state)) + .catch((err) => console.warn("Direct update check failed", err)); + } else { + window.electronAPI.checkForUpdates?.(); + } + }); } // Config fetcher will trigger init()