diff --git a/package-lock.json b/package-lock.json index 100b28082..4cdb7692a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "classnames": "^2.2.6", "clean-webpack-plugin": "^3.0.0", "colors": "^1.4.0", + "crc": "^4.3.2", "crc-32": "^1.2.0", "fast-memoize": "^2.5.2", "intl-messageformat": "^9.4.6", @@ -51,6 +52,7 @@ "shell-quote": "^1.7.2", "source-map-support": "^0.5.19", "split2": "^3.2.2", + "steam-shortcut-editor": "^3.1.3", "styled-components": "^5.2.1", "systeminformation": "^5.21.7", "term-color": "^1.0.1", @@ -2740,6 +2742,23 @@ "node": ">=8" } }, + "node_modules/crc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/crc/-/crc-4.3.2.tgz", + "integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "buffer": ">=6.0.3" + }, + "peerDependenciesMeta": { + "buffer": { + "optional": true + } + } + }, "node_modules/crc-32": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", @@ -7459,6 +7478,39 @@ "node": ">= 0.4" } }, + "node_modules/object-sizeof": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/object-sizeof/-/object-sizeof-1.6.3.tgz", + "integrity": "sha512-LGtilAKuDGKCcvu1Xg3UvAhAeJJlFmblo3faltmOQ80xrGwAHxnauIXucalKdTEksHp/Pq9tZGz1hfyEmjFJPQ==", + "license": "MIT", + "dependencies": { + "buffer": "^5.6.0" + } + }, + "node_modules/object-sizeof/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/object-visit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", @@ -9730,6 +9782,16 @@ "node": ">= 0.6" } }, + "node_modules/steam-shortcut-editor": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/steam-shortcut-editor/-/steam-shortcut-editor-3.1.3.tgz", + "integrity": "sha512-KATVeu6Y/DLlfbzCxs3RvVvBx9hPOxV/GkRrP6MqzkXQPpKHHVv/GYr1p1WUgU8cK0LWxbKgDdOBQNpOM7+uAw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "object-sizeof": "^1.2.0" + } + }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -13995,6 +14057,12 @@ } } }, + "crc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/crc/-/crc-4.3.2.tgz", + "integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==", + "requires": {} + }, "crc-32": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", @@ -17649,6 +17717,25 @@ "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", "devOptional": true }, + "object-sizeof": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/object-sizeof/-/object-sizeof-1.6.3.tgz", + "integrity": "sha512-LGtilAKuDGKCcvu1Xg3UvAhAeJJlFmblo3faltmOQ80xrGwAHxnauIXucalKdTEksHp/Pq9tZGz1hfyEmjFJPQ==", + "requires": { + "buffer": "^5.6.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "object-visit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", @@ -19532,6 +19619,15 @@ "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", "dev": true }, + "steam-shortcut-editor": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/steam-shortcut-editor/-/steam-shortcut-editor-3.1.3.tgz", + "integrity": "sha512-KATVeu6Y/DLlfbzCxs3RvVvBx9hPOxV/GkRrP6MqzkXQPpKHHVv/GYr1p1WUgU8cK0LWxbKgDdOBQNpOM7+uAw==", + "requires": { + "lodash": "^4.17.21", + "object-sizeof": "^1.2.0" + } + }, "stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", diff --git a/package.json b/package.json index 6c201444a..0c275df0d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "classnames": "^2.2.6", "clean-webpack-plugin": "^3.0.0", "colors": "^1.4.0", + "crc": "^4.3.2", "crc-32": "^1.2.0", "fast-memoize": "^2.5.2", "intl-messageformat": "^9.4.6", @@ -66,6 +67,7 @@ "shell-quote": "^1.7.2", "source-map-support": "^0.5.19", "split2": "^3.2.2", + "steam-shortcut-editor": "^3.1.3", "styled-components": "^5.2.1", "systeminformation": "^5.21.7", "term-color": "^1.0.1", @@ -79,6 +81,7 @@ "yauzl": "^2.9.2" }, "devDependencies": { + "@electron/notarize": "^2.3.2", "@itchio/bob": "^2.1.0", "@types/classnames": "^2.2.11", "@types/clone": "^2.1.0", @@ -110,7 +113,6 @@ "cross-env": "^7.0.3", "css-loader": "^5.2.4", "electron": "^22.3.27", - "@electron/notarize": "^2.3.2", "electron-packager": "^17.1.2", "file-loader": "^6.2.0", "happypack": "^5.0.1", @@ -196,7 +198,8 @@ "split2", "log-rotate", "butlerd", - "querystring" + "querystring", + "steam-shortcut-editor" ], "renderer": { "webpackConfig": "custom.webpack.additions.js" @@ -209,4 +212,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/common/reducers/preferences.ts b/src/common/reducers/preferences.ts index ce42ac2a7..94841fe6d 100644 --- a/src/common/reducers/preferences.ts +++ b/src/common/reducers/preferences.ts @@ -22,6 +22,7 @@ export const initialState = { preferOptimizedPatches: false, disableBrowser: env.integrationTests ? true : false, enableTabs: false, + addGamesToSteam: false, } as PreferencesState; export default reducer(initialState, (on) => { diff --git a/src/common/types/index.ts b/src/common/types/index.ts index a60f90afe..2342137ec 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -494,6 +494,9 @@ export interface PreferencesState { /** whether or not we've already imported appdata as an install location */ importedOldInstallLocations: boolean; + + /** whether games should be added to steam automatically */ + addGamesToSteam: boolean; } export interface Task { diff --git a/src/main/reactors/context-menu/build-template.ts b/src/main/reactors/context-menu/build-template.ts index b75171802..2e331aab7 100644 --- a/src/main/reactors/context-menu/build-template.ts +++ b/src/main/reactors/context-menu/build-template.ts @@ -272,5 +272,5 @@ export function userMenu(store: Store): MenuTemplate { function escapeForContextMenu(label: string) { // In a context menu, '&[^&]' will be interpreted as a shortcut // definition. Escaping requires a second ampersand - return label.replaceAll("&", "&&"); + return label.replace("&", "&&"); } diff --git a/src/main/reactors/downloads/download-ended.ts b/src/main/reactors/downloads/download-ended.ts index d06e4e33c..5bc92c51e 100644 --- a/src/main/reactors/downloads/download-ended.ts +++ b/src/main/reactors/downloads/download-ended.ts @@ -2,6 +2,7 @@ import { Watcher } from "common/util/watcher"; import { actions } from "common/actions"; import { t } from "common/format/t"; import { urlForGame } from "common/util/navigation"; +import { addGameToSteam } from "main/steam"; export default function (watcher: Watcher) { watcher.on(actions.downloadEnded, async (store, action) => { @@ -58,5 +59,17 @@ export default function (watcher: Watcher) { ); } } + + // Add game to Steam if the option is enabled and this is an install + if (download.reason === "install") { + console.log("Download ended for install, checking Steam integration..."); + try { + const result = await addGameToSteam(store, download.game); + console.log("Steam integration result:", result); + } catch (error) { + console.error("Failed to add game to Steam:", error); + console.error("Error stack:", error.stack); + } + } }); } diff --git a/src/main/reactors/downloads/perform-uninstall.ts b/src/main/reactors/downloads/perform-uninstall.ts index ebf62f203..d2503a921 100644 --- a/src/main/reactors/downloads/perform-uninstall.ts +++ b/src/main/reactors/downloads/perform-uninstall.ts @@ -3,6 +3,7 @@ import * as messages from "common/butlerd/messages"; import { Logger } from "common/logger"; import { Store } from "common/types"; import { mcall } from "main/butlerd/mcall"; +import { removeGameFromSteam } from "main/steam"; export async function performUninstall( store: Store, @@ -23,4 +24,15 @@ export async function performUninstall( }); }); logger.info(`Uninstall successful`); + + // Remove game from Steam after successful uninstall + try { + const cave = await mcall(messages.FetchCave, { caveId }); + if (cave.cave && cave.cave.game) { + await removeGameFromSteam(store, cave.cave.game); + logger.info(`Removed ${cave.cave.game.title} from Steam`); + } + } catch (error) { + logger.warn(`Failed to remove game from Steam: ${error}`); + } } diff --git a/src/main/reactors/tasks/queue-cave-uninstall.ts b/src/main/reactors/tasks/queue-cave-uninstall.ts index 54712f9f0..30f433d7f 100644 --- a/src/main/reactors/tasks/queue-cave-uninstall.ts +++ b/src/main/reactors/tasks/queue-cave-uninstall.ts @@ -8,6 +8,7 @@ import { performUninstall } from "main/reactors/downloads/perform-uninstall"; import { promisedModal } from "main/reactors/modals"; import asTask from "main/reactors/tasks/as-task"; import { mcall } from "main/butlerd/mcall"; +import { removeGameFromSteam } from "main/steam"; const logger = mainLogger.child(__filename); @@ -42,6 +43,14 @@ export default function (watcher: Watcher) { }); logger.info(`Uninstall successful`); + // Remove game from Steam after successful uninstall + try { + await removeGameFromSteam(store, cave.game); + logger.info(`Removed ${cave.game.title} from Steam`); + } catch (error) { + logger.warn(`Failed to remove game from Steam: ${error}`); + } + store.dispatch(actions.uninstallEnded({})); }, onError: async (e, log) => { diff --git a/src/main/steam/index.ts b/src/main/steam/index.ts new file mode 100644 index 000000000..97d53a796 --- /dev/null +++ b/src/main/steam/index.ts @@ -0,0 +1,97 @@ +import { Game } from "common/butlerd/messages"; +import { Store } from "common/types"; +import { addNonSteamGame } from "./steam-shortcuts"; +import { mainLogger } from "main/logger"; + +const logger = mainLogger.child(__filename); + +export interface SteamGameInfo { + title: string; + app_name: string; + runner: string; + art_cover?: string; + art_square?: string; + art_logo?: string; +} + +export async function removeGameFromSteam( + store: Store, + game: Game +): Promise { + const prefs = store.getState().preferences; + + if (!prefs.addGamesToSteam) { + return false; + } + + logger.info(`Attempting to remove ${game.title} from Steam`); + + const gameInfo: SteamGameInfo = { + title: game.title, + app_name: game.id.toString(), + runner: "itch", + art_cover: game.coverUrl, + art_square: game.stillCoverUrl, + }; + + try { + const { removeNonSteamGame } = await import("./steam-shortcuts"); + const success = await removeNonSteamGame({ gameInfo }); + if (success) { + logger.info(`Successfully removed ${game.title} from Steam`); + } else { + logger.warn(`Failed to remove ${game.title} from Steam`); + } + return success; + } catch (error) { + logger.error(`Error removing ${game.title} from Steam:`); + logger.error(error); + return false; + } +} + +export async function addGameToSteam( + store: Store, + game: Game +): Promise { + console.log("addGameToSteam called for:", game.title); + const prefs = store.getState().preferences; + console.log("Steam integration preference:", prefs.addGamesToSteam); + + if (!prefs.addGamesToSteam) { + console.log("Steam integration disabled in preferences"); + logger.debug("Steam integration disabled in preferences"); + return false; + } + + console.log(`Attempting to add ${game.title} to Steam`); + logger.info(`Attempting to add ${game.title} to Steam`); + + const gameInfo: SteamGameInfo = { + title: game.title, + app_name: game.id.toString(), + runner: "itch", + art_cover: game.coverUrl, + art_square: game.stillCoverUrl, + }; + + console.log("Game info:", gameInfo); + + try { + const success = await addNonSteamGame({ gameInfo }); + console.log("addNonSteamGame result:", success); + if (success) { + console.log(`Successfully added ${game.title} to Steam`); + logger.info(`Successfully added ${game.title} to Steam`); + } else { + console.log(`Failed to add ${game.title} to Steam`); + logger.warn(`Failed to add ${game.title} to Steam`); + } + return success; + } catch (error) { + console.error(`Error adding ${game.title} to Steam:`, error); + logger.error(`Error adding ${game.title} to Steam:`); + logger.error(error); + return false; + } +} diff --git a/src/main/steam/steam-helper.ts b/src/main/steam/steam-helper.ts new file mode 100644 index 000000000..622f6865d --- /dev/null +++ b/src/main/steam/steam-helper.ts @@ -0,0 +1,22 @@ +import { crc32 } from "crc"; + +function generatePreliminaryId(exe: string, appname: string) { + const key = exe + appname; + const top = BigInt(crc32(key)) | BigInt(0x80000000); + return (BigInt(top) << BigInt(32)) | BigInt(0x02000000); +} + +export function generateShortcutId(exe: string, appname: string) { + const id = + (generatePreliminaryId(exe, appname) >> BigInt(32)) - BigInt(0x100000000); + // Ensure the ID fits in a 32-bit signed integer (keep negative values) + return Number(BigInt.asIntN(32, id)); +} + +export function generateAppId(exe: string, appname: string) { + return String(generatePreliminaryId(exe, appname)); +} + +export function generateShortAppId(exe: string, appname: string) { + return String(generatePreliminaryId(exe, appname) >> BigInt(32)); +} diff --git a/src/main/steam/steam-images.ts b/src/main/steam/steam-images.ts new file mode 100644 index 000000000..d28fe80ed --- /dev/null +++ b/src/main/steam/steam-images.ts @@ -0,0 +1,105 @@ +import { writeFileSync } from "fs"; +import { join } from "path"; +import { get } from "https"; +import { SteamGameInfo } from "./index"; + +export async function downloadImage( + url: string, + filePath: string +): Promise { + if (!url) return; + + return new Promise((resolve) => { + get(url, (response) => { + if (response.statusCode === 200) { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + writeFileSync(filePath, Buffer.concat(chunks)); + resolve(); + }); + } else { + resolve(); + } + }).on("error", () => resolve()); + }); +} + +export async function removeImages( + configDir: string, + shortAppId: string +): Promise { + const gridDir = join(configDir, "grid"); + const imageId = shortAppId; + + const imagesToRemove = [ + join(gridDir, `${imageId}p.jpg`), + join(gridDir, `${imageId}.jpg`), + join(gridDir, `${imageId}_hero.jpg`), + join(gridDir, `${imageId}_icon.png`), + join(gridDir, `${imageId}_icon.jpg`), + ]; + + for (const imagePath of imagesToRemove) { + if (require("fs").existsSync(imagePath)) { + try { + require("fs").unlinkSync(imagePath); + } catch (error) { + // Ignore errors when removing images + } + } + } +} + +export async function setupSteamImages( + configDir: string, + appId: string, + shortAppId: string, + gameInfo: SteamGameInfo +): Promise { + const gridDir = join(configDir, "grid"); + + if (!require("fs").existsSync(gridDir)) { + require("fs").mkdirSync(gridDir, { recursive: true }); + } + + // Use shortAppId directly for image filenames to match shortcut ID + const imageId = shortAppId; + + // Get file extension from URL + const getExt = (url: string) => { + const match = url.match(/\.(png|jpg|jpeg)/); + return match ? match[0] : ".jpg"; + }; + + const coverExt = getExt(gameInfo.art_cover || ""); + const iconExt = getExt(gameInfo.art_square || gameInfo.art_cover || ""); + + // Steam image formats + const iconPath = join(gridDir, `${imageId}_icon${iconExt}`); + const images = [ + { url: gameInfo.art_cover, path: join(gridDir, `${imageId}p.jpg`) }, // Portrait (coverArt) + { url: gameInfo.art_cover, path: join(gridDir, `${imageId}.jpg`) }, // Header (headerArt) + { + url: gameInfo.art_cover, + path: join(gridDir, `${imageId}_hero.jpg`), + }, // Background (backGroundArt) + { url: gameInfo.art_square || gameInfo.art_cover, path: iconPath }, // Icon + ]; + + console.log(`Creating Steam images for shortcut ID ${imageId}:`); + for (const image of images) { + if (image.url) { + console.log(` ${image.path}`); + await downloadImage(image.url, image.path); + } + } + console.log( + `Steam should look for background image at: ${join( + gridDir, + `${imageId}_hero.jpg` + )}` + ); + + return iconPath; +} diff --git a/src/main/steam/steam-path.ts b/src/main/steam/steam-path.ts new file mode 100644 index 000000000..f95a52518 --- /dev/null +++ b/src/main/steam/steam-path.ts @@ -0,0 +1,55 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { homedir, platform } from "os"; + +export async function getSteamPath(): Promise { + const os = platform(); + let steamPaths: string[] = []; + + switch (os) { + case "win32": + steamPaths = [ + "C:\\Program Files (x86)\\Steam", + "C:\\Program Files\\Steam", + join(homedir(), "AppData", "Local", "Steam"), + "D:\\Steam", + "E:\\Steam", + ]; + break; + case "darwin": + steamPaths = [ + join(homedir(), "Library", "Application Support", "Steam"), + "/Applications/Steam.app/Contents/MacOS", + ]; + break; + case "linux": + steamPaths = [ + join(homedir(), ".steam", "steam"), + join(homedir(), ".local", "share", "Steam"), + join( + homedir(), + ".var", + "app", + "com.valvesoftware.Steam", + ".local", + "share", + "Steam" + ), // Flatpak + "/usr/share/steam", + "/opt/steam", + ]; + break; + } + + for (const path of steamPaths) { + if (existsSync(path)) { + // Verify it's actually a Steam installation by checking for userdata folder + const userdataPath = join(path, "userdata"); + if (existsSync(userdataPath)) { + return path; + } + } + } + + return null; +} diff --git a/src/main/steam/steam-shortcuts.ts b/src/main/steam/steam-shortcuts.ts new file mode 100644 index 000000000..a5472a535 --- /dev/null +++ b/src/main/steam/steam-shortcuts.ts @@ -0,0 +1,244 @@ +let steamShortcutEditor: any; +try { + steamShortcutEditor = require("steam-shortcut-editor"); +} catch (error) { + console.error("Failed to load steam-shortcut-editor:", error); + steamShortcutEditor = null; +} +import { + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + readFileSync, +} from "fs"; +import { join } from "path"; +import { app } from "electron"; +import { SteamGameInfo } from "./index"; +import { + generateShortcutId, + generateAppId, + generateShortAppId, +} from "./steam-helper"; +import { getSteamPath } from "./steam-path"; +import { setupSteamImages, removeImages } from "./steam-images"; +import { ShortcutEntry, ShortcutObject } from "steam-shortcut-editor"; + +function checkSteamUserDataDir( + steamUserdataDir: string +): { + folders: string[]; + error?: string; +} { + if (!existsSync(steamUserdataDir)) { + return { + folders: [], + error: `${steamUserdataDir} does not exist. Can't add game to Steam!`, + }; + } + + const ignoreFolders = ["0", "ac"]; + const folders = readdirSync(steamUserdataDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => ignoreFolders.every((folder) => folder !== dirent.name)) + .map((dirent) => dirent.name); + + if (folders.length <= 0) { + return { + folders: [], + error: `${steamUserdataDir} does not contain a valid user directory!`, + }; + } + + return { folders }; +} + +function readShortcutFile(file: string): any { + if (!steamShortcutEditor) + throw new Error("steam-shortcut-editor not available"); + const content = readFileSync(file); + return steamShortcutEditor.parseBuffer(content, { + autoConvertArrays: true, + autoConvertBooleans: true, + dateProperties: ["LastPlayTime"], + }); +} + +function writeShortcutFile(file: string, object: any): string | undefined { + if (!steamShortcutEditor) + throw new Error("steam-shortcut-editor not available"); + const buffer = steamShortcutEditor.writeBuffer(object); + try { + writeFileSync(file, buffer); + return undefined; + } catch (error) { + return `${error}`; + } +} + +function getAppName(object: ShortcutEntry): string { + return Object.entries(object).find( + ([key]) => key.toLowerCase() === "appname" + )?.[1]; +} + +function checkIfAlreadyAdded(object: Partial, title: string) { + const shortcuts = object.shortcuts ?? []; + return shortcuts.findIndex((entry) => getAppName(entry) === title); +} + +export async function removeNonSteamGame(props: { + gameInfo: SteamGameInfo; +}): Promise { + if (!steamShortcutEditor) { + throw new Error("steam-shortcut-editor not available"); + } + + const steamPath = await getSteamPath(); + if (!steamPath) { + throw new Error("Steam installation not found"); + } + + const users = readdirSync(join(steamPath, "userdata")); + + for (const user of users) { + const configDir = join(steamPath, "userdata", user, "config"); + const shortcutsFile = join(configDir, "shortcuts.vdf"); + + if (!existsSync(shortcutsFile)) { + continue; + } + + try { + const content = readShortcutFile(shortcutsFile); + if (!content.shortcuts) { + continue; + } + + // Find and remove the game + const originalLength = content.shortcuts.length; + content.shortcuts = content.shortcuts.filter( + (shortcut: any) => shortcut.AppName !== props.gameInfo.title + ); + + if (content.shortcuts.length < originalLength) { + const error = writeShortcutFile(shortcutsFile, content); + if (error) { + throw new Error(error); + } + + // Remove images + const appId = generateAppId( + `"${app.getPath("exe")}"`, + props.gameInfo.title + ); + const shortAppId = generateShortAppId( + `"${app.getPath("exe")}"`, + props.gameInfo.title + ); + await removeImages(configDir, shortAppId); + } + } catch (error) { + throw new Error(`Error processing user ${user}: ${error}`); + } + } + + return true; +} + +export async function addNonSteamGame(props: { + gameInfo: SteamGameInfo; +}): Promise { + if (!steamShortcutEditor) { + throw new Error("steam-shortcut-editor not available"); + } + + const steamPath = await getSteamPath(); + if (!steamPath) { + throw new Error("Steam installation not found"); + } + + const steamUserdataDir = join(steamPath, "userdata"); + const { folders, error } = checkSteamUserDataDir(steamUserdataDir); + + if (error) { + throw new Error(error); + } + + const errors: string[] = []; + let added = false; + + for (const folder of folders) { + try { + const configDir = join(steamUserdataDir, folder, "config"); + const shortcutsFile = join(configDir, "shortcuts.vdf"); + + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + if (!existsSync(shortcutsFile)) { + writeShortcutFile(shortcutsFile, { shortcuts: [] }); + } + + const content = readShortcutFile(shortcutsFile); + content.shortcuts = content.shortcuts ?? []; + + if (checkIfAlreadyAdded(content, props.gameInfo.title) > -1) { + added = true; + continue; + } + + const newEntry = {} as any; + newEntry.AppName = props.gameInfo.title; + newEntry.Exe = `"${app.getPath("exe")}"`; + newEntry.StartDir = `"${process.cwd()}"`; + newEntry.LaunchOptions = `"itch://games/${props.gameInfo.app_name}"`; + + // Generate IDs first + const appId = generateAppId(newEntry.Exe, newEntry.AppName); + const shortAppId = generateShortAppId(newEntry.Exe, newEntry.AppName); + newEntry.appid = generateShortcutId(newEntry.Exe, newEntry.AppName); + + newEntry.IsHidden = false; + newEntry.AllowDesktopConfig = true; + newEntry.AllowOverlay = true; + newEntry.OpenVR = false; + newEntry.Devkit = false; + newEntry.DevkitOverrideAppID = false; + newEntry.LastPlayTime = new Date(); + + // Setup Steam images and get icon path using shortAppId for images + const iconPath = await setupSteamImages( + configDir, + appId, + shortAppId, + props.gameInfo + ); + newEntry.icon = iconPath; + + console.log("Complete shortcut entry before saving to shortcuts.vdf:"); + console.log(JSON.stringify(newEntry, null, 2)); + + content.shortcuts.push(newEntry); + + const writeError = writeShortcutFile(shortcutsFile, content); + if (writeError) { + errors.push( + `Failed to write shortcuts for user ${folder}: ${writeError}` + ); + continue; + } + + added = true; + } catch (error) { + errors.push(`Error processing user ${folder}: ${error}`); + } + } + + if (!added && errors.length > 0) { + throw new Error(`Failed to add game to Steam: ${errors.join("; ")}`); + } + + return added; +} diff --git a/src/renderer/pages/PreferencesPage/BehaviorSettings.tsx b/src/renderer/pages/PreferencesPage/BehaviorSettings.tsx index ba8afdc56..c34b5c1b0 100644 --- a/src/renderer/pages/PreferencesPage/BehaviorSettings.tsx +++ b/src/renderer/pages/PreferencesPage/BehaviorSettings.tsx @@ -56,6 +56,11 @@ class BehaviorSettings extends React.PureComponent { name="preventDisplaySleep" label={T(["preferences.behavior.prevent_display_sleep"])} /> + +

{T(["preferences.notifications"])}

diff --git a/src/static/locales/en.json b/src/static/locales/en.json index c79f61509..8dcbd48a5 100644 --- a/src/static/locales/en.json +++ b/src/static/locales/en.json @@ -310,6 +310,8 @@ "preferences.behavior.manual_game_updates": "Ask before updating anything", "preferences.behavior.prevent_display_sleep": "Prevent display sleep while playing", + "preferences.behavior.add_games_to_steam": + "Add games to Steam automatically", "preferences.notifications": "Notifications", "preferences.notifications.ready_notification": "Notify me when a download has been installed or updated", diff --git a/typings/steam-shortcut-editor.d.ts b/typings/steam-shortcut-editor.d.ts new file mode 100644 index 000000000..210642331 --- /dev/null +++ b/typings/steam-shortcut-editor.d.ts @@ -0,0 +1,32 @@ +declare module "steam-shortcut-editor" { + export interface ShortcutEntry { + AppName: string; + Exe: string; + StartDir: string; + LaunchOptions: string; + appid: number; + IsHidden: boolean; + AllowDesktopConfig: boolean; + AllowOverlay: boolean; + OpenVR: boolean; + Devkit: boolean; + DevkitOverrideAppID: boolean; + LastPlayTime: Date; + icon?: string; + [key: string]: any; + } + + export interface ShortcutObject { + shortcuts: ShortcutEntry[]; + } + + export function writeBuffer(object: Partial): Buffer; + export function parseBuffer( + buffer: Buffer, + options?: { + autoConvertArrays?: boolean; + autoConvertBooleans?: boolean; + dateProperties?: string[]; + } + ): Partial; +}