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

View File

@@ -24,7 +24,7 @@
height="100%25" filter="url(%23noiseFilter)" opacity="0.1" /%3E%3C/svg%3E')] opacity-20"></div>
</div>
<div class="flex w-full h-screen relative z-10">
<div class="flex w-full h-screen relative z-10" style="isolation: isolate">
<nav class="sidebar">
<div class="sidebar-logo">
<img src="./icon.png" alt="Hytale Logo" />
@@ -52,9 +52,12 @@
<i class="fas fa-terminal"></i>
<span class="nav-tooltip">Logs</span>
</div>
<div class="nav-item" onclick="openDiscordExternal()">
<i class="fas fa-comments"></i>
<span class="nav-tooltip">Community Chat</span>
<div class="nav-item" id="matchaNavItem">
<i class="fas fa-comments" id="matchaNavIcon"></i>
<img id="matchaNavAvatar" class="matcha-nav-avatar" style="display:none" />
<span class="matcha-nav-status" id="matchaNavStatus" style="display:none"></span>
<span class="nav-tooltip">Matcha!</span>
<span id="matchaUnreadBadge" class="matcha-unread-badge" style="display:none"></span>
</div>
</div>
@@ -687,6 +690,29 @@
</main>
</div>
<!-- Matcha! Panel -->
<div id="matchaPanelBackdrop" class="matcha-panel-backdrop"></div>
<div id="matchaPanel" class="matcha-panel">
<div class="matcha-panel-header">
<div id="matchaPanelHeaderContent" class="matcha-panel-header-content">
<span class="matcha-header-title">Matcha!</span>
</div>
<button class="matcha-panel-close" id="matchaPanelCloseBtn">
<i class="fas fa-times"></i>
</button>
</div>
<div id="matchaPanelBody" class="matcha-panel-body"></div>
</div>
<!-- Matcha User Profile Popup -->
<div id="matchaUserProfileOverlay" class="matcha-user-profile-overlay" style="display:none">
<div class="matcha-user-profile-card" id="matchaUserProfileCard">
<div class="matcha-user-profile-body" id="matchaUserProfileBody">
<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div id="myModsModal" class="mods-modal">
<div class="mods-modal-content">
<div class="mods-modal-header">
@@ -969,6 +995,12 @@
<a href="#" onclick="window.electronAPI?.openExternal('https://github.com/xSamiVS'); return false;"
class="text-blue-400 hover:text-blue-300 transition-colors">@xSamiVS</a>
</span>
<span class="mx-2">|</span>
<span>Social powered by <a href="#" onclick="window.electronAPI?.openExternal('https://github.com/vZylev/Butter-Launcher'); return false;"
class="text-purple-400 hover:text-purple-300 transition-colors">Butter Launcher</a> &amp;
<a href="#" onclick="window.electronAPI?.openExternal('https://butterlauncher.tech/'); return false;"
class="text-purple-400 hover:text-purple-300 transition-colors">Matcha!</a>
</span>
</div>
</footer>

1919
GUI/js/matcha.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { initModsManager } from './mods.js';
import './players.js';
import './settings.js';
import './logs.js';
import { initMatcha } from './matcha.js';
let i18nInitialized = false;
(async () => {
@@ -15,10 +16,12 @@ let i18nInitialized = false;
if (document.readyState === 'complete' || document.readyState === 'interactive') {
updateLanguageSelector();
initModsManager();
initMatcha();
} else {
document.addEventListener('DOMContentLoaded', () => {
updateLanguageSelector();
initModsManager();
initMatcha();
});
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1087,6 +1087,48 @@ function _generateWindowsWrapper(stripFlags, alwaysArgs, serverArgs) {
return lines.join('\r\n');
}
// =============================================================================
// MATCHA SOCIAL AUTH
// =============================================================================
function saveMatchaToken(token) {
saveConfig({ matchaToken: token || null });
}
function loadMatchaToken() {
const config = loadConfig();
return config.matchaToken || null;
}
function saveMatchaHandle(handle) {
saveConfig({ matchaHandle: handle || null });
}
function loadMatchaHandle() {
const config = loadConfig();
return config.matchaHandle || null;
}
function saveMatchaUserId(id) {
saveConfig({ matchaUserId: id || null });
}
function loadMatchaUserId() {
const config = loadConfig();
return config.matchaUserId || null;
}
function clearMatchaAuth() {
const config = loadConfig();
delete config.matchaToken;
delete config.matchaUserId;
delete config.matchaHandle;
const data = JSON.stringify(config, null, 2);
fs.writeFileSync(CONFIG_TEMP, data, 'utf8');
if (fs.existsSync(CONFIG_FILE)) fs.copyFileSync(CONFIG_FILE, CONFIG_BACKUP);
fs.renameSync(CONFIG_TEMP, CONFIG_FILE);
}
// =============================================================================
// EXPORTS
// =============================================================================
@@ -1162,6 +1204,15 @@ module.exports = {
resetWrapperConfig,
generateWrapperScript,
// Matcha Social
saveMatchaToken,
loadMatchaToken,
saveMatchaHandle,
loadMatchaHandle,
saveMatchaUserId,
loadMatchaUserId,
clearMatchaAuth,
// Constants
CONFIG_FILE,
UUID_STORE_FILE

View File

@@ -0,0 +1,529 @@
const axios = require('axios');
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const { loadConfig, saveConfig } = require('../core/config');
const MATCHA_BASE = 'https://butter.lat';
const MATCHA_API = `${MATCHA_BASE}/api/matcha`;
const MATCHA_WS = 'wss://butter.lat/api/matcha/ws';
class MatchaService {
constructor() {
this.token = null;
this.user = null;
this.ws = null;
this.wsConnected = false;
this.mainWindow = null;
this.heartbeatInterval = null;
this.reconnectTimeout = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.lastMessageSentAt = 0;
this.gameRunning = false;
}
// =========================================================================
// LIFECYCLE
// =========================================================================
init(mainWindow) {
this.mainWindow = mainWindow;
this.token = this._loadToken();
if (this.token) {
this._connectWs();
this._startHeartbeat();
// Load cached user info
const config = loadConfig();
if (config.matchaUserId && config.matchaHandle) {
this.user = { id: config.matchaUserId, handle: config.matchaHandle };
}
}
}
destroy() {
// Best-effort offline heartbeat
if (this.token) {
this.sendHeartbeat('offline').catch(() => {});
}
this._stopHeartbeat();
this._disconnectWs();
this.mainWindow = null;
}
// =========================================================================
// TOKEN MANAGEMENT
// =========================================================================
_loadToken() {
const config = loadConfig();
return config.matchaToken || null;
}
_saveToken(token) {
this.token = token;
saveConfig({ matchaToken: token });
}
_saveUser(user) {
this.user = user;
if (user) {
saveConfig({ matchaUserId: user.id, matchaHandle: user.handle });
}
}
_clearAuth() {
this.token = null;
this.user = null;
this._stopHeartbeat();
this._disconnectWs();
// Set to null — saveConfig merges, and JSON.stringify preserves null values,
// but this effectively marks them as cleared for _loadToken() checks
saveConfig({ matchaToken: null, matchaUserId: null, matchaHandle: null });
}
_authHeaders() {
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
}
// =========================================================================
// AUTH
// =========================================================================
async register(username, password, password2) {
try {
const res = await axios.post(`${MATCHA_API}/register`, {
username, password, password2, deferCreate: true
});
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async confirmRegistration(pendingId, proofId) {
try {
const res = await axios.post(`${MATCHA_API}/register/confirm`, {
pendingId, proofId
});
if (res.data.token) {
this._saveToken(res.data.token);
if (res.data.user) this._saveUser(res.data.user);
this._connectWs();
this._startHeartbeat();
}
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async login(handle, password) {
try {
const res = await axios.post(`${MATCHA_API}/login`, { handle, password });
if (res.data.token) {
this._saveToken(res.data.token);
if (res.data.user) this._saveUser(res.data.user);
this._connectWs();
this._startHeartbeat();
}
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async logout() {
await this.sendHeartbeat('offline').catch(() => {});
this._clearAuth();
return { ok: true };
}
getAuthState() {
return {
authenticated: !!this.token,
user: this.user,
wsConnected: this.wsConnected
};
}
// =========================================================================
// PROFILE
// =========================================================================
async getMe() {
try {
const res = await axios.get(`${MATCHA_API}/me`, { headers: this._authHeaders() });
if (res.data?.user) this._saveUser({ id: res.data.user.id, handle: res.data.user.handle });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async getUser(userId) {
try {
const res = await axios.get(`${MATCHA_API}/users/${encodeURIComponent(userId)}`, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
// =========================================================================
// FRIENDS
// =========================================================================
async getFriends() {
try {
const res = await axios.get(`${MATCHA_API}/friends`, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async sendFriendRequest(handle) {
try {
const res = await axios.post(`${MATCHA_API}/friends/request`, { toHandle: handle }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async acceptFriend(requestId) {
try {
const res = await axios.post(`${MATCHA_API}/friends/request/accept`, { id: requestId }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async rejectFriend(requestId) {
try {
const res = await axios.post(`${MATCHA_API}/friends/request/reject`, { id: requestId }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async cancelFriendRequest(requestId) {
try {
const res = await axios.post(`${MATCHA_API}/friends/request/cancel`, { id: requestId }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async removeFriend(friendId) {
try {
const res = await axios.post(`${MATCHA_API}/friends/remove`, { friendId }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
// =========================================================================
// MESSAGES
// =========================================================================
async getMessages(withTarget, cursor, after) {
try {
const params = { with: withTarget };
if (cursor) params.cursor = cursor;
if (after) params.after = after;
const res = await axios.get(`${MATCHA_API}/messages`, { headers: this._authHeaders(), params });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async sendMessage(to, body, replyTo) {
// Enforce 800ms throttle
const now = Date.now();
const elapsed = now - this.lastMessageSentAt;
if (elapsed < 800) {
return { ok: false, error: 'Please wait before sending another message' };
}
this.lastMessageSentAt = now;
// Try WebSocket first
if (this.wsConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
const msg = { type: 'send', to, body };
if (replyTo) msg.replyTo = replyTo;
this.ws.send(JSON.stringify(msg));
return { ok: true, data: { sent: true, via: 'ws' } };
}
// Fallback to HTTP
try {
const payload = { to, body };
if (replyTo) payload.replyTo = replyTo;
const res = await axios.post(`${MATCHA_API}/messages/send`, payload, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async deleteMessage(messageId) {
try {
const res = await axios.post(`${MATCHA_API}/messages/${messageId}/delete`, {}, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
// =========================================================================
// UNREAD
// =========================================================================
async getUnread() {
try {
const res = await axios.get(`${MATCHA_API}/unread`, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async clearUnread(withTarget) {
try {
const res = await axios.post(`${MATCHA_API}/unread/clear`, { with: withTarget }, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
// =========================================================================
// HEARTBEAT
// =========================================================================
async sendHeartbeat(state) {
if (!this.token) return;
try {
await axios.post(`${MATCHA_API}/heartbeat`, { state: state || 'online' }, { headers: this._authHeaders() });
} catch (err) {
console.log('[Matcha] Heartbeat failed:', err.message);
}
}
_startHeartbeat() {
this._stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
const state = this.gameRunning ? 'in_game' : 'online';
this.sendHeartbeat(state);
}, 30000);
// Send initial heartbeat
this.sendHeartbeat('online');
}
_stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
setGameRunning(running) {
this.gameRunning = running;
}
// =========================================================================
// AVATAR
// =========================================================================
async uploadAvatar(filePath, mode) {
try {
const crypto = require('crypto');
const fileBuffer = fs.readFileSync(filePath);
if (fileBuffer.length > 1024 * 1024) {
return { ok: false, error: 'Avatar too large (max 1MB)' };
}
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const endpoint = mode === 'custom' ? `${MATCHA_API}/avatar/custom` : `${MATCHA_API}/avatar`;
const res = await axios.post(endpoint, fileBuffer, {
headers: {
...this._authHeaders(),
'Content-Type': 'image/png',
'x-avatar-hash': hash,
'x-avatar-enable': '1',
'x-avatar-force': '1',
'Cache-Control': 'no-store'
},
maxContentLength: 1024 * 1024
});
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
async deleteAvatar() {
try {
const res = await axios.delete(`${MATCHA_API}/avatar`, { headers: this._authHeaders() });
return { ok: true, data: res.data };
} catch (err) {
return this._handleError(err);
}
}
// =========================================================================
// WEBSOCKET
// =========================================================================
_connectWs() {
if (this.ws) this._disconnectWs();
try {
this.ws = new WebSocket(MATCHA_WS);
this.ws.on('open', () => {
console.log('[Matcha] WebSocket connected');
this.reconnectAttempts = 0;
// Authenticate
if (this.token) {
this.ws.send(JSON.stringify({ type: 'auth', token: this.token }));
}
});
this.ws.on('message', (raw) => {
try {
const data = JSON.parse(raw.toString());
this._handleWsMessage(data);
} catch (err) {
console.error('[Matcha] WS parse error:', err.message);
}
});
this.ws.on('close', (code) => {
console.log('[Matcha] WebSocket closed:', code);
this.wsConnected = false;
this._sendToRenderer('matcha:ws:disconnected');
// Handle ban
if (code === 4003) {
this._sendToRenderer('matcha:ws:banned', { reason: 'Account banned' });
return;
}
// Auto-reconnect if we have a token
if (this.token && code !== 4003) {
this._scheduleReconnect();
}
});
this.ws.on('error', (err) => {
console.error('[Matcha] WS error:', err.message);
});
} catch (err) {
console.error('[Matcha] WS connect failed:', err.message);
this._scheduleReconnect();
}
}
_disconnectWs() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
try {
this.ws.close();
} catch (e) {}
this.ws = null;
}
this.wsConnected = false;
}
_scheduleReconnect() {
if (this.reconnectTimeout) return;
// Exponential backoff capped at 30s, no hard limit (matches Butter's infinite reconnect)
const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
// Notify renderer after several failures so it can show a banner
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this._sendToRenderer('matcha:ws:max-retries');
}
console.log(`[Matcha] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
if (this.token) this._connectWs();
}, delay);
}
_handleWsMessage(data) {
switch (data.type) {
case 'authed':
this.wsConnected = true;
if (data.user) this._saveUser(data.user);
this._sendToRenderer('matcha:ws:connected', { user: this.user });
break;
case 'message':
console.log('[Matcha] WS message received:', JSON.stringify(data).substring(0, 200));
this._sendToRenderer('matcha:ws:message', data);
break;
case 'message_deleted':
this._sendToRenderer('matcha:ws:message-deleted', data);
break;
case 'avatar_updated':
this._sendToRenderer('matcha:ws:avatar-updated', data);
break;
case 'banned':
this._sendToRenderer('matcha:ws:banned', data);
break;
case 'announcement':
this._sendToRenderer('matcha:ws:announcement', data);
break;
case 'error':
console.log('[Matcha] WS error:', data.message || data.error || JSON.stringify(data));
// If auth error, treat as ban/disconnect
if (data.message === 'Not authed' || data.error === 'Not authed') {
this._clearAuth();
this._sendToRenderer('matcha:ws:disconnected');
}
break;
default:
console.log('[Matcha] Unknown WS message type:', data.type);
}
}
manualReconnect() {
this.reconnectAttempts = 0;
if (this.token) this._connectWs();
}
// =========================================================================
// HELPERS
// =========================================================================
_sendToRenderer(channel, data) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, data);
}
}
_handleError(err) {
if (err.response) {
const status = err.response.status;
const msg = err.response.data?.error || err.response.data?.message || err.message;
if (status === 401) {
// Token expired or invalid
this._clearAuth();
this._sendToRenderer('matcha:ws:disconnected');
}
return { ok: false, error: msg, status };
}
return { ok: false, error: err.message };
}
}
module.exports = new MatchaService();

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;
});

View File

@@ -56,7 +56,8 @@
"electron-updater": "^6.7.3",
"fs-extra": "^11.3.3",
"tar": "^7.5.7",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"ws": "^8.16.0"
},
"build": {
"appId": "com.hytalef2p.launcher",

View File

@@ -153,5 +153,39 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
onUpdateError: (callback) => {
ipcRenderer.on('update-error', (event, data) => callback(data));
},
// Matcha Social API
matcha: {
log: (level, ...args) => ipcRenderer.invoke('matcha:log', level, ...args),
register: (username, password, password2) => ipcRenderer.invoke('matcha:register', username, password, password2),
confirmRegister: (pendingId, proofId) => ipcRenderer.invoke('matcha:confirm-register', pendingId, proofId),
login: (handle, password) => ipcRenderer.invoke('matcha:login', handle, password),
logout: () => ipcRenderer.invoke('matcha:logout'),
getAuthState: () => ipcRenderer.invoke('matcha:get-auth-state'),
getMe: () => ipcRenderer.invoke('matcha:get-me'),
getUser: (userId) => ipcRenderer.invoke('matcha:get-user', userId),
getFriends: () => ipcRenderer.invoke('matcha:get-friends'),
friendRequest: (handle) => ipcRenderer.invoke('matcha:friend-request', handle),
friendAccept: (requestId) => ipcRenderer.invoke('matcha:friend-accept', requestId),
friendReject: (requestId) => ipcRenderer.invoke('matcha:friend-reject', requestId),
friendCancel: (requestId) => ipcRenderer.invoke('matcha:friend-cancel', requestId),
friendRemove: (friendId) => ipcRenderer.invoke('matcha:friend-remove', friendId),
getMessages: (withTarget, cursor, after) => ipcRenderer.invoke('matcha:get-messages', withTarget, cursor, after),
sendMessage: (to, body, replyTo) => ipcRenderer.invoke('matcha:send-message', to, body, replyTo),
deleteMessage: (messageId) => ipcRenderer.invoke('matcha:delete-message', messageId),
getUnread: () => ipcRenderer.invoke('matcha:get-unread'),
clearUnread: (withTarget) => ipcRenderer.invoke('matcha:clear-unread', withTarget),
uploadAvatar: (mode) => ipcRenderer.invoke('matcha:upload-avatar', mode),
deleteAvatar: () => ipcRenderer.invoke('matcha:delete-avatar'),
reconnect: () => ipcRenderer.invoke('matcha:reconnect'),
onWsMessage: (callback) => ipcRenderer.on('matcha:ws:message', (event, data) => callback(data)),
onWsConnected: (callback) => ipcRenderer.on('matcha:ws:connected', (event, data) => callback(data)),
onWsDisconnected: (callback) => ipcRenderer.on('matcha:ws:disconnected', () => callback()),
onMessageDeleted: (callback) => ipcRenderer.on('matcha:ws:message-deleted', (event, data) => callback(data)),
onAvatarUpdated: (callback) => ipcRenderer.on('matcha:ws:avatar-updated', (event, data) => callback(data)),
onBanned: (callback) => ipcRenderer.on('matcha:ws:banned', (event, data) => callback(data)),
onAnnouncement: (callback) => ipcRenderer.on('matcha:ws:announcement', (event, data) => callback(data)),
onMaxRetries: (callback) => ipcRenderer.on('matcha:ws:max-retries', () => callback())
}
});