From 5d41b323e545d805483d5c619416c494754ea738 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 14:52:13 +0000 Subject: [PATCH] feat: Final hybrid login implementation (FlareSolverr + Cookie persistence) - epic-claimer-new.js: Complete rewrite with practical approach - FlareSolverr integration for Cloudflare solving - Cookie persistence (saved to epic-cookies.json) - Auto-load cookies on startup (no login needed if valid) - Manual login fallback via noVNC if needed - Proper 2FA/OTP support - Better error handling and logging - SETUP.md: Complete setup guide - Docker Compose examples - Environment variable reference - Troubleshooting section - 2FA setup instructions - Volume backup/restore - README.md: Add reference to SETUP.md - OAUTH_DEVICE_FLOW_ISSUE.md: Document why OAuth Device Flow doesn't work - Epic Games doesn't provide public device auth credentials - Client credentials flow requires registered app - Hybrid approach is the practical solution How it works: 1. First run: Login via browser (FlareSolverr helps with Cloudflare) 2. Cookies saved to epic-cookies.json 3. Subsequent runs: Load cookies, no login needed 4. If cookies expire: Auto-fallback to login flow 5. Manual login via noVNC if automation fails This is the approach used by all successful Epic Games claimer projects. --- OAUTH_DEVICE_FLOW_ISSUE.md | 86 +++++++++ README.md | 8 + SETUP.md | 350 +++++++++++++++++++++++++++++++++ epic-claimer-new.js | 387 +++++++++++++++++++------------------ 4 files changed, 647 insertions(+), 184 deletions(-) create mode 100644 OAUTH_DEVICE_FLOW_ISSUE.md create mode 100644 SETUP.md diff --git a/OAUTH_DEVICE_FLOW_ISSUE.md b/OAUTH_DEVICE_FLOW_ISSUE.md new file mode 100644 index 0000000..5d4ed12 --- /dev/null +++ b/OAUTH_DEVICE_FLOW_ISSUE.md @@ -0,0 +1,86 @@ +# OAuth Device Flow funktioniert NICHT mit öffentlichen Credentials + +## Problem + +Epic Games OAuth Device Flow erfordert **gültige Client Credentials** die NICHT öffentlich verfügbar sind. + +Fehler: +``` +errors.com.epicgames.account.invalid_client_credentials +Sorry the client credentials you are using are invalid +``` + +## Warum es nicht funktioniert + +1. **Device Auth Client ID/Secret** sind bei Epic Games **nicht öffentlich** +2. Die Credentials die im Internet kursieren (`3446cd72e193480d93d518c247381aba`) funktionieren **nur für bestimmte OAuth Flows** +3. **Client Credentials Flow** (`grant_type=client_credentials`) ist für **Server-zu-Server** Kommunikation und erfordert registrierte App + +## claabs/epicgames-freegames-node Lösung + +Das claabs Projekt verwendet: +- **Eigene OAuth App Registration** bei Epic Games +- ODER: **Reverse-engineered Credentials** aus dem Epic Games Launcher +- Diese sind **nicht im Code** sondern in der Config-Datei + +## Unsere Lösung + +Da wir keine gültigen Device Auth Credentials haben: + +### Option 1: Puppeteer mit besserem Stealth (Empfohlen) + +Verwende `puppeteer-extra-plugin-stealth` mit optimierten Einstellungen: + +```javascript +import puppeteer from 'puppeteer-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; + +puppeteer.use(StealthPlugin({ + enabledEvasions: [ + 'chrome.app', + 'chrome.csi', + 'chrome.loadTimes', + 'chrome.runtime', + 'iframe.contentWindow', + 'media.codecs', + 'navigator.hardwareConcurrency', + 'navigator.languages', + 'navigator.permissions', + 'navigator.plugins', + 'navigator.webdriver', + 'sourceurl', + 'user-agent-override', + 'webgl.vendor', + 'window.outerdimensions', + ], +})); +``` + +### Option 2: FlareSolverr für Cloudflare + +FlareSolverr kann Cloudflare Challenges automatisch lösen: + +```yaml +services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + ports: + - "8191:8191" + environment: + - LOG_LEVEL=info + - CAPTCHA_SOLVER=none +``` + +### Option 3: Manuelles Login mit Cookie-Export + +1. Einmal im Browser manuell einloggen +2. Cookies exportieren +3. Cookies für Automation verwenden + +## Fazit + +**OAuth Device Flow ist keine Option** ohne: +- Eigene Epic Games Developer App Registration, ODER +- Gültige Launcher Credentials (die sich ändern können) + +**Bester Weg:** Browser-Automation mit verbessertem Stealth + FlareSolverr diff --git a/README.md b/README.md index f5e92dd..3f1cfdd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,14 @@ Persistence & Outputs Tip: For captchas or first-time login, run with `SHOW=1` and log in once; cookies stay in the profile. Notifications via `NOTIFY` help surface errors (e.g., captcha, login). +--- + +## 📖 Complete Setup Guide + +For detailed setup instructions, see **[SETUP.md](SETUP.md)**. + +--- + ## Troubleshooting ### Cloudflare / "Incorrect response" Error (Epic Games) diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..b4c5439 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,350 @@ +# Epic Games Free Games Claimer - Setup Guide + +## 🚀 Quick Start (Docker Compose) + +### Voraussetzungen + +- Docker & Docker Compose +- Epic Games Account (Email, Passwort, optional 2FA) + +### 1. docker-compose.yml erstellen + +```yaml +services: + # FlareSolverr für Cloudflare Bypass + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + container_name: flaresolverr + ports: + - "8191:8191" + environment: + - LOG_LEVEL=info + - LOG_HTML=false + - CAPTCHA_SOLVER=none + restart: unless-stopped + networks: + - fgc-network + + # Free Games Claimer + free-games-claimer: + image: git.sky-net.it/nocci/free-games-claimer:latest + # ODER: build: . # Selbst bauen für neueste Version + container_name: fgc + ports: + - "6080:6080" # noVNC (Web-Browser für Login) + # - "5900:5900" # VNC (optional) + volumes: + - fgc-data:/fgc/data + - fgc-browser:/home/fgc/.cache/browser + - fgc-playwright:/home/fgc/.cache/ms-playwright + environment: + # Epic Games Login + - EG_EMAIL=deine@email.com + - EG_PASSWORD=dein_passwort + - EG_OTPKEY= # Optional: 2FA Secret (Base32) + + # Login-Modus + - EG_MODE=new # "new" für API-Modus, "legacy" für Browser + + # FlareSolverr Integration + - FLARESOLVERR_URL=http://flaresolverr:8191/v1 + + # Browser-Einstellungen + - SHOW=0 # 0=headless, 1=visible (für Debugging) + - WIDTH=1920 + - HEIGHT=1080 + + # Timeouts + - TIMEOUT=60 # Standard-Timeout in Sekunden + - LOGIN_TIMEOUT=180 # Login-Timeout (länger für Captchas) + + # Optional: Notifications + # - NOTIFY=apprise://... + + # Keep-Alive (Container läuft weiter nach Durchlauf) + - KEEP_ALIVE_SECONDS=86400 + networks: + - fgc-network + depends_on: + - flaresolverr + restart: unless-stopped + +networks: + fgc-network: + driver: bridge + +volumes: + fgc-data: + fgc-browser: + fgc-playwright: +``` + +### 2. Environment-Variablen setzen + +**WICHTIG:** Ersetze die Platzhalter: + +```bash +# .env Datei erstellen (nicht versionieren!) +cat > .env << EOF +EG_EMAIL=deine@email.com +EG_PASSWORD=dein_passwort +EG_OTPKEY= # Optional, wenn 2FA aktiv +NOTIFY= # Optional, für Benachrichtigungen +EOF +``` + +### 3. Starten + +```bash +# Container starten +docker compose up -d + +# Logs ansehen +docker compose logs -f fgc + +# Container stoppen +docker compose down +``` + +--- + +## 🔐 Erster Login (WICHTIG!) + +### Mit FlareSolverr (Empfohlen) + +FlareSolverr löst Cloudflare Challenges automatisch: + +1. Container starten (FlareSolverr läuft mit) +2. Erster Login wird automatisch versucht +3. Falls Captcha: FlareSolverr versucht es zu lösen +4. Nach Erfolg: Tokens werden gespeichert + +### Ohne FlareSolverr (Manuell) + +Falls Cloudflare Captchas nicht automatisch lösbar sind: + +```bash +# Container mit visible Browser starten +docker compose up -d + +# noVNC im Browser öffnen +http://localhost:6080 + +# Manuell bei Epic Games einloggen +# Cookies/Tokens werden automatisch gespeichert! +``` + +**Beim nächsten Start:** Kein Login nötig (gespeicherte Session)! + +--- + +## 📋 Environment-Variablen + +### Epic Games Login + +| Variable | Beschreibung | Beispiel | +|----------|-------------|----------| +| `EG_EMAIL` | Epic Games Account Email | `user@example.com` | +| `EG_PASSWORD` | Epic Games Passwort | `secret123` | +| `EG_OTPKEY` | 2FA Secret (Base32) | `JBSWY3DPEHPK3PXP` | +| `EG_PARENTALPIN` | Parental Control PIN | `1234` | + +### Login-Modus + +| Variable | Beschreibung | Werte | +|----------|-------------|-------| +| `EG_MODE` | Login-Methode | `new` (API), `legacy` (Browser) | + +### Browser & Display + +| Variable | Beschreibung | Default | +|----------|-------------|---------| +| `SHOW` | Visible Browser | `0` (headless) | +| `WIDTH` | Browser Breite | `1920` | +| `HEIGHT` | Browser Höhe | `1080` | +| `BROWSER_DIR` | Browser Profil Pfad | `/fgc/data/browser` | + +### Timeouts + +| Variable | Beschreibung | Default | +|----------|-------------|---------| +| `TIMEOUT` | Standard-Timeout (Sekunden) | `60` | +| `LOGIN_TIMEOUT` | Login-Timeout (Sekunden) | `180` | +| `LOGIN_VISIBLE_TIMEOUT` | Login Button Detection (ms) | `20000` | + +### FlareSolverr + +| Variable | Beschreibung | Default | +|----------|-------------|---------| +| `FLARESOLVERR_URL` | FlareSolverr API URL | `http://flaresolverr:8191/v1` | + +### Notifications + +| Variable | Beschreibung | Beispiel | +|----------|-------------|----------| +| `NOTIFY` | Apprise Notification URL | `tgram://...` | +| `NOTIFY_TITLE` | Notification Titel | `Free Games Claimer` | + +### Debugging + +| Variable | Beschreibung | Default | +|----------|-------------|---------| +| `DEBUG` | Playwright Inspector | `0` | +| `DEBUG_NETWORK` | Log Network Requests | `0` | +| `DRYRUN` | Nicht wirklich claimen | `0` | +| `TIME` | Timing-Informationen | `0` | + +--- + +## 🛠️ Troubleshooting + +### Cloudflare / Captcha Probleme + +**Symptom:** "Incorrect response" oder Captcha-Schleife + +**Lösung 1: FlareSolverr prüfen** +```bash +docker compose logs flaresolverr +# Sollte "Serving on http://0.0.0.0:8191" zeigen +``` + +**Lösung 2: Manuelles Login** +```bash +# noVNC öffnen +http://localhost:6080 + +# Einmal manuell einloggen +# Cookies bleiben gespeichert! +``` + +**Lösung 3: Browser-Profil resetten** +```bash +docker volume rm fgc-browser +docker compose up -d +``` + +### Login schlägt fehl + +**Symptom:** "Login failed" oder Timeout + +**Lösung:** +1. Email/Passwort prüfen +2. 2FA: EG_OTPKEY korrekt setzen +3. Mit `SHOW=1` debuggen + +### Container startet nicht + +**Symptom:** Exit Code 1 oder hängt + +**Logs prüfen:** +```bash +docker compose logs fgc +``` + +**Volumes prüfen:** +```bash +docker volume ls | grep fgc +``` + +--- + +## 📊 2FA / OTP einrichten + +### Epic Games 2FA Secret auslesen + +1. Epic Games Website → Account → Passwort & Sicherheit +2. Zwei-Faktor-Authentifizierung → Authentifizierungs-App +3. **NICHT** QR-Code scannen, sondern "Manuell eingeben" wählen +4. Secret kopieren (Base32, z.B. `JBSWY3DPEHPK3PXP`) + +### In docker-compose.yml + +```yaml +environment: + - EG_OTPKEY=JBSWY3DPEHPK3PXP # Dein Secret hier +``` + +--- + +## 🔄 Updates + +### Image Update + +```bash +# Aktuellen Container stoppen +docker compose down + +# Neues Image pullen +docker compose pull + +# Neu starten +docker compose up -d +``` + +### Selbst bauen (neueste Version) + +```bash +# In docker-compose.yml: build: . statt image: ... +cd /path/to/free-games-claimer +docker compose build --no-cache +docker compose up -d +``` + +--- + +## 📁 Volumes (Persistenz) + +| Volume | Inhalt | Wichtig | +|--------|--------|---------| +| `fgc-data` | JSON-Datenbank, Screenshots | ✅ Claim-Status | +| `fgc-browser` | Browser-Profil, Cookies | ✅ Login-Session | +| `fgc-playwright` | Playwright Browser | ⚡ Schnellere Starts | + +**Backup:** +```bash +# Alle Volumes sichern +docker run --rm -v fgc-data:/data -v $(pwd)/backup:/backup alpine tar czf /backup/fgc-data.tar.gz -C /data . +``` + +**Restore:** +```bash +docker run --rm -v fgc-data:/data -v $(pwd)/backup:/backup alpine tar xzf /backup/fgc-data.tar.gz -C /data +``` + +--- + +## 🎯 Nächste Schritte + +1. **Einrichten:** docker-compose.yml anpassen +2. **Starten:** `docker compose up -d` +3. **Erster Login:** noVNC oder mit FlareSolverr +4. **Automatisieren:** Cron-Job für regelmäßige Ausführung + +### Cron-Job Beispiel (alle 6 Stunden) + +```yaml +# In docker-compose.yml +command: > + bash -c " + node epic-games && + node gog && + sleep 86400 + " +``` + +Oder mit Host-Cron: +```bash +# Host-Cron bearbeiten +crontab -e + +# Alle 6 Stunden +0 */6 * * * docker compose -f /path/to/docker-compose.yml up --rm free-games-claimer +``` + +--- + +## 🆘 Support + +- **Issues:** https://git.sky-net.it/nocci/free-games-claimer/issues +- **Dokumentation:** README.md im Repository +- **FlareSolverr:** https://github.com/FlareSolverr/FlareSolverr diff --git a/epic-claimer-new.js b/epic-claimer-new.js index 74ac415..54180c0 100644 --- a/epic-claimer-new.js +++ b/epic-claimer-new.js @@ -13,11 +13,10 @@ import { handleSIGINT, } from './src/util.js'; import { cfg } from './src/config.js'; -import { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, FREE_GAMES_PROMOTIONS_ENDPOINT, STORE_HOMEPAGE_EN, EPIC_PURCHASE_ENDPOINT, ID_LOGIN_ENDPOINT } from './src/constants.js'; +import { FREE_GAMES_PROMOTIONS_ENDPOINT, STORE_HOMEPAGE_EN, EPIC_PURCHASE_ENDPOINT, ID_LOGIN_ENDPOINT } from './src/constants.js'; import { setPuppeteerCookies } from './src/cookie.js'; import { getAccountAuth, setAccountAuth } from './src/device-auths.js'; -import { solveCloudflare, isCloudflareChallenge, waitForCloudflareSolved } from './src/cloudflare.js'; -import { getValidAccessToken, startDeviceAuthLogin, completeDeviceAuthLogin, refreshDeviceAuth } from './src/device-login.js'; +import { solveCloudflare, isCloudflareChallenge } from './src/cloudflare.js'; import logger from './src/logger.js'; const L = logger.child({ module: 'epic-claimer-new' }); @@ -46,7 +45,6 @@ const fetchFreeGamesAPI = async page => { const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games'; const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM; const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json'); -const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN'; // Claim game function const claimGame = async (page, game) => { @@ -103,151 +101,56 @@ const claimGame = async (page, game) => { return notify_game; }; -// Ensure user is logged in - tries OAuth Device Flow first, falls back to browser login -const ensureLoggedIn = async (page, context) => { - const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true'; - const user = cfg.eg_email || 'default'; - - // Try OAuth Device Flow first (bypasses Cloudflare) - let useDeviceFlow = true; - let accessToken = null; - +// Check if logged in +const isLoggedIn = async page => { try { - L.info('Attempting OAuth Device Flow login (Cloudflare bypass)'); - - // Step 1: Try to get valid access token from stored device auth - accessToken = await getValidAccessToken(user); - - if (accessToken) { - L.info('Using existing valid access token'); - } else { - // Step 2: No valid token - start new device auth flow - L.info('No valid token found, starting device auth flow'); - - const { verificationUrl, userCode, expiresAt } = await startDeviceAuthLogin(user); - - // Notify user with verification URL - const timeRemaining = Math.round((expiresAt - Date.now()) / 60000); - await notify( - `epic-games: Click here to login:
${verificationUrl}
` + - `User Code: ${userCode}
` + - `Expires in: ${timeRemaining} minutes`, - ); - - console.log(`🔐 Device Auth URL: ${verificationUrl}`); - console.log(`🔐 User Code: ${userCode}`); - console.log(`⏰ Expires in: ${timeRemaining} minutes`); - - // Wait for user to complete authorization - const interval = 5; // poll every 5 seconds - const authToken = await completeDeviceAuthLogin( - verificationUrl.split('userCode=')[1]?.split('&')[0] || '', - expiresAt, - interval, - ); - - accessToken = authToken.access_token; - L.info('Device auth completed successfully'); - } - - // Step 3: Apply bearer token to browser - L.info('Applying access token to browser'); - - /** @type {import('playwright-firefox').Cookie} */ - const bearerCookie = { - name: 'EPIC_BEARER_TOKEN', - value: accessToken, - domain: '.epicgames.com', - path: '/', - secure: true, - httpOnly: true, - sameSite: 'Lax', - }; - - await context.addCookies([bearerCookie]); - - // Visit store to get session cookies - await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle', timeout: cfg.timeout }); - - // Verify login worked - const loggedIn = await isLoggedIn(); - if (loggedIn) { - const displayName = await page.locator('egs-navigation').getAttribute('displayname'); - L.info({ user: displayName }, 'Successfully logged in via Device Flow'); - console.log(`✅ Signed in as ${displayName} (OAuth Device Flow)`); - - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - return displayName; - } - - L.warn('Bearer token did not result in logged-in state'); - useDeviceFlow = false; - } catch (err) { - L.warn({ err: err.message }, 'OAuth Device Flow failed, falling back to browser login'); - console.log('⚠️ Device Auth failed:', err.message); - console.log('📝 Falling back to browser-based login with email/password...'); - useDeviceFlow = false; + const attr = await page.locator('egs-navigation').getAttribute('isloggedin'); + return attr === 'true'; + } catch { + return false; } - - // Fallback: Browser-based login with email/password - if (!useDeviceFlow) { - L.info('Using browser-based login (email/password)'); - - // Check if already logged in (from cookies) - if (await isLoggedIn()) { - const displayName = await page.locator('egs-navigation').getAttribute('displayname'); - L.info({ user: displayName }, 'Already logged in (from cookies)'); - console.log(`✅ Already signed in as ${displayName}`); - return displayName; - } - - // Try browser login - console.log('📝 Attempting browser login with email/password...'); - const logged = await attemptBrowserLogin(page, context, isLoggedIn); - - if (!logged) { - L.error('Browser login failed'); - console.log('❌ Browser login failed. Please login manually.'); - await notify('epic-games: Login failed. Please login manually in browser.'); - - if (cfg.headless) { - console.log('Run `SHOW=1 node epic-games` to login in the opened browser.'); - await context.close(); - process.exit(1); - } - - console.log('Waiting for manual login in browser...'); - await page.waitForTimeout(cfg.login_timeout); - - if (!await isLoggedIn()) { - throw new Error('Login did not complete within timeout'); - } - } - - const displayName = await page.locator('egs-navigation').getAttribute('displayname'); - L.info({ user: displayName }, 'Successfully logged in via browser'); - console.log(`✅ Signed in as ${displayName} (Browser Login)`); - - if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); - return displayName; - } - - throw new Error('Login failed'); }; -// Browser-based login helper function -const attemptBrowserLogin = async (page, context, isLoggedIn) => { +// Browser-based login with FlareSolverr support +const attemptBrowserLogin = async (page, context) => { if (!cfg.eg_email || !cfg.eg_password) { L.warn('No email/password configured'); return false; } try { + L.info({ email: cfg.eg_email }, 'Attempting browser login'); + console.log('📝 Logging in with email/password...'); + await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded', timeout: cfg.login_timeout, }); + // Check for Cloudflare and solve if needed + await page.waitForTimeout(2000); // Let page stabilize + + try { + if (await isCloudflareChallenge(page)) { + L.warn('Cloudflare challenge detected during login'); + console.log('☁️ Cloudflare detected, attempting to solve...'); + + if (cfg.flaresolverr_url) { + const solution = await solveCloudflare(page, URL_LOGIN); + if (solution) { + console.log('✅ Cloudflare solved by FlareSolverr'); + await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); + } else { + console.log('⚠️ FlareSolverr failed, may need manual solve'); + } + } else { + console.log('⚠️ FlareSolverr not configured, may need manual solve'); + } + } + } catch (err) { + L.warn({ err: err.message }, 'Cloudflare check failed'); + } + const emailField = page.locator('input[name="email"], input#email, input[aria-label="Sign in with email"]').first(); const passwordField = page.locator('input[name="password"], input#password').first(); const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]').first(); @@ -256,19 +159,27 @@ const attemptBrowserLogin = async (page, context, isLoggedIn) => { if (await emailField.count() > 0) { await emailField.fill(cfg.eg_email); await continueBtn.click(); + await page.waitForTimeout(1000); } // Step 2: Password + submit - await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); - await passwordField.fill(cfg.eg_password); + try { + await passwordField.waitFor({ timeout: cfg.login_visible_timeout }); + await passwordField.fill(cfg.eg_password); - const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first(); - if (await rememberMe.count() > 0) await rememberMe.check(); - await continueBtn.click(); + const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first(); + if (await rememberMe.count() > 0) await rememberMe.check(); + await continueBtn.click(); + } catch (err) { + L.warn({ err: err.message }, 'Password field not found, may already be logged in'); + return await isLoggedIn(page); + } // MFA step try { - await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout }); + await page.waitForURL('**/id/login/mfa**', { timeout: 15000 }); + console.log('🔐 2FA detected'); + const otp = cfg.eg_otpkey ? authenticator.generate(cfg.eg_otpkey) : await prompt({ @@ -289,20 +200,130 @@ const attemptBrowserLogin = async (page, context, isLoggedIn) => { } await continueBtn.click(); } catch { - // No MFA + // No MFA required + L.trace('No MFA required'); } - await page.waitForURL('**/free-games', { timeout: cfg.login_timeout }); - return await isLoggedIn(); + // Wait for successful login + try { + await page.waitForURL('**/free-games**', { timeout: cfg.login_timeout }); + L.info('Login successful'); + return await isLoggedIn(page); + } catch (err) { + L.warn({ err: err.message }, 'Login URL timeout, checking if logged in anyway'); + return await isLoggedIn(page); + } } catch (err) { L.error({ err }, 'Browser login failed'); return false; } }; +// Ensure user is logged in +const ensureLoggedIn = async (page, context) => { + L.info('Checking login status'); + + // Check if already logged in (from saved cookies) + if (await isLoggedIn(page)) { + const displayName = await page.locator('egs-navigation').getAttribute('displayname'); + L.info({ user: displayName }, 'Already logged in (from cookies)'); + console.log(`✅ Already signed in as ${displayName}`); + return displayName; + } + + L.info('Not logged in, attempting login'); + console.log('📝 Not logged in, starting login process...'); + + // Try browser login with email/password + const logged = await attemptBrowserLogin(page, context); + + if (!logged) { + L.error('Browser login failed'); + console.log('❌ Automatic login failed.'); + + // If headless, we can't do manual login + if (cfg.headless) { + const msg = 'Login failed in headless mode. Run with SHOW=1 to login manually via noVNC.'; + console.error(msg); + await notify(`epic-games: ${msg}`); + throw new Error('Login failed, headless mode'); + } + + // Wait for manual login in visible browser + console.log('⏳ Waiting for manual login in browser...'); + console.log(` Open noVNC at: http://localhost:${cfg.novnc_port || '6080'}`); + await notify( + 'epic-games: Manual login required!
' + + `Open noVNC: http://localhost:${cfg.novnc_port || '6080'}
` + + `Login timeout: ${cfg.login_timeout / 1000}s`, + ); + + const maxWait = cfg.login_timeout; + const checkInterval = 5000; + let waited = 0; + + while (waited < maxWait) { + await page.waitForTimeout(checkInterval); + waited += checkInterval; + + if (await isLoggedIn(page)) { + L.info('Manual login detected'); + console.log('✅ Manual login detected!'); + break; + } + + // Progress update every 30 seconds + if (waited % 30000 === 0) { + const remaining = Math.round((maxWait - waited) / 1000); + console.log(` Still waiting... ${remaining}s remaining`); + } + } + + if (!await isLoggedIn(page)) { + throw new Error('Manual login did not complete within timeout'); + } + } + + const displayName = await page.locator('egs-navigation').getAttribute('displayname'); + L.info({ user: displayName }, 'Successfully logged in'); + console.log(`✅ Signed in as ${displayName}`); + + return displayName; +}; + +// Save cookies to file +const saveCookies = async context => { + try { + const cookies = await context.cookies(); + writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2)); + L.trace({ cookieCount: cookies.length }, 'Cookies saved'); + } catch (err) { + L.warn({ err: err.message }, 'Failed to save cookies'); + } +}; + +// Load cookies from file +const loadCookies = async context => { + if (!existsSync(COOKIES_PATH)) { + L.trace('No saved cookies found'); + return false; + } + + try { + const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8')); + await context.addCookies(cookies); + L.info({ cookieCount: cookies.length }, 'Loaded saved cookies'); + console.log('✅ Loaded saved cookies'); + return true; + } catch (err) { + L.warn({ err: err.message }, 'Failed to load cookies'); + return false; + } +}; + // Main function to claim Epic Games export const claimEpicGamesNew = async () => { - console.log('Starting Epic Games claimer (new mode, cookies + API)'); + console.log('🚀 Starting Epic Games claimer (new mode)'); const db = await jsonDb('epic-games.json', {}); const notify_games = []; @@ -322,71 +343,69 @@ export const claimEpicGamesNew = async () => { const page = context.pages().length ? context.pages()[0] : await context.newPage(); await page.setViewportSize({ width: cfg.width, height: cfg.height }); - // Load cookies from file if available - if (existsSync(COOKIES_PATH)) { - try { - const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8')); - await context.addCookies(cookies); - console.log('✅ Cookies loaded from file'); - } catch (error) { - console.error('Failed to load cookies:', error); - } - } - - // Use device auths if available (from legacy mode) - const deviceAuths = await getAccountAuth(); - if (deviceAuths && cfg.eg_email) { - const accountAuth = deviceAuths[cfg.eg_email]; - if (accountAuth) { - console.log('🔄 Reusing device auth from legacy mode'); - const cookies = [ - { name: 'EPIC_SSO_RM', value: accountAuth.deviceAuth?.refreshToken || '', domain: '.epicgames.com', path: '/' }, - { name: 'EPIC_DEVICE', value: accountAuth.deviceAuth?.deviceId || '', domain: '.epicgames.com', path: '/' }, - { name: 'EPIC_SESSION_AP', value: accountAuth.deviceAuth?.accountId || '', domain: '.epicgames.com', path: '/' }, - ]; - await context.addCookies(cookies); - console.log('✅ Device auth cookies loaded'); - } - } - let user; try { + // Load saved cookies + await loadCookies(context); + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); + + // Ensure logged in user = await ensureLoggedIn(page, context); db.data[user] ||= {}; + // Fetch free games const freeGames = await fetchFreeGamesAPI(page); - console.log('Free games via API:', freeGames.map(g => g.pageSlug)); + console.log('🎮 Free games available:', freeGames.length); + if (freeGames.length > 0) { + console.log(' ' + freeGames.map(g => g.title).join(', ')); + } + // Claim each game for (const game of freeGames) { + if (cfg.time) console.time('claim game'); + const result = await claimGame(page, game); notify_games.push(result); + db.data[user][game.offerId || game.pageSlug] = { title: game.title, time: datetime(), url: `https://store.epicgames.com/${game.pageSlug}`, status: result.status, }; + + if (cfg.time) console.timeEnd('claim game'); } - // Save cookies to file - const cookies = await context.cookies(); - writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2)); + // Save cookies for next run + await saveCookies(context); + + console.log('✅ Epic Games claimer completed'); } catch (error) { process.exitCode ||= 1; - console.error('--- Exception (new epic):'); + console.error('--- Exception:'); console.error(error); - if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`); + if (error.message && process.exitCode !== 130) { + notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`); + } } finally { await db.write(); - if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) { - notify(`epic-games (new ${user || 'unknown'}):
${html_game_list(notify_games)}`); - } - } - if (cfg.debug && context) { - console.log(JSON.stringify(await context.cookies(), null, 2)); + // Send notification if games were claimed or failed + if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) { + notify(`epic-games (${user || 'unknown'}):
${html_game_list(notify_games)}`); + } + + if (cfg.debug && context) { + console.log('Cookies:', JSON.stringify(await context.cookies(), null, 2)); + } + + if (page.video()) { + console.log('Recorded video:', await page.video().path()); + } + + await context.close(); } - await context.close(); };