feat: Matcha! social integration — friends, chat, DMs, avatars, presence

Add full Matcha social panel (butter.lat API) as a right-side slide-out:

Backend (matchaService.js):
- HTTP client for auth, friends, messages, unread, avatar, heartbeat APIs
- WebSocket with auto-reconnect (exponential backoff, no hard cap)
- Token management via config, presence heartbeat every 30s
- WS error message type handling, game running presence

Renderer (matcha.js):
- State machine UI: intro → login/register → app (friends/chat/DMs/profile)
- Two-phase registration with master key display and verification
- Friends list with presence dots, collapsible requests, 12s polling
- Global chat + DM with optimistic rendering, cursor pagination
- Scroll position preserved on load-more, separate loading flags
- Clickable URLs in messages (linkify with proper escaping)
- User profile popup with avatar upload/delete
- Unread badges (messages + friend requests) on nav icon
- Escape key closes panel/overlay, try/catch on auth flows

IPC bridge (preload.js + main.js):
- 21 IPC invoke methods + 8 WS event listeners
- Avatar upload via file picker dialog in main process
- Game launch sets in_game heartbeat state

CSS (style.css):
- ~1500 lines: panel, auth screens, friends, chat, profile, toast
- Responsive panel width, improved contrast, no overflow clipping
- Loading states, disabled states, pulse animations

Credits: Powered by Butter Launcher & Matcha! (butterlauncher.tech)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanasol
2026-03-02 00:49:37 +01:00
parent 57056e5b7a
commit a40465cac9
9 changed files with 4219 additions and 5 deletions

113
main.js
View File

@@ -6,6 +6,7 @@ const fs = require('fs');
const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, saveAllowMultiInstance, loadAllowMultiInstance, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched, loadConfig, saveConfig, checkLaunchReady } = require('./backend/launcher');
const { retryPWRDownload } = require('./backend/managers/gameManager');
const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration');
const matchaService = require('./backend/services/matchaService');
// Handle Hardware Acceleration
try {
@@ -213,6 +214,9 @@ function createWindow() {
// Initialize Discord Rich Presence
initDiscordRPC();
// Initialize Matcha Social service
matchaService.init(mainWindow);
// Configure and initialize electron-updater
// Enable auto-download so updates start immediately when available
autoUpdater.autoDownload = true;
@@ -509,6 +513,7 @@ async function cleanupDiscordRPC() {
app.on('before-quit', () => {
console.log('=== LAUNCHER BEFORE QUIT ===');
cleanupDiscordRPC();
matchaService.destroy();
});
app.on('window-all-closed', () => {
@@ -558,6 +563,9 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g
// Save last played timestamp
try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ }
// Notify Matcha that game is running (heartbeat will send 'in_game')
matchaService.setGameRunning(true);
const closeOnStart = loadCloseLauncherOnStart();
if (closeOnStart) {
console.log('Close Launcher on start enabled, quitting application...');
@@ -615,6 +623,9 @@ ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath,
if (result.success && result.launched) {
try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ }
matchaService.setGameRunning(true);
const closeOnStart = loadCloseLauncherOnStart();
if (closeOnStart) {
setTimeout(() => { app.quit(); }, 1000);
@@ -1808,6 +1819,108 @@ ipcMain.handle('preview-wrapper-script', (event, config, platform) => {
return generateWrapperScript(config || require('./backend/launcher').loadWrapperConfig(), platform || process.platform, '/path/to/java');
});
// =============================================================================
// MATCHA SOCIAL IPC HANDLERS
// =============================================================================
ipcMain.handle('matcha:log', (event, level, ...args) => {
const prefix = '[Matcha/Renderer]';
if (level === 'error') console.error(prefix, ...args);
else if (level === 'warn') console.warn(prefix, ...args);
else console.log(prefix, ...args);
});
ipcMain.handle('matcha:register', async (event, username, password, password2) => {
return matchaService.register(username, password, password2);
});
ipcMain.handle('matcha:confirm-register', async (event, pendingId, proofId) => {
return matchaService.confirmRegistration(pendingId, proofId);
});
ipcMain.handle('matcha:login', async (event, handle, password) => {
return matchaService.login(handle, password);
});
ipcMain.handle('matcha:logout', async () => {
return matchaService.logout();
});
ipcMain.handle('matcha:get-auth-state', () => {
return matchaService.getAuthState();
});
ipcMain.handle('matcha:get-me', async () => {
return matchaService.getMe();
});
ipcMain.handle('matcha:get-user', async (event, userId) => {
return matchaService.getUser(userId);
});
ipcMain.handle('matcha:get-friends', async () => {
return matchaService.getFriends();
});
ipcMain.handle('matcha:friend-request', async (event, handle) => {
return matchaService.sendFriendRequest(handle);
});
ipcMain.handle('matcha:friend-accept', async (event, requestId) => {
return matchaService.acceptFriend(requestId);
});
ipcMain.handle('matcha:friend-reject', async (event, requestId) => {
return matchaService.rejectFriend(requestId);
});
ipcMain.handle('matcha:friend-cancel', async (event, requestId) => {
return matchaService.cancelFriendRequest(requestId);
});
ipcMain.handle('matcha:friend-remove', async (event, friendId) => {
return matchaService.removeFriend(friendId);
});
ipcMain.handle('matcha:get-messages', async (event, withTarget, cursor, after) => {
return matchaService.getMessages(withTarget, cursor, after);
});
ipcMain.handle('matcha:send-message', async (event, to, body, replyTo) => {
return matchaService.sendMessage(to, body, replyTo);
});
ipcMain.handle('matcha:delete-message', async (event, messageId) => {
return matchaService.deleteMessage(messageId);
});
ipcMain.handle('matcha:get-unread', async () => {
return matchaService.getUnread();
});
ipcMain.handle('matcha:clear-unread', async (event, withTarget) => {
return matchaService.clearUnread(withTarget);
});
ipcMain.handle('matcha:upload-avatar', async (event, mode) => {
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Select Avatar Image',
filters: [{ name: 'PNG Images', extensions: ['png'] }],
properties: ['openFile']
});
if (result.canceled || !result.filePaths[0]) return { ok: false, error: 'Cancelled' };
return matchaService.uploadAvatar(result.filePaths[0], mode);
});
ipcMain.handle('matcha:delete-avatar', async () => {
return matchaService.deleteAvatar();
});
ipcMain.handle('matcha:reconnect', () => {
matchaService.manualReconnect();
return { ok: true };
});
ipcMain.handle('get-current-platform', () => {
return process.platform;
});