feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
This commit is contained in:
19
apps/electron/index.html
Normal file
19
apps/electron/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>discord</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('discord-theme') || 'theme-dark';
|
||||
document.documentElement.className = t;
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
623
apps/electron/main.cjs
Normal file
623
apps/electron/main.cjs
Normal file
@@ -0,0 +1,623 @@
|
||||
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// --- Secure session persistence ---
|
||||
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { checkForUpdates } = require('./updater.cjs');
|
||||
|
||||
// --- Settings persistence ---
|
||||
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
||||
const DEFAULT_SETTINGS = {
|
||||
windowX: undefined,
|
||||
windowY: undefined,
|
||||
windowWidth: 1200,
|
||||
windowHeight: 800,
|
||||
isMaximized: false,
|
||||
theme: 'theme-dark',
|
||||
};
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
try {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function isPositionOnScreen(x, y, w, h) {
|
||||
const displays = screen.getAllDisplays();
|
||||
const MIN_OVERLAP = 100;
|
||||
return displays.some(display => {
|
||||
const { x: dx, y: dy, width: dw, height: dh } = display.bounds;
|
||||
const overlapX = Math.max(0, Math.min(x + w, dx + dw) - Math.max(x, dx));
|
||||
const overlapY = Math.max(0, Math.min(y + h, dy + dh) - Math.max(y, dy));
|
||||
return overlapX >= MIN_OVERLAP && overlapY >= MIN_OVERLAP;
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const settings = loadSettings();
|
||||
|
||||
const windowOptions = {
|
||||
width: settings.windowWidth,
|
||||
height: settings.windowHeight,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
sandbox: false
|
||||
}
|
||||
};
|
||||
|
||||
// Only restore position if it's valid on a connected display
|
||||
if (settings.windowX !== undefined && settings.windowY !== undefined &&
|
||||
isPositionOnScreen(settings.windowX, settings.windowY, settings.windowWidth, settings.windowHeight)) {
|
||||
windowOptions.x = settings.windowX;
|
||||
windowOptions.y = settings.windowY;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
if (settings.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Save window state on close
|
||||
mainWindow.on('close', () => {
|
||||
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||
mainWindow.webContents.session.flushStorageData();
|
||||
|
||||
const current = loadSettings(); // re-read to preserve theme changes
|
||||
if (!mainWindow.isMaximized()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
current.windowX = bounds.x;
|
||||
current.windowY = bounds.y;
|
||||
current.windowWidth = bounds.width;
|
||||
current.windowHeight = bounds.height;
|
||||
}
|
||||
current.isMaximized = mainWindow.isMaximized();
|
||||
saveSettings(current);
|
||||
});
|
||||
|
||||
const isDev = process.env.npm_lifecycle_event === 'electron:dev';
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_URL || 'http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// Production: Load the built file
|
||||
mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
function createSplashWindow() {
|
||||
const splash = new BrowserWindow({
|
||||
width: 300,
|
||||
height: 350,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
splash.loadFile(path.join(__dirname, 'splash.html'));
|
||||
return splash;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
createWindow();
|
||||
} else {
|
||||
const splash = createSplashWindow();
|
||||
const noUpdate = await checkForUpdates(splash);
|
||||
if (noUpdate === false) {
|
||||
if (!splash.isDestroyed()) splash.close();
|
||||
createWindow();
|
||||
}
|
||||
// If update downloaded, quitAndInstall handles restart
|
||||
}
|
||||
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) win.minimize();
|
||||
});
|
||||
ipcMain.on('window-maximize', () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) {
|
||||
if (win.isMaximized()) win.unmaximize();
|
||||
else win.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on('window-close', () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) win.close();
|
||||
});
|
||||
|
||||
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
||||
|
||||
const OEMBED_PROVIDERS = {
|
||||
'twitter.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
|
||||
'x.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
|
||||
'youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'www.youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'youtu.be': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'open.spotify.com': (url) => `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'www.tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
|
||||
'www.reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
'reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
|
||||
};
|
||||
|
||||
const FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/1.0)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_SIZE = 256 * 1024; // 256KB
|
||||
const FETCH_TIMEOUT = 8000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function httpGet(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(url, { headers: FETCH_HEADERS, timeout: FETCH_TIMEOUT }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirectsLeft <= 0) { resolve(''); return; }
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith('/')) {
|
||||
const parsed = new URL(url);
|
||||
redirectUrl = parsed.origin + redirectUrl;
|
||||
}
|
||||
res.resume();
|
||||
httpGet(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
let size = 0;
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
size += Buffer.byteLength(chunk);
|
||||
if (size > MAX_RESPONSE_SIZE) { res.destroy(); resolve(data); return; }
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', () => resolve(data));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve(''); });
|
||||
req.on('error', (err) => { console.error('httpGet error:', err.message); resolve(''); });
|
||||
});
|
||||
}
|
||||
|
||||
function fetchJson(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(url, { headers: { 'User-Agent': FETCH_HEADERS['User-Agent'], 'Accept': 'application/json' }, timeout: FETCH_TIMEOUT }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirectsLeft <= 0) { resolve(null); return; }
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith('/')) {
|
||||
const parsed = new URL(url);
|
||||
redirectUrl = parsed.origin + redirectUrl;
|
||||
}
|
||||
res.resume();
|
||||
fetchJson(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
|
||||
res.on('error', () => resolve(null));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve(null); });
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
function parseMetaTags(html) {
|
||||
const meta = {};
|
||||
const metaRegex = /<meta\s+([^>]*?)\/?\s*>/gi;
|
||||
let match;
|
||||
while ((match = metaRegex.exec(html)) !== null) {
|
||||
const attrs = match[1];
|
||||
let name = null, content = null;
|
||||
const propMatch = attrs.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i);
|
||||
const contentMatch = attrs.match(/content\s*=\s*["']([^"']*?)["']/i);
|
||||
if (propMatch) name = propMatch[1].toLowerCase();
|
||||
if (contentMatch) content = contentMatch[1];
|
||||
if (name && content !== null) meta[name] = content;
|
||||
}
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (titleMatch) meta['_title'] = titleMatch[1].trim();
|
||||
return meta;
|
||||
}
|
||||
|
||||
function buildMetadata(meta) {
|
||||
const get = (...keys) => { for (const k of keys) { if (meta[k]) return meta[k]; } return null; };
|
||||
return {
|
||||
title: get('og:title', 'twitter:title', '_title'),
|
||||
description: get('og:description', 'twitter:description', 'description'),
|
||||
image: get('og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'),
|
||||
siteName: get('og:site_name'),
|
||||
themeColor: get('theme-color'),
|
||||
video: get('og:video:secure_url', 'og:video:url', 'og:video'),
|
||||
type: get('og:type', 'twitter:card'),
|
||||
author: get('article:author', 'author'),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMetadata(metadata) {
|
||||
const decodeEntities = (str) => {
|
||||
if (!str) return str;
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
|
||||
};
|
||||
const stripTags = (str) => str ? str.replace(/<[^>]*>/g, '') : str;
|
||||
const limit = (str, max = 1000) => str && str.length > max ? str.substring(0, max) : str;
|
||||
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (typeof value === 'string') {
|
||||
result[key] = limit(decodeEntities(stripTags(value)).trim());
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ipcMain.handle('fetch-metadata', async (event, url) => {
|
||||
try {
|
||||
// Check for direct video links
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
|
||||
const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp'];
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) {
|
||||
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
|
||||
}
|
||||
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
|
||||
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
|
||||
}
|
||||
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, '');
|
||||
const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
|
||||
const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
|
||||
|
||||
let metadata;
|
||||
|
||||
if (oembedUrl) {
|
||||
const [oembedResult, htmlResult] = await Promise.allSettled([
|
||||
fetchJson(oembedUrl),
|
||||
httpGet(url),
|
||||
]);
|
||||
|
||||
const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null;
|
||||
const html = htmlResult.status === 'fulfilled' ? htmlResult.value : '';
|
||||
const meta = html ? parseMetaTags(html) : {};
|
||||
const ogData = buildMetadata(meta);
|
||||
|
||||
metadata = {
|
||||
title: oembed?.title || ogData.title,
|
||||
description: ogData.description,
|
||||
image: oembed?.thumbnail_url || ogData.image,
|
||||
siteName: oembed?.provider_name || ogData.siteName,
|
||||
themeColor: ogData.themeColor,
|
||||
video: ogData.video,
|
||||
type: ogData.type,
|
||||
author: oembed?.author_name || ogData.author,
|
||||
};
|
||||
} else {
|
||||
const html = await httpGet(url);
|
||||
if (!html) return null;
|
||||
const meta = parseMetaTags(html);
|
||||
metadata = buildMetadata(meta);
|
||||
}
|
||||
|
||||
return sanitizeMetadata(metadata);
|
||||
} catch (err) {
|
||||
console.error('Metadata fetch error:', err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Flatpak update check
|
||||
ipcMain.handle('check-flatpak-update', async () => {
|
||||
const isFlatpak = fs.existsSync('/.flatpak-info') || !!process.env.FLATPAK_ID;
|
||||
if (!isFlatpak) return { isFlatpak: false };
|
||||
|
||||
try {
|
||||
const yaml = await httpGet('https://gitea.moyettes.com/Moyettes/DiscordClone/releases/download/latest/latest-linux.yml');
|
||||
if (!yaml) return { isFlatpak: true, updateAvailable: false };
|
||||
|
||||
const versionMatch = yaml.match(/^version:\s*(.+)$/m);
|
||||
if (!versionMatch) return { isFlatpak: true, updateAvailable: false };
|
||||
|
||||
const latestVersion = versionMatch[1].trim();
|
||||
const currentVersion = app.getVersion();
|
||||
|
||||
const latest = latestVersion.split('.').map(Number);
|
||||
const current = currentVersion.split('.').map(Number);
|
||||
let updateAvailable = false;
|
||||
let updateType = 'patch';
|
||||
for (let i = 0; i < Math.max(latest.length, current.length); i++) {
|
||||
const l = latest[i] || 0;
|
||||
const c = current[i] || 0;
|
||||
if (l > c) {
|
||||
updateAvailable = true;
|
||||
updateType = i === 0 ? 'major' : i === 1 ? 'minor' : 'patch';
|
||||
break;
|
||||
}
|
||||
if (l < c) break;
|
||||
}
|
||||
|
||||
return { isFlatpak: true, updateAvailable, updateType, latestVersion, currentVersion };
|
||||
} catch (err) {
|
||||
console.error('Flatpak update check error:', err.message);
|
||||
return { isFlatpak: true, updateAvailable: false };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (event, url) => {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// Settings IPC handlers
|
||||
ipcMain.handle('get-setting', (event, key) => {
|
||||
const settings = loadSettings();
|
||||
return settings[key];
|
||||
});
|
||||
|
||||
ipcMain.handle('set-setting', (event, key, value) => {
|
||||
const settings = loadSettings();
|
||||
settings[key] = value;
|
||||
saveSettings(settings);
|
||||
});
|
||||
|
||||
// Secure session persistence handlers
|
||||
ipcMain.handle('save-session', (event, data) => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(data));
|
||||
fs.writeFileSync(SESSION_FILE, encrypted);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to save session:', err.message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('load-session', () => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||
if (!fs.existsSync(SESSION_FILE)) return null;
|
||||
const encrypted = fs.readFileSync(SESSION_FILE);
|
||||
const decrypted = safeStorage.decryptString(encrypted);
|
||||
return JSON.parse(decrypted);
|
||||
} catch (err) {
|
||||
console.error('Failed to load session (clearing corrupt file):', err.message);
|
||||
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-session', () => {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to clear session:', err.message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-screen-sources', async () => {
|
||||
const { desktopCapturer } = require('electron');
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 450, height: 250, borderRadius: '8px' },
|
||||
fetchWindowIcons: true
|
||||
});
|
||||
return sources.map(source => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL(),
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
|
||||
}));
|
||||
});
|
||||
|
||||
// Crypto Handlers
|
||||
const crypto = require('crypto');
|
||||
|
||||
ipcMain.handle('generate-keys', async () => {
|
||||
const generateRSA = () => new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
||||
}, (err, pub, priv) => {
|
||||
if (err) reject(err); else resolve({ pub, priv });
|
||||
});
|
||||
});
|
||||
|
||||
const generateEd = () => new Promise((resolve, reject) => {
|
||||
crypto.generateKeyPair('ed25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
||||
}, (err, pub, priv) => {
|
||||
if (err) reject(err); else resolve({ pub, priv });
|
||||
});
|
||||
});
|
||||
|
||||
const [rsa, ed] = await Promise.all([generateRSA(), generateEd()]);
|
||||
|
||||
return {
|
||||
rsaPub: rsa.pub,
|
||||
rsaPriv: rsa.priv,
|
||||
edPub: ed.pub,
|
||||
edPriv: ed.priv
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('random-bytes', (event, size) => {
|
||||
return crypto.randomBytes(size).toString('hex');
|
||||
});
|
||||
|
||||
ipcMain.handle('sha256', (event, data) => {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
});
|
||||
|
||||
ipcMain.handle('sign-message', (event, privateKeyPem, message) => {
|
||||
return crypto.sign(null, Buffer.from(message), privateKeyPem).toString('hex');
|
||||
});
|
||||
|
||||
ipcMain.handle('verify-signature', (event, publicKeyPem, message, signature) => {
|
||||
return crypto.verify(null, Buffer.from(message), publicKeyPem, Buffer.from(signature, 'hex'));
|
||||
});
|
||||
|
||||
ipcMain.handle('derive-auth-keys', (event, password, salt) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const dak = derivedKey.subarray(0, 32).toString('hex');
|
||||
const dek = derivedKey.subarray(32, 64);
|
||||
resolve({ dak, dek });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('public-encrypt', (event, publicKeyPem, data) => {
|
||||
const buffer = Buffer.from(data);
|
||||
return crypto.publicEncrypt({
|
||||
key: publicKeyPem,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: "sha256",
|
||||
}, buffer).toString('hex');
|
||||
});
|
||||
|
||||
ipcMain.handle('private-decrypt', (event, privateKeyPem, encryptedHex) => {
|
||||
const buffer = Buffer.from(encryptedHex, 'hex');
|
||||
return crypto.privateDecrypt({
|
||||
key: privateKeyPem,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: "sha256",
|
||||
}, buffer).toString();
|
||||
});
|
||||
|
||||
ipcMain.handle('encrypt-data', (event, plaintext, key) => {
|
||||
console.log('encrypt-data called with:', {
|
||||
plaintextType: typeof plaintext,
|
||||
isBuffer: Buffer.isBuffer(plaintext),
|
||||
keyType: typeof key,
|
||||
keyIsBuffer: Buffer.isBuffer(plaintext)
|
||||
});
|
||||
|
||||
if (plaintext === undefined) throw new TypeError('plaintext is undefined');
|
||||
if (key === undefined) throw new TypeError('key is undefined');
|
||||
|
||||
const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
||||
|
||||
const encryptedBuffer = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
const tag = cipher.getAuthTag().toString('hex');
|
||||
return { content: encryptedBuffer.toString('hex'), iv: iv.toString('hex'), tag };
|
||||
});
|
||||
|
||||
ipcMain.handle('decrypt-data', (event, ciphertext, key, iv, tag, options = {}) => {
|
||||
const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
|
||||
const ivBuffer = Buffer.from(iv, 'hex');
|
||||
const tagBuffer = Buffer.from(tag, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
||||
decipher.setAuthTag(tagBuffer);
|
||||
|
||||
const outputEncoding = options.encoding || 'utf8';
|
||||
const updateEncoding = outputEncoding === 'buffer' ? undefined : outputEncoding;
|
||||
|
||||
let decrypted;
|
||||
if (outputEncoding === 'buffer') {
|
||||
decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext, 'hex'),
|
||||
decipher.final()
|
||||
]);
|
||||
} else {
|
||||
decrypted = decipher.update(ciphertext, 'hex', outputEncoding);
|
||||
decrypted += decipher.final(outputEncoding);
|
||||
}
|
||||
return decrypted;
|
||||
});
|
||||
|
||||
ipcMain.handle('decrypt-batch', (event, items) => {
|
||||
return items.map(({ ciphertext, key, iv, tag }) => {
|
||||
try {
|
||||
const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
|
||||
const ivBuffer = Buffer.from(iv, 'hex');
|
||||
const tagBuffer = Buffer.from(tag, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
||||
decipher.setAuthTag(tagBuffer);
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return { success: true, data: decrypted };
|
||||
} catch (err) {
|
||||
return { success: false, data: null };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('verify-batch', (event, items) => {
|
||||
return items.map(({ publicKey, message, signature }) => {
|
||||
try {
|
||||
const verified = crypto.verify(null, Buffer.from(message), publicKey, Buffer.from(signature, 'hex'));
|
||||
return { success: true, verified };
|
||||
} catch (err) {
|
||||
return { success: false, verified: false };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// AFK voice channel: expose system idle time to renderer
|
||||
ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime());
|
||||
|
||||
// --- Auto-idle detection ---
|
||||
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
|
||||
let wasIdle = false;
|
||||
setInterval(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
const idleTime = powerMonitor.getSystemIdleTime();
|
||||
if (!wasIdle && idleTime >= IDLE_THRESHOLD_SECONDS) {
|
||||
wasIdle = true;
|
||||
mainWindow.webContents.send('idle-state-changed', { isIdle: true });
|
||||
} else if (wasIdle && idleTime < IDLE_THRESHOLD_SECONDS) {
|
||||
wasIdle = false;
|
||||
mainWindow.webContents.send('idle-state-changed', { isIdle: false });
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
84
apps/electron/package.json
Normal file
84
apps/electron/package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@discord-clone/electron",
|
||||
"private": true,
|
||||
"version": "1.0.14",
|
||||
"description": "Discord Clone - Electron app",
|
||||
"author": "Moyettes",
|
||||
"type": "module",
|
||||
"main": "main.cjs",
|
||||
"homepage": "./",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"electron:dev": "concurrently \"vite\" \"wait-on tcp:5173 && electron . --dev\"",
|
||||
"electron:build": "vite build && electron-builder"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.yourorg.discord-clone",
|
||||
"productName": "Discord Clone",
|
||||
"files": [
|
||||
"dist-react/**/*",
|
||||
"main.cjs",
|
||||
"preload.cjs",
|
||||
"updater.cjs",
|
||||
"splash.html",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "generic",
|
||||
"url": "https://gitea.moyettes.com/Moyettes/DiscordClone/releases/download/latest"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": ["nsis"],
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg", "zip"],
|
||||
"category": "public.app-category.social-networking"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "flatpak"],
|
||||
"category": "Network;InstantMessaging"
|
||||
},
|
||||
"flatpak": {
|
||||
"runtime": "org.freedesktop.Platform",
|
||||
"runtimeVersion": "23.08",
|
||||
"sdk": "org.freedesktop.Sdk",
|
||||
"base": "org.electronjs.Electron2.BaseApp",
|
||||
"baseVersion": "23.08",
|
||||
"finishArgs": [
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--device=all",
|
||||
"--filesystem=home",
|
||||
"--talk-name=org.freedesktop.Notifications"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": true,
|
||||
"perMachine": false
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@discord-clone/shared": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"vite": "^7.2.4",
|
||||
"wait-on": "^8.0.1"
|
||||
}
|
||||
}
|
||||
49
apps/electron/preload.cjs
Normal file
49
apps/electron/preload.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('cryptoAPI', {
|
||||
generateKeys: () => ipcRenderer.invoke('generate-keys'),
|
||||
randomBytes: (size) => ipcRenderer.invoke('random-bytes', size),
|
||||
sha256: (data) => ipcRenderer.invoke('sha256', data),
|
||||
signMessage: (privateKey, message) => ipcRenderer.invoke('sign-message', privateKey, message),
|
||||
verifySignature: (publicKey, message, signature) => ipcRenderer.invoke('verify-signature', publicKey, message, signature),
|
||||
deriveAuthKeys: (password, salt) => ipcRenderer.invoke('derive-auth-keys', password, salt),
|
||||
encryptData: (data, key) => ipcRenderer.invoke('encrypt-data', data, key),
|
||||
decryptData: (encryptedData, key, iv, tag, options) => ipcRenderer.invoke('decrypt-data', encryptedData, key, iv, tag, options),
|
||||
decryptBatch: (items) => ipcRenderer.invoke('decrypt-batch', items),
|
||||
verifyBatch: (items) => ipcRenderer.invoke('verify-batch', items),
|
||||
|
||||
// RSA Helpers
|
||||
publicEncrypt: (publicKey, data) => ipcRenderer.invoke('public-encrypt', publicKey, data),
|
||||
privateDecrypt: (privateKey, encryptedHex) => ipcRenderer.invoke('private-decrypt', privateKey, encryptedHex),
|
||||
|
||||
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('windowControls', {
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('appSettings', {
|
||||
get: (key) => ipcRenderer.invoke('get-setting', key),
|
||||
set: (key, value) => ipcRenderer.invoke('set-setting', key, value),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('updateAPI', {
|
||||
checkFlatpakUpdate: () => ipcRenderer.invoke('check-flatpak-update'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('sessionPersistence', {
|
||||
save: (data) => ipcRenderer.invoke('save-session', data),
|
||||
load: () => ipcRenderer.invoke('load-session'),
|
||||
clear: () => ipcRenderer.invoke('clear-session'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('idleAPI', {
|
||||
onIdleStateChanged: (callback) => ipcRenderer.on('idle-state-changed', (_event, data) => callback(data)),
|
||||
removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'),
|
||||
getSystemIdleTime: () => ipcRenderer.invoke('get-system-idle-time'),
|
||||
});
|
||||
109
apps/electron/splash.html
Normal file
109
apps/electron/splash.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Discord Clone</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: #313338;
|
||||
color: #dbdee1;
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #5865f2;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #f2f3f5;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 13px;
|
||||
color: #b5bac1;
|
||||
margin-bottom: 16px;
|
||||
min-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 220px;
|
||||
height: 6px;
|
||||
background: #1e1f22;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-container.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #5865f2;
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 0 0-4.8 0c-.14-.34-.36-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Discord Clone</div>
|
||||
<div id="status">Starting up...</div>
|
||||
<div class="progress-container" id="progress-container">
|
||||
<div id="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setStatus(text) {
|
||||
document.getElementById('status').textContent = text;
|
||||
}
|
||||
|
||||
function setProgress(percent) {
|
||||
const container = document.getElementById('progress-container');
|
||||
const bar = document.getElementById('progress-bar');
|
||||
container.classList.remove('hidden');
|
||||
bar.style.width = Math.min(100, Math.max(0, percent)) + '%';
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
document.getElementById('progress-container').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
34
apps/electron/src/main.jsx
Normal file
34
apps/electron/src/main.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||
import { PlatformProvider } from '@discord-clone/shared/src/platform';
|
||||
import App from '@discord-clone/shared/src/App';
|
||||
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
|
||||
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
|
||||
import { UpdateProvider } from '@discord-clone/shared/src/components/UpdateBanner';
|
||||
import TitleBar from '@discord-clone/shared/src/components/TitleBar';
|
||||
import electronPlatform from './platform';
|
||||
import '@discord-clone/shared/src/styles/themes.css';
|
||||
import '@discord-clone/shared/src/index.css';
|
||||
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<PlatformProvider platform={electronPlatform}>
|
||||
<ThemeProvider>
|
||||
<UpdateProvider>
|
||||
<ConvexProvider client={convex}>
|
||||
<VoiceProvider>
|
||||
<TitleBar />
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</VoiceProvider>
|
||||
</ConvexProvider>
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
</PlatformProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
56
apps/electron/src/platform/index.js
Normal file
56
apps/electron/src/platform/index.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Electron platform implementation.
|
||||
* Delegates to the window.* APIs exposed by preload.cjs.
|
||||
*/
|
||||
const electronPlatform = {
|
||||
crypto: {
|
||||
generateKeys: () => window.cryptoAPI.generateKeys(),
|
||||
randomBytes: (size) => window.cryptoAPI.randomBytes(size),
|
||||
sha256: (data) => window.cryptoAPI.sha256(data),
|
||||
signMessage: (privateKey, message) => window.cryptoAPI.signMessage(privateKey, message),
|
||||
verifySignature: (publicKey, message, signature) => window.cryptoAPI.verifySignature(publicKey, message, signature),
|
||||
deriveAuthKeys: (password, salt) => window.cryptoAPI.deriveAuthKeys(password, salt),
|
||||
encryptData: (data, key) => window.cryptoAPI.encryptData(data, key),
|
||||
decryptData: (encryptedData, key, iv, tag, options) => window.cryptoAPI.decryptData(encryptedData, key, iv, tag, options),
|
||||
decryptBatch: (items) => window.cryptoAPI.decryptBatch(items),
|
||||
verifyBatch: (items) => window.cryptoAPI.verifyBatch(items),
|
||||
publicEncrypt: (publicKey, data) => window.cryptoAPI.publicEncrypt(publicKey, data),
|
||||
privateDecrypt: (privateKey, encryptedHex) => window.cryptoAPI.privateDecrypt(privateKey, encryptedHex),
|
||||
},
|
||||
session: {
|
||||
save: (data) => window.sessionPersistence.save(data),
|
||||
load: () => window.sessionPersistence.load(),
|
||||
clear: () => window.sessionPersistence.clear(),
|
||||
},
|
||||
settings: {
|
||||
get: (key) => window.appSettings.get(key),
|
||||
set: (key, value) => window.appSettings.set(key, value),
|
||||
},
|
||||
idle: {
|
||||
getSystemIdleTime: () => window.idleAPI.getSystemIdleTime(),
|
||||
onIdleStateChanged: (callback) => window.idleAPI.onIdleStateChanged(callback),
|
||||
removeIdleStateListener: () => window.idleAPI.removeIdleStateListener(),
|
||||
},
|
||||
links: {
|
||||
openExternal: (url) => window.cryptoAPI.openExternal(url),
|
||||
fetchMetadata: (url) => window.cryptoAPI.fetchMetadata(url),
|
||||
},
|
||||
screenCapture: {
|
||||
getScreenSources: () => window.cryptoAPI.getScreenSources(),
|
||||
},
|
||||
windowControls: {
|
||||
minimize: () => window.windowControls.minimize(),
|
||||
maximize: () => window.windowControls.maximize(),
|
||||
close: () => window.windowControls.close(),
|
||||
},
|
||||
updates: {
|
||||
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
||||
},
|
||||
features: {
|
||||
hasWindowControls: true,
|
||||
hasScreenCapture: true,
|
||||
hasNativeUpdates: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default electronPlatform;
|
||||
61
apps/electron/updater.cjs
Normal file
61
apps/electron/updater.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const log = require('electron-log');
|
||||
|
||||
autoUpdater.logger = log;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
function checkForUpdates(splashWindow) {
|
||||
return new Promise((resolve) => {
|
||||
function sendToSplash(js) {
|
||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||
splashWindow.webContents.executeJavaScript(js).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
sendToSplash('setStatus("Checking for updates...")');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
sendToSplash('setStatus("Downloading update...")');
|
||||
autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
const percent = Math.round(progress.percent);
|
||||
sendToSplash(`setProgress(${percent})`);
|
||||
sendToSplash(`setStatus("Downloading update... ${percent}%")`);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
sendToSplash('setStatus("Installing update...")');
|
||||
sendToSplash('setProgress(100)');
|
||||
setTimeout(() => {
|
||||
autoUpdater.quitAndInstall();
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
sendToSplash('setStatus("Up to date!")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 1000);
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Auto-updater error:', err);
|
||||
sendToSplash('setStatus("Update check failed")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
});
|
||||
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
log.error('checkForUpdates failed:', err);
|
||||
sendToSplash('setStatus("Update check failed")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdates };
|
||||
19
apps/electron/vite.config.js
Normal file
19
apps/electron/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
envDir: '../../', // Pick up .env.local from project root (for VITE_CONVEX_URL)
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
alias: {
|
||||
'@discord-clone/shared': path.resolve(__dirname, '../../packages/shared'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-react',
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user