feat: Implement core Discord features including members list, direct messages, user presence, authentication, and chat UI.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-11 04:36:40 -06:00
parent a29858fd32
commit cb4361da1a
32 changed files with 2051 additions and 144 deletions

View File

@@ -1,13 +1,60 @@
const { app, BrowserWindow, ipcMain, shell } = require('electron');
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = 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 win = new BrowserWindow({
width: 1200,
height: 800,
const settings = loadSettings();
const windowOptions = {
width: settings.windowWidth,
height: settings.windowHeight,
frame: false,
webPreferences: {
nodeIntegration: false,
@@ -15,17 +62,44 @@ function createWindow() {
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', () => {
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) {
win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// Production: Load the built file
// dist-react is in the same directory as main.cjs
win.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
}
}
@@ -77,75 +151,254 @@ app.whenReady().then(async () => {
});
// 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 = {};
// Match both orderings: property/name before content AND content before property/name
const metaRegex = /<meta\s+([^>]*?)\/?\s*>/gi;
let match;
while ((match = metaRegex.exec(html)) !== null) {
const attrs = match[1];
let name = null, content = null;
// Extract property or name attribute
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;
}
// Extract <title> tag
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/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) => {
return new Promise((resolve) => {
// Check for direct video links to avoid downloading large files
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))) {
const filename = url.split('/').pop();
resolve({
title: filename,
siteName: new URL(url).hostname,
video: url,
image: null, // No thumbnail for now unless we generate one
description: 'Video File'
});
return;
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
}
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
const filename = url.split('/').pop();
resolve({
title: filename,
siteName: new URL(url).hostname,
video: null,
image: url, // Direct image/gif
description: 'Image File'
});
return;
return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
}
const client = url.startsWith('https') ? https : http;
const req = client.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
// Simple Regex Parser for OG Tags to avoid dependencies
const getMeta = (prop) => {
const regex = new RegExp(`<meta\\s+(?:name|property)=["'](?:og:)?${prop}["']\\s+content=["'](.*?)["']`, 'i');
const match = data.match(regex);
return match ? match[1] : null;
};
const getTitle = () => {
const regex = /<title>(.*?)<\/title>/i;
const match = data.match(regex);
return match ? match[1] : null;
};
const hostname = new URL(url).hostname.replace(/^www\./, '');
const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
const metadata = {
title: getMeta('title') || getTitle(),
description: getMeta('description'),
image: getMeta('image'),
siteName: getMeta('site_name'),
themeColor: getMeta('theme-color')
};
resolve(metadata);
});
});
req.on('error', (err) => {
console.error('Metadata fetch error:', err);
resolve(null);
});
});
let metadata;
if (oembedUrl) {
// Fetch oEmbed JSON and page HTML in parallel
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 {
// Standard HTML fetch
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;
}
});
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({