- Electron: Fängt GOG Redirect automatisch ab (will-redirect Interceptor) - Code-Exchange passiert im Hintergrund, User sieht nur Erfolgs-Popup - GOG Auth-URLs (auth.gog.com, login.gog.com, embed.gog.com) in Popup erlaubt - Server: GET /gog/login Redirect + POST /gog/exchange Endpoint - Browser-Fallback: Code-Paste Dialog falls nicht in Electron - gog.ts: Feste redirect_uri (embed.gog.com/on_login_success) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
8.7 KiB
TypeScript
289 lines
8.7 KiB
TypeScript
// ──────────────────────────────────────────────────────────────────────────────
|
|
// GOG (Good Old Games) API service
|
|
// Uses GOG OAuth2 for user authentication. All paginated requests include a
|
|
// 500 ms pause between pages to respect rate-limits.
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
const GOG_CLIENT_ID = '46899977096215655';
|
|
const GOG_CLIENT_SECRET =
|
|
'9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571';
|
|
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
|
|
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
|
|
const GOG_EMBED_URL = 'https://embed.gog.com';
|
|
const GOG_REDIRECT_URI = 'https://embed.gog.com/on_login_success?origin=client';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface GogTokens {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresAt: number; // unix-ms
|
|
userId: string;
|
|
}
|
|
|
|
export interface GogUserInfo {
|
|
userId: string;
|
|
username: string;
|
|
avatarUrl: string;
|
|
}
|
|
|
|
export interface GogGame {
|
|
gogId: number;
|
|
title: string;
|
|
image: string; // cover URL from GOG
|
|
slug: string;
|
|
}
|
|
|
|
// ── OAuth helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build the GOG OAuth authorization URL that the user should visit to grant
|
|
* access. After approval, GOG redirects to `embed.gog.com/on_login_success`
|
|
* with a `code` query parameter that the user copies back into the app.
|
|
*/
|
|
export function getGogAuthUrl(): string {
|
|
const params = new URLSearchParams({
|
|
client_id: GOG_CLIENT_ID,
|
|
redirect_uri: GOG_REDIRECT_URI,
|
|
response_type: 'code',
|
|
layout: 'client2',
|
|
});
|
|
|
|
return `${GOG_AUTH_URL}?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* Exchange an authorization code for GOG access + refresh tokens.
|
|
* Uses the fixed GOG_REDIRECT_URI that matches the OAuth registration.
|
|
*/
|
|
export async function exchangeGogCode(
|
|
code: string,
|
|
): Promise<GogTokens> {
|
|
console.log('[GOG] Exchanging authorization code for tokens...');
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: GOG_CLIENT_ID,
|
|
client_secret: GOG_CLIENT_SECRET,
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: GOG_REDIRECT_URI,
|
|
});
|
|
|
|
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
console.error(`[GOG] Token exchange failed (${res.status}): ${text}`);
|
|
throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
expires_in: number;
|
|
user_id: string;
|
|
token_type: string;
|
|
};
|
|
|
|
const tokens: GogTokens = {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
userId: data.user_id,
|
|
};
|
|
|
|
console.log(
|
|
`[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
|
|
);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Obtain a fresh set of tokens using an existing refresh token.
|
|
*/
|
|
export async function refreshGogToken(
|
|
refreshToken: string,
|
|
): Promise<GogTokens> {
|
|
console.log('[GOG] Refreshing access token...');
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: GOG_CLIENT_ID,
|
|
client_secret: GOG_CLIENT_SECRET,
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
});
|
|
|
|
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
console.error(`[GOG] Token refresh failed (${res.status}): ${text}`);
|
|
throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
expires_in: number;
|
|
user_id: string;
|
|
token_type: string;
|
|
};
|
|
|
|
const tokens: GogTokens = {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
userId: data.user_id,
|
|
};
|
|
|
|
console.log(
|
|
`[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
|
|
);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
// ── User info ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch basic user profile information from the GOG embed API.
|
|
*/
|
|
export async function fetchGogUserInfo(
|
|
accessToken: string,
|
|
): Promise<GogUserInfo> {
|
|
console.log('[GOG] Fetching user info...');
|
|
|
|
const res = await fetch(`${GOG_EMBED_URL}/userData.json`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
console.error(`[GOG] User info request failed (${res.status}): ${text}`);
|
|
throw new Error(`[GOG] User info request failed (${res.status}): ${text}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
userId: string;
|
|
username: string;
|
|
galaxyUserId: string;
|
|
avatar: string;
|
|
};
|
|
|
|
const userInfo: GogUserInfo = {
|
|
userId: data.galaxyUserId || data.userId,
|
|
username: data.username,
|
|
avatarUrl: data.avatar ? `https:${data.avatar}` : '',
|
|
};
|
|
|
|
console.log(`[GOG] User info fetched: ${userInfo.username}`);
|
|
|
|
return userInfo;
|
|
}
|
|
|
|
// ── Game library ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch the complete list of owned GOG games for the authenticated user.
|
|
*
|
|
* The GOG embed API paginates results (one page at a time). We iterate
|
|
* through all pages with a 500 ms pause between requests to stay friendly.
|
|
*/
|
|
export async function fetchGogGames(
|
|
accessToken: string,
|
|
): Promise<GogGame[]> {
|
|
console.log('[GOG] Fetching game library...');
|
|
|
|
const allGames: GogGame[] = [];
|
|
let currentPage = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`;
|
|
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
console.error(
|
|
`[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`,
|
|
);
|
|
throw new Error(
|
|
`[GOG] Game library request failed (${res.status}): ${text}`,
|
|
);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
totalPages: number;
|
|
products: Array<{
|
|
id: number;
|
|
title: string;
|
|
image: string;
|
|
slug: string;
|
|
}>;
|
|
};
|
|
|
|
totalPages = data.totalPages;
|
|
|
|
for (const product of data.products) {
|
|
allGames.push({
|
|
gogId: product.id,
|
|
title: product.title,
|
|
image: product.image ? `https:${product.image}` : '',
|
|
slug: product.slug,
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`,
|
|
);
|
|
|
|
currentPage++;
|
|
|
|
// Rate-limit: 500 ms between page requests
|
|
if (currentPage <= totalPages) {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
} while (currentPage <= totalPages);
|
|
|
|
console.log(`[GOG] Library complete: ${allGames.length} games total`);
|
|
|
|
return allGames;
|
|
}
|
|
|
|
// ── Cover URL helper ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Convert a GOG image base path into a full HTTPS cover URL.
|
|
*
|
|
* GOG images are returned as protocol-relative paths like
|
|
* `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally
|
|
* appends a size suffix (e.g. `_196.jpg`).
|
|
*
|
|
* @param imageBase The raw image path returned by the GOG API
|
|
* @param size Optional size suffix to append (e.g. `_196.jpg`)
|
|
*/
|
|
export function gogCoverUrl(imageBase: string, size?: string): string {
|
|
let url = imageBase;
|
|
|
|
// Ensure HTTPS prefix
|
|
if (url.startsWith('//')) {
|
|
url = `https:${url}`;
|
|
} else if (!url.startsWith('http')) {
|
|
url = `https://${url}`;
|
|
}
|
|
|
|
// Append size suffix if provided
|
|
if (size) {
|
|
url = `${url}${size}`;
|
|
}
|
|
|
|
return url;
|
|
}
|