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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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(/&/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) => {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user