Skip to content

Commit fb467ca

Browse files
steamGamesStarter@KopfdesDaemons: Initial release (#1543)
1 parent 101ea5f commit fb467ca

17 files changed

Lines changed: 640 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## Icons
2+
3+
- [Steam Icon](https://fontawesome.com/icons/steam?f=brands&s=solid) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
4+
- [Play Icon](https://fontawesome.com/icons/play?f=classic&s=solid) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
5+
- [Shop Icon](https://fontawesome.com/icons/bag-shopping?f=classic&s=solid) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
6+
- [Reload Icon](https://fontawesome.com/icons/rotate-right?f=classic&s=solid) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
7+
- [Error Icon](https://fontawesome.com/icons/circle-exclamation?f=classic&s=solid) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
const Desklet = imports.ui.desklet;
2+
const St = imports.gi.St;
3+
const GLib = imports.gi.GLib;
4+
const Gettext = imports.gettext;
5+
const Settings = imports.ui.settings;
6+
7+
const { SteamHelper } = require("./helpers/steam.helper");
8+
const { UiHelper } = require("./helpers/ui.helper");
9+
10+
const UUID = "steamGamesStarter@KopfdesDaemons";
11+
Gettext.bindtextdomain(UUID, GLib.get_home_dir() + "/.local/share/locale");
12+
13+
function _(str) {
14+
return Gettext.dgettext(UUID, str);
15+
}
16+
17+
class SteamGamesStarterDesklet extends Desklet.Desklet {
18+
constructor(metadata, deskletId) {
19+
super(metadata, deskletId);
20+
this.games = [];
21+
this.error = null;
22+
this.steamInstallationType = "system package";
23+
this.numberOfGames = 10;
24+
this.maxDeskletHeight = 400;
25+
this.scrollView = null;
26+
this.mainContainer = null;
27+
this.backgroundColor = "rgba(58, 64, 74, 0.5)";
28+
29+
// Setup settings and bind them to properties
30+
const settings = new Settings.DeskletSettings(this, metadata["uuid"], deskletId);
31+
settings.bindProperty(Settings.BindingDirection.IN, "steam-install-type", "steamInstallType", this._loadGamesAndSetupUI.bind(this));
32+
settings.bindProperty(Settings.BindingDirection.IN, "number-of-games", "numberOfGames", this._loadGamesAndSetupUI.bind(this));
33+
settings.bindProperty(Settings.BindingDirection.IN, "max-desklet-height", "maxDeskletHeight", this._updateScrollViewStyle.bind(this));
34+
settings.bindProperty(Settings.BindingDirection.IN, "background-color", "backgroundColor", this._updateScrollViewStyle.bind(this));
35+
36+
this.setHeader(_("Steam Games Starter"));
37+
this._initUI();
38+
this._loadGamesAndSetupUI();
39+
}
40+
41+
_initUI() {
42+
this.mainContainer = new St.BoxLayout({ vertical: true, style_class: "main-container" });
43+
this.mainContainer.add_child(UiHelper.createHeader(this.metadata.path, this._loadGamesAndSetupUI.bind(this)));
44+
this.setContent(this.mainContainer);
45+
}
46+
47+
async _loadGamesAndSetupUI() {
48+
this._setupLayout(true);
49+
50+
this.error = null;
51+
this.games = [];
52+
53+
try {
54+
this.games = await SteamHelper.getGames(this.steamInstallType);
55+
} catch (e) {
56+
this.error = e;
57+
global.logError(`Error getting Steam games: ${e}`);
58+
}
59+
60+
this._setupLayout();
61+
}
62+
63+
_setupLayout(loading = false) {
64+
const gamesToDisplay = this.games.slice(0, this.numberOfGames);
65+
66+
if (this.scrollView) {
67+
this.mainContainer.remove_child(this.scrollView);
68+
this.scrollView.destroy();
69+
}
70+
71+
this.scrollView = new St.ScrollView({ overlay_scrollbars: true, clip_to_allocation: true });
72+
this._updateScrollViewStyle();
73+
74+
if (loading) {
75+
this.scrollView.add_actor(UiHelper.createLoadingView());
76+
} else if (this.error || gamesToDisplay.length === 0) {
77+
this.scrollView.add_actor(UiHelper.createErrorView(this.error, gamesToDisplay.length > 0, this.metadata.path));
78+
} else {
79+
const gamesContainer = new St.BoxLayout({ vertical: true, style_class: "games-container" });
80+
gamesToDisplay.forEach(game => {
81+
const gameItem = UiHelper.createGameItem(game, this.steamInstallType, this.metadata.path);
82+
gamesContainer.add_child(gameItem);
83+
});
84+
this.scrollView.add_actor(gamesContainer);
85+
}
86+
87+
this.mainContainer.add_child(this.scrollView);
88+
}
89+
90+
_updateScrollViewStyle() {
91+
if (!this.scrollView) return;
92+
this.scrollView.set_style("max-height:" + this.maxDeskletHeight + "px; background-color: " + this.backgroundColor + ";");
93+
}
94+
}
95+
96+
function main(metadata, deskletId) {
97+
return new SteamGamesStarterDesklet(metadata, deskletId);
98+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const GdkPixbuf = imports.gi.GdkPixbuf;
2+
const St = imports.gi.St;
3+
const Clutter = imports.gi.Clutter;
4+
const Cogl = imports.gi.Cogl;
5+
6+
class ImageHelper {
7+
// Helper to create an actor from a Pixbuf
8+
static createActorFromPixbuf(pixBuf) {
9+
const pixelFormat = pixBuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGB_888;
10+
const image = new Clutter.Image();
11+
image.set_data(pixBuf.get_pixels(), pixelFormat, pixBuf.get_width(), pixBuf.get_height(), pixBuf.get_rowstride());
12+
13+
return new Clutter.Actor({
14+
content: image,
15+
width: pixBuf.get_width(),
16+
height: pixBuf.get_height(),
17+
});
18+
}
19+
20+
static getImageAtScale(imageFileName, requestedWidth, requestedHeight) {
21+
try {
22+
const pixBuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imageFileName, requestedWidth, requestedHeight);
23+
return this.createActorFromPixbuf(pixBuf);
24+
} catch (e) {
25+
global.logError(`Error loading image ${imageFileName}: ${e}`);
26+
return new St.Label({ text: "Error" }); // Return a label on error
27+
}
28+
}
29+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
const Gio = imports.gi.Gio;
2+
const GLib = imports.gi.GLib;
3+
const GdkPixbuf = imports.gi.GdkPixbuf;
4+
const St = imports.gi.St;
5+
const Util = imports.misc.util;
6+
const { ImageHelper } = require("./helpers/image.helper");
7+
8+
// AppIDs for games/tools to be filtered out from the list
9+
const FILTERED_APP_IDS = [
10+
"1628350", // Proton Experimental
11+
"1493710", // Steam Linux Runtime 3.0 (sniper)
12+
"1391110", // Steam Linux Runtime 2.0 (soldier)
13+
];
14+
15+
class SteamHelper {
16+
// Helper to read the paths of the Steam library folders
17+
static extractLibraryPaths(vdfString) {
18+
const paths = [];
19+
const regex = /"path"\s*"(.*?)"/g;
20+
let match;
21+
while ((match = regex.exec(vdfString)) !== null) {
22+
if (!match[0].includes("debian-installation")) {
23+
paths.push(match[1]);
24+
}
25+
}
26+
return paths;
27+
}
28+
29+
// Helper to extract game info from an appmanifest file
30+
static async extractGameInfo(filePath) {
31+
const file = Gio.file_new_for_path(filePath);
32+
const [, contentBytes] = await new Promise(resolve =>
33+
file.load_contents_async(null, (obj, res) => resolve(obj.load_contents_finish(res)))
34+
);
35+
const content = new TextDecoder("utf-8").decode(contentBytes);
36+
37+
const nameMatch = /"name"\s*"(.*?)"/.exec(content);
38+
const appidMatch = /"appid"\s*"(.*?)"/.exec(content);
39+
const lastPlayedMatch = /"LastPlayed"\s*"(.*?)"/.exec(content);
40+
41+
if (nameMatch && appidMatch && lastPlayedMatch) {
42+
return {
43+
name: nameMatch[1],
44+
appid: appidMatch[1],
45+
lastPlayed: lastPlayedMatch[1],
46+
};
47+
}
48+
return null;
49+
}
50+
51+
// Helper to get all installed games
52+
static async getGames(steamInstallType) {
53+
// Get Steam library paths
54+
let libraryfoldersFilePath = GLib.get_home_dir() + "/.steam/steam/steamapps/libraryfolders.vdf";
55+
if (steamInstallType === "flatpak") {
56+
libraryfoldersFilePath = GLib.get_home_dir() + "/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/libraryfolders.vdf";
57+
}
58+
59+
const libraryfoldersFile = Gio.file_new_for_path(libraryfoldersFilePath);
60+
if (!libraryfoldersFile.query_exists(null)) {
61+
throw new Error(`Steam library file not found at: ${libraryfoldersFilePath}`);
62+
}
63+
64+
const [success, libraryfoldersFileContentBytes] = await new Promise(resolve =>
65+
libraryfoldersFile.load_contents_async(null, (obj, res) => resolve(obj.load_contents_finish(res)))
66+
);
67+
const libraryfoldersFileContent = new TextDecoder("utf-8").decode(libraryfoldersFileContentBytes);
68+
const libraryPaths = this.extractLibraryPaths(libraryfoldersFileContent);
69+
70+
// Find all appmanifest files in the library paths
71+
const appmanifestPaths = [];
72+
for (const path of libraryPaths) {
73+
const steamAppsPath = GLib.build_filenamev([path, "steamapps"]);
74+
const out = await new Promise(resolve => Util.spawn_async(["find", steamAppsPath, "-name", "*.acf"], stdout => resolve(stdout)));
75+
if (out) {
76+
const paths = out
77+
.trim()
78+
.split("\n")
79+
.filter(p => p);
80+
appmanifestPaths.push(...paths);
81+
}
82+
}
83+
84+
// Extract game info from each appmanifest file
85+
const gamePromises = [];
86+
for (const path of appmanifestPaths) {
87+
if (path) {
88+
gamePromises.push(this.extractGameInfo(path));
89+
}
90+
}
91+
92+
const games = await Promise.all(gamePromises);
93+
const filteredGames = games.filter(game => game !== null && game.lastPlayed && !FILTERED_APP_IDS.includes(game.appid));
94+
95+
// Filter and sort the games by last played date (newest first)
96+
const sortedGames = filteredGames.sort((a, b) => parseInt(b.lastPlayed, 10) - parseInt(a.lastPlayed, 10));
97+
98+
return sortedGames;
99+
}
100+
101+
// Helper to load a game's header image from the Steam appcache
102+
static getGameHeaderImage(appid, requestedWidth, requestedHeight) {
103+
const appCachePath = GLib.get_home_dir() + "/.steam/steam/appcache/librarycache/";
104+
const commonImageNames = ["header.jpg", "library_header.jpg", "library_hero.jpg"];
105+
106+
let imagePath = null;
107+
for (const name of commonImageNames) {
108+
const potentialPath = GLib.build_filenamev([appCachePath, appid, name]);
109+
if (GLib.file_test(potentialPath, GLib.FileTest.EXISTS)) {
110+
imagePath = potentialPath;
111+
break;
112+
}
113+
}
114+
115+
let pixBuf = null;
116+
if (imagePath) {
117+
try {
118+
pixBuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imagePath, requestedWidth, requestedHeight);
119+
} catch (e) {
120+
global.logError(`Error loading image ${imagePath}: ${e}`);
121+
}
122+
}
123+
124+
if (pixBuf) {
125+
const imageActor = ImageHelper.createActorFromPixbuf(pixBuf);
126+
127+
const clickableBin = new St.Bin({
128+
reactive: true,
129+
width: pixBuf.get_width(),
130+
height: pixBuf.get_height(),
131+
});
132+
clickableBin.set_child(imageActor);
133+
return clickableBin;
134+
}
135+
136+
global.logError(`Could not load an image for appid ${appid}`);
137+
return new St.Label({ text: "Error" });
138+
}
139+
140+
static getSteamCommand(steamInstallType) {
141+
return steamInstallType === "flatpak" ? "flatpak run com.valvesoftware.Steam" : "/usr/games/steam";
142+
}
143+
144+
static runGame(appid, steamInstallType) {
145+
const cmd = this.getSteamCommand(steamInstallType);
146+
GLib.spawn_command_line_async(`${cmd} steam://rungameid/${appid}`);
147+
}
148+
149+
static openStorePage(appid, steamInstallType) {
150+
const cmd = this.getSteamCommand(steamInstallType);
151+
GLib.spawn_command_line_async(`${cmd} steam://store/${appid}`);
152+
}
153+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const St = imports.gi.St;
2+
const GLib = imports.gi.GLib;
3+
const Clutter = imports.gi.Clutter;
4+
const Gettext = imports.gettext;
5+
6+
const { ImageHelper } = require("./helpers/image.helper");
7+
const { SteamHelper } = require("./helpers/steam.helper");
8+
9+
const UUID = "steamGamesStarter@KopfdesDaemons";
10+
Gettext.bindtextdomain(UUID, GLib.get_home_dir() + "/.local/share/locale");
11+
12+
function _(str) {
13+
return Gettext.dgettext(UUID, str);
14+
}
15+
16+
class UiHelper {
17+
static createHeader(metadataPath, onReload) {
18+
const headerContainer = new St.BoxLayout({ style_class: "header-container", reactive: true, track_hover: true });
19+
headerContainer.add_child(new St.Label({ text: _("Steam Games Starter"), style_class: "header-label" }));
20+
headerContainer.add_child(new St.BoxLayout({ x_expand: true }));
21+
22+
const reloadButton = new St.Button({
23+
child: ImageHelper.getImageAtScale(`${metadataPath}/reload.svg`, 24, 24),
24+
style_class: "reload-button",
25+
});
26+
reloadButton.connect("clicked", onReload);
27+
28+
headerContainer.add_child(reloadButton);
29+
return headerContainer;
30+
}
31+
32+
static createGameItem(game, steamInstallType, metadataPath) {
33+
const gameContainer = new St.BoxLayout({ style_class: "game-container", reactive: true, track_hover: true });
34+
35+
const imageActor = SteamHelper.getGameHeaderImage(game.appid, 139, 72);
36+
if (imageActor) {
37+
imageActor.connect("button-press-event", () => {
38+
SteamHelper.openStorePage(game.appid, steamInstallType);
39+
return Clutter.EVENT_PROPAGATE;
40+
});
41+
gameContainer.add_child(imageActor);
42+
}
43+
44+
const labelContainer = new St.BoxLayout({ vertical: true, style_class: "label-container" });
45+
const gameLabel = new St.Label({ text: game.name, style_class: "game-label" });
46+
labelContainer.add_child(gameLabel);
47+
48+
// Format the last played date and add a label
49+
const lastPlayedDate = new Date(parseInt(game.lastPlayed, 10) * 1000);
50+
const formattedDate = lastPlayedDate.toLocaleDateString();
51+
const dateLabel = new St.Label({ text: _("Last played:") + ` ${formattedDate}` });
52+
labelContainer.add_child(dateLabel);
53+
54+
const buttonRow = new St.BoxLayout({ style: "spacing: 10px;" });
55+
56+
const playIcon = ImageHelper.getImageAtScale(`${metadataPath}/play.svg`, 22, 22);
57+
const playButton = new St.Button({ child: playIcon, style_class: "play-button" });
58+
playButton.connect("clicked", () => SteamHelper.runGame(game.appid, steamInstallType));
59+
buttonRow.add_child(playButton);
60+
61+
const shopIcon = ImageHelper.getImageAtScale(`${metadataPath}/shop.svg`, 22, 22);
62+
const shopButton = new St.Button({ child: shopIcon, style_class: "shop-button" });
63+
shopButton.connect("clicked", () => SteamHelper.openStorePage(game.appid, steamInstallType));
64+
buttonRow.add_child(shopButton);
65+
66+
labelContainer.add_child(buttonRow);
67+
gameContainer.add_child(labelContainer);
68+
69+
return gameContainer;
70+
}
71+
72+
static createLoadingView() {
73+
const loadingLabel = new St.Label({ text: _("Loading..."), style_class: "loading-label" });
74+
const box = new St.BoxLayout({ vertical: true, style_class: "loading-layout" });
75+
box.add_child(new St.Bin({ child: loadingLabel, x_align: St.Align.MIDDLE, y_expand: true }));
76+
return box;
77+
}
78+
79+
static createErrorView(error, gamesFound, metadataPath) {
80+
const errorLayout = new St.BoxLayout({ style_class: "error-layout", vertical: true });
81+
82+
const errorIcon = ImageHelper.getImageAtScale(`${metadataPath}/error.svg`, 48, 48);
83+
const iconBin = new St.Bin({ child: errorIcon, style_class: "error-icon" });
84+
errorLayout.add_child(iconBin);
85+
86+
if (!gamesFound) {
87+
const noGamesLabel = new St.Label({ text: _("No installed games found"), style_class: "no-games-label" });
88+
errorLayout.add_child(noGamesLabel);
89+
}
90+
91+
if (error) {
92+
const clutterText = new Clutter.Text({
93+
text: "Error: " + error.message,
94+
line_wrap: true,
95+
color: new Clutter.Color({ red: 255, green: 0, blue: 0, alpha: 255 }),
96+
});
97+
98+
const errorLabel = new St.Bin({ child: clutterText, style_class: "error-label" });
99+
errorLayout.add_child(errorLabel);
100+
}
101+
102+
return errorLayout;
103+
}
104+
}
29.2 KB
Loading

0 commit comments

Comments
 (0)