checkpoint

This commit is contained in:
pavel 2026-02-27 10:57:13 +01:00
commit 208ab8042e
3 changed files with 296 additions and 26 deletions

View file

@ -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))

137
main.js
View file

@ -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', () => {

View file

@ -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()