feat/makepkg + project structure #12
@@ -11,32 +11,58 @@ import { scrapeCooldown, SteamAuthError } from './services/scraper';
|
|||||||
import { steamClient, LocalSteamAccount } from './services/steam-client';
|
import { steamClient, LocalSteamAccount } from './services/steam-client';
|
||||||
import { BackendService } from './services/backend';
|
import { BackendService } from './services/backend';
|
||||||
|
|
||||||
// --- Reliable isDev check ---
|
// Reliable isDev check
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
|
|
||||||
app.name = "Ultimate Ban Tracker";
|
app.name = "Ultimate Ban Tracker";
|
||||||
|
|
||||||
|
// Force Wayland/Ozone support if on Linux
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'UseOzonePlatform');
|
||||||
|
app.commandLine.appendSwitch('ozone-platform', 'wayland');
|
||||||
|
}
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config({ path: path.join(app.getAppPath(), '..', '.env') });
|
dotenv.config({ path: path.join(app.getAppPath(), '..', '.env') });
|
||||||
|
|
||||||
// --- Types & Interfaces ---
|
// --- Types & Interfaces ---
|
||||||
interface Account {
|
interface Account {
|
||||||
_id: string; steamId: string; personaName: string; loginName: string;
|
_id: string;
|
||||||
steamLoginSecure?: string; loginConfig?: any; sessionUpdatedAt?: string;
|
steamId: string;
|
||||||
autoCheckCooldown: boolean; avatar: string; localAvatar?: string;
|
personaName: string;
|
||||||
profileUrl: string; status: string; vacBanned: boolean; gameBans: number;
|
loginName: string;
|
||||||
lastBanCheck: string; lastScrapeTime?: string; cooldownExpiresAt?: string;
|
steamLoginSecure?: string;
|
||||||
authError?: boolean; notes?: string; sharedWith?: any[];
|
loginConfig?: any;
|
||||||
|
sessionUpdatedAt?: string;
|
||||||
|
autoCheckCooldown: boolean;
|
||||||
|
avatar: string;
|
||||||
|
localAvatar?: string;
|
||||||
|
profileUrl: string;
|
||||||
|
status: string;
|
||||||
|
vacBanned: boolean;
|
||||||
|
gameBans: number;
|
||||||
|
lastBanCheck: string;
|
||||||
|
lastScrapeTime?: string;
|
||||||
|
cooldownExpiresAt?: string;
|
||||||
|
authError?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
sharedWith?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerConfig {
|
interface ServerConfig {
|
||||||
url: string; token?: string; serverSteamId?: string; enabled: boolean; theme?: string; isAdmin?: boolean;
|
url: string;
|
||||||
|
token?: string;
|
||||||
|
serverSteamId?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
theme?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. GLOBAL REFERENCES (Prevent Garbage Collection) ---
|
// --- App State ---
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let isQuitting = false;
|
|
||||||
let backend: BackendService | null = null;
|
let backend: BackendService | null = null;
|
||||||
|
(app as any).isQuitting = false;
|
||||||
|
|
||||||
const store = new Store<{ accounts: Account[], serverConfig: ServerConfig }>({
|
const store = new Store<{ accounts: Account[], serverConfig: ServerConfig }>({
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -45,7 +71,7 @@ const store = new Store<{ accounts: Account[], serverConfig: ServerConfig }>({
|
|||||||
}
|
}
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
// --- Avatar Cache ---
|
// --- Avatar Cache Logic ---
|
||||||
const AVATAR_DIR = path.join(app.getPath('userData'), 'avatars');
|
const AVATAR_DIR = path.join(app.getPath('userData'), 'avatars');
|
||||||
if (!fs.existsSync(AVATAR_DIR)) fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
if (!fs.existsSync(AVATAR_DIR)) fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -67,19 +93,68 @@ const initBackend = () => {
|
|||||||
} else { backend = null; }
|
} else { backend = null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 2. TRAY IMPLEMENTATION (Smart Arch) ---
|
// --- System Tray ---
|
||||||
const getIconPath = (themeName: string = 'steam') => {
|
const createTray = () => {
|
||||||
if (process.platform === 'win32') {
|
console.log('[Tray] Initializing...');
|
||||||
// Windows prefers .ico, but we use high-res PNG as fallback if .ico isn't generated
|
|
||||||
return path.join(app.getAppPath(), 'assets-build', 'icon.png');
|
// 1. Determine source path (handling both Dev and Prod/ASAR)
|
||||||
|
let sourceIconPath = '';
|
||||||
|
const possibleSourcePaths = [
|
||||||
|
path.join(app.getAppPath(), 'assets-build', 'icon.png'), // Priority 1: ASAR/Internal
|
||||||
|
path.join(__dirname, '..', 'assets-build', 'icon.png'), // Priority 2: Dev
|
||||||
|
path.join(process.resourcesPath, 'assets-build', 'icon.png') // Priority 3: External resources
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of possibleSourcePaths) {
|
||||||
|
if (fs.existsSync(p)) { sourceIconPath = p; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceIconPath) {
|
||||||
|
console.warn('[Tray] FAILED: No source icon found. Using empty fallback.');
|
||||||
|
try { tray = new Tray(nativeImage.createEmpty()); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. LINUX FIX: Extract icon from ASAR to real filesystem
|
||||||
|
let finalIconPath = sourceIconPath;
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const tempIconPath = path.join(app.getPath('temp'), 'ultimate-ban-tracker-tray.png');
|
||||||
|
try { fs.unlinkSync(tempIconPath); } catch (e) {}
|
||||||
|
|
||||||
|
console.log(`[Tray] Extracting icon to: ${tempIconPath}`);
|
||||||
|
fs.copyFileSync(sourceIconPath, tempIconPath);
|
||||||
|
finalIconPath = tempIconPath;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[Tray] Failed to extract icon: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux: Try system path first (Most reliable for Wayland/StatusNotifier)
|
try {
|
||||||
const systemPath = '/usr/share/pixmaps/ultimate-ban-tracker.png';
|
console.log(`[Tray] Creating tray with icon: ${finalIconPath}`);
|
||||||
if (fs.existsSync(systemPath)) return systemPath;
|
const icon = nativeImage.createFromPath(finalIconPath).resize({ width: 16, height: 16 });
|
||||||
|
tray = new Tray(icon);
|
||||||
|
tray.setToolTip('Ultimate Ban Tracker');
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
tray.setIgnoreMouseEvents(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to local assets
|
tray.on('click', () => {
|
||||||
return path.join(app.getAppPath(), 'assets-build', 'icon.png');
|
if (mainWindow) {
|
||||||
|
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTrayMenu();
|
||||||
|
|
||||||
|
const config = store.get('serverConfig');
|
||||||
|
if (config?.theme) setAppIcon(config.theme);
|
||||||
|
|
||||||
|
console.log(`[Tray] Successfully initialized`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[Tray] Error: ${e.message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTrayMenu = () => {
|
const updateTrayMenu = () => {
|
||||||
@@ -100,24 +175,25 @@ const updateTrayMenu = () => {
|
|||||||
},
|
},
|
||||||
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
|
{ label: 'Sync Now', enabled: !!config?.enabled, click: () => syncAccounts(true) },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Show Dashboard', click: () => { if (mainWindow) mainWindow.show(); } },
|
{ label: 'Show Dashboard', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } },
|
||||||
{ label: 'Quit', click: () => { isQuitting = true; app.quit(); } }
|
{ label: 'Quit', click: () => {
|
||||||
|
(app as any).isQuitting = true;
|
||||||
|
if (tray) tray.destroy();
|
||||||
|
app.quit();
|
||||||
|
} }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
tray.setContextMenu(contextMenu);
|
tray.setContextMenu(contextMenu);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupTray = () => {
|
const setAppIcon = (themeName: string = 'steam') => {
|
||||||
const icon = nativeImage.createFromPath(getIconPath()).resize({ width: 16, height: 16 });
|
const assetsDir = path.join(app.getAppPath(), 'assets-build', 'icons');
|
||||||
tray = new Tray(icon);
|
const iconPath = path.join(assetsDir, `${themeName}.svg`);
|
||||||
tray.setToolTip('Ultimate Ban Tracker');
|
|
||||||
|
|
||||||
tray.on('click', () => {
|
if (!fs.existsSync(iconPath)) return;
|
||||||
if (!mainWindow) return;
|
const icon = nativeImage.createFromPath(iconPath);
|
||||||
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
|
if (tray) tray.setImage(icon.resize({ width: 16, height: 16 }));
|
||||||
});
|
if (mainWindow) mainWindow.setIcon(icon);
|
||||||
|
|
||||||
updateTrayMenu();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Steam Logic ---
|
// --- Steam Logic ---
|
||||||
@@ -194,6 +270,7 @@ const scrapeAccountData = async (account: Account) => {
|
|||||||
|
|
||||||
// --- Sync Worker ---
|
// --- Sync Worker ---
|
||||||
const syncAccounts = async (isManual = false) => {
|
const syncAccounts = async (isManual = false) => {
|
||||||
|
console.log(`[Sync] Starting ${isManual ? 'MANUAL' : 'BACKGROUND'} phase 1 (Server Pull)...`);
|
||||||
initBackend();
|
initBackend();
|
||||||
let accounts = store.get('accounts') as Account[];
|
let accounts = store.get('accounts') as Account[];
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
@@ -245,11 +322,12 @@ const syncAccounts = async (isManual = false) => {
|
|||||||
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) { exists.sharedWith = s.sharedWith; hasChanges = true; }
|
if (JSON.stringify(exists.sharedWith) !== JSON.stringify(s.sharedWith)) { exists.sharedWith = s.sharedWith; hasChanges = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { }
|
} catch (e) { console.error('[Sync] Pull failed:', e); }
|
||||||
}
|
}
|
||||||
if (hasChanges) { store.set('accounts', accounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts); updateTrayMenu(); }
|
if (hasChanges) { store.set('accounts', accounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', accounts); updateTrayMenu(); }
|
||||||
|
|
||||||
const runScrapes = async () => {
|
const runScrapes = async () => {
|
||||||
|
console.log(`[Sync] Starting phase 2 (Scrapes) for ${accounts.length} accounts...`);
|
||||||
const currentAccounts = [...store.get('accounts') as Account[]];
|
const currentAccounts = [...store.get('accounts') as Account[]];
|
||||||
let scrapeChanges = false;
|
let scrapeChanges = false;
|
||||||
for (const account of currentAccounts) {
|
for (const account of currentAccounts) {
|
||||||
@@ -267,46 +345,13 @@ const syncAccounts = async (isManual = false) => {
|
|||||||
} catch (error) { }
|
} catch (error) { }
|
||||||
}
|
}
|
||||||
if (scrapeChanges) { store.set('accounts', currentAccounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts); updateTrayMenu(); }
|
if (scrapeChanges) { store.set('accounts', currentAccounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts); updateTrayMenu(); }
|
||||||
|
console.log('[Sync] All phases complete.');
|
||||||
};
|
};
|
||||||
if (isManual) await runScrapes(); else runScrapes();
|
if (isManual) await runScrapes(); else runScrapes();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNextSync = () => { setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000); };
|
const scheduleNextSync = () => { setTimeout(async () => { await syncAccounts(false); scheduleNextSync(); }, isDev ? 300000 : 1800000); };
|
||||||
|
|
||||||
// --- Discovery ---
|
|
||||||
const addingAccounts = new Set<string>();
|
|
||||||
const handleLocalAccountsFound = async (localAccounts: LocalSteamAccount[]) => {
|
|
||||||
const currentAccounts = store.get('accounts') as Account[];
|
|
||||||
let hasChanges = false;
|
|
||||||
for (const local of localAccounts) {
|
|
||||||
if (addingAccounts.has(local.steamId)) continue;
|
|
||||||
const exists = currentAccounts.find(a => a.steamId === local.steamId);
|
|
||||||
if (exists) { if (!exists.loginName && local.accountName) { exists.loginName = local.accountName; hasChanges = true; }
|
|
||||||
} else {
|
|
||||||
addingAccounts.add(local.steamId);
|
|
||||||
try {
|
|
||||||
const profile = await fetchProfileData(local.steamId);
|
|
||||||
const bans = await scrapeBanStatus(profile.profileUrl);
|
|
||||||
const localPath = await downloadAvatar(profile.steamId, profile.avatar);
|
|
||||||
let loginConfig = undefined;
|
|
||||||
for (let i = 0; i < 3; i++) { await new Promise(r => setTimeout(r, 2000)); loginConfig = steamClient.extractAccountConfig(local.accountName); if (loginConfig) break; }
|
|
||||||
currentAccounts.push({
|
|
||||||
_id: Date.now().toString() + Math.random().toString().slice(2, 5),
|
|
||||||
steamId: local.steamId, personaName: profile.personaName || local.accountName,
|
|
||||||
loginName: local.accountName, autoCheckCooldown: false, avatar: profile.avatar,
|
|
||||||
localAvatar: localPath, profileUrl: profile.profileUrl,
|
|
||||||
loginConfig, sessionUpdatedAt: loginConfig ? new Date().toISOString() : undefined,
|
|
||||||
status: (bans.vacBanned || bans.gameBans > 0) ? 'banned' : 'none',
|
|
||||||
vacBanned: bans.vacBanned, gameBans: bans.gameBans, lastBanCheck: new Date().toISOString()
|
|
||||||
});
|
|
||||||
hasChanges = true;
|
|
||||||
} catch (e) { }
|
|
||||||
addingAccounts.delete(local.steamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasChanges) { store.set('accounts', currentAccounts); if (mainWindow) mainWindow.webContents.send('accounts-updated', currentAccounts); updateTrayMenu(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Single Instance & Window ---
|
// --- Single Instance & Window ---
|
||||||
const gotTheLock = app.requestSingleInstanceLock();
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
@@ -329,25 +374,8 @@ if (!gotTheLock) {
|
|||||||
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
|
try { return net.fetch(pathToFileURL(absolutePath).toString()); } catch (e) { return new Response('Error', { status: 500 }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
createWindow();
|
||||||
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
|
createTray();
|
||||||
webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.setMenu(null);
|
|
||||||
|
|
||||||
mainWindow.on('close', (event) => {
|
|
||||||
if (!isQuitting) {
|
|
||||||
event.preventDefault();
|
|
||||||
mainWindow?.hide();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDev) mainWindow.loadURL('http://localhost:5173');
|
|
||||||
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
|
||||||
|
|
||||||
setupTray();
|
|
||||||
initBackend();
|
initBackend();
|
||||||
setTimeout(() => syncAccounts(false), 5000);
|
setTimeout(() => syncAccounts(false), 5000);
|
||||||
scheduleNextSync();
|
scheduleNextSync();
|
||||||
@@ -355,14 +383,43 @@ if (!gotTheLock) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('before-quit', () => { isQuitting = true; if (tray) tray.destroy(); });
|
function createWindow() {
|
||||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin' && isQuitting) app.quit(); });
|
mainWindow = new BrowserWindow({
|
||||||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0 && gotTheLock) { /* Should be handled by ready */ } else mainWindow?.show(); });
|
width: 1280, height: 800, title: "Ultimate Ban Tracker", backgroundColor: '#171a21', autoHideMenuBar: true,
|
||||||
|
webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.setMenu(null);
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow?.hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) mainWindow.loadURL('http://localhost:5173');
|
||||||
|
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
console.log('[App] Preparing to quit...');
|
||||||
|
(app as any).isQuitting = true;
|
||||||
|
if (tray) tray.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => { if (process.platform !== 'darwin' && (app as any).isQuitting) app.quit(); });
|
||||||
|
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0 && gotTheLock) { /* Handled */ } else mainWindow?.show(); });
|
||||||
|
|
||||||
// Handle terminal termination (Ctrl+C / SIGTERM)
|
// Handle terminal termination (Ctrl+C / SIGTERM)
|
||||||
const handleSignal = (signal: string) => {
|
const handleSignal = (signal: string) => {
|
||||||
if (!isQuitting && mainWindow) { mainWindow.hide(); }
|
console.log(`[App] Received ${signal}`);
|
||||||
else if (isQuitting) { app.quit(); }
|
if (!(app as any).isQuitting && mainWindow) {
|
||||||
|
console.log(`[App] Hiding window instead of quitting due to ${signal}`);
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
else if ((app as any).isQuitting) { app.quit(); }
|
||||||
};
|
};
|
||||||
process.on('SIGINT', () => handleSignal('SIGINT'));
|
process.on('SIGINT', () => handleSignal('SIGINT'));
|
||||||
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
||||||
@@ -472,7 +529,7 @@ ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBacke
|
|||||||
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
|
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
|
||||||
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
|
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
|
||||||
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
|
ipcMain.handle('force-sync', async () => { await syncAccounts(true); return true; });
|
||||||
ipcMain.handle('update-app-icon', (event, themeName: string) => { /* Icon handled by setupTray/createWindow */ return true; });
|
ipcMain.handle('update-app-icon', (event, themeName: string) => { setAppIcon(themeName); return true; });
|
||||||
ipcMain.handle('switch-account', async (event, loginName: string) => {
|
ipcMain.handle('switch-account', async (event, loginName: string) => {
|
||||||
if (!loginName) return false;
|
if (!loginName) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user