Files
2026-09-01 17:12:10 +03:30

885 lines
26 KiB
Python

"""
Steam-like Python Game Launcher
Install dependencies:
python -m pip install customtkinter pillow
Run:
python launcher.py
"""
import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog, messagebox
import json
import os
import sys
import uuid
import subprocess
from PIL import Image, ImageDraw, ImageFont
# ------------------------------------------------------------
# DATA FILE
# ------------------------------------------------------------
DATA_FILE = os.path.join(os.path.expanduser("~"), ".steam_like_launcher.json")
# ------------------------------------------------------------
# STEAM-LIKE COLORS
# ------------------------------------------------------------
BG = "#1b2838"
SIDEBAR = "#171a21"
TOPBAR = "#141920"
PANEL = "#2a475e"
HOVER = "#3b5068"
ENTRY_BG = "#232a35"
TEXT = "#c7d5e0"
MUTED = "#8f98a0"
ACCENT = "#66c0f4"
GREEN = "#5c7e10"
GREEN_HOVER = "#6f9a12"
DANGER = "#a63d3d"
DANGER_HOVER = "#bf4c4c"
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# ------------------------------------------------------------
# IMAGE HELPERS
# ------------------------------------------------------------
def get_font(size):
"""Try to load a decent font for placeholder icons."""
candidates = [
"arialbd.ttf",
"arial.ttf",
"segoeui.ttf",
"DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"Helvetica.ttc",
]
for candidate in candidates:
try:
return ImageFont.truetype(candidate, size)
except Exception:
pass
try:
return ImageFont.load_default()
except Exception:
return None
def make_placeholder(text, size=(180, 180)):
"""Create a placeholder icon image with initials."""
img = Image.new("RGBA", size, (23, 26, 33, 255))
draw = ImageDraw.Draw(img)
initials = "".join(part[0] for part in text.split() if part)[:2].upper() or "G"
font = get_font(int(min(size) * 0.32))
try:
draw.text(
(size[0] / 2, size[1] / 2),
initials,
fill=(102, 192, 244, 255),
font=font,
anchor="mm",
)
except Exception:
draw.text((10, 10), initials, fill=(102, 192, 244, 255), font=font)
return img
def load_image(path, size, fallback_text="?"):
"""Load icon and center it on a transparent square canvas."""
try:
if not path or not os.path.isfile(path):
raise FileNotFoundError("No icon path provided.")
img = Image.open(path).convert("RGBA")
resample = Image.Resampling.LANCZOS if hasattr(Image, "Resampling") else Image.LANCZOS
img.thumbnail(size, resample)
canvas = Image.new("RGBA", size, (0, 0, 0, 0))
offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2)
canvas.paste(img, offset, img)
return canvas
except Exception:
return make_placeholder(fallback_text, size)
# ------------------------------------------------------------
# ADD GAME DIALOG
# ------------------------------------------------------------
class AddGameDialog(ctk.CTkToplevel):
def __init__(self, master, on_save):
super().__init__(master)
self.on_save = on_save
self.title("Add Game")
self.geometry("580x360")
self.resizable(False, False)
self.configure(fg_color=BG)
self.transient(master)
self.after(20, self.center_window)
self.after(20, self.grab_set)
self.after(20, self.lift)
pad = ctk.CTkFrame(self, fg_color="transparent")
pad.pack(fill="both", expand=True, padx=22, pady=20)
ctk.CTkLabel(
pad,
text="Add a Game",
font=ctk.CTkFont(size=24, weight="bold"),
text_color=TEXT,
).pack(anchor="w", pady=(0, 14))
self.name_entry = self._field(pad, "Game Name")
self.exe_entry, _ = self._field_with_browse(
pad,
"Executable",
self.browse_exe,
)
self.icon_entry, _ = self._field_with_browse(
pad,
"Icon Image (.ico, .png, .jpg)",
self.browse_icon,
)
buttons = ctk.CTkFrame(pad, fg_color="transparent")
buttons.pack(fill="x", pady=(18, 0))
ctk.CTkButton(
buttons,
text="Cancel",
width=110,
height=38,
fg_color=PANEL,
hover_color=HOVER,
text_color=TEXT,
command=self.destroy,
).pack(side="right")
ctk.CTkButton(
buttons,
text="Add Game",
width=125,
height=38,
fg_color=GREEN,
hover_color=GREEN_HOVER,
text_color="white",
font=ctk.CTkFont(weight="bold"),
command=self.save,
).pack(side="right", padx=(0, 10))
def center_window(self):
try:
self.update_idletasks()
master = self.master
x = master.winfo_rootx() + (master.winfo_width() - self.winfo_width()) // 2
y = master.winfo_rooty() + (master.winfo_height() - self.winfo_height()) // 2
self.geometry(f"+{max(x, 0)}+{max(y, 0)}")
except Exception:
pass
def _field(self, parent, label):
block = ctk.CTkFrame(parent, fg_color="transparent")
block.pack(fill="x", pady=7)
ctk.CTkLabel(
block,
text=label,
text_color=MUTED,
anchor="w",
).pack(fill="x")
entry = ctk.CTkEntry(
block,
height=36,
fg_color=ENTRY_BG,
border_color="#39434f",
text_color=TEXT,
)
entry.pack(fill="x", pady=(4, 0))
return entry
def _field_with_browse(self, parent, label, command):
block = ctk.CTkFrame(parent, fg_color="transparent")
block.pack(fill="x", pady=7)
ctk.CTkLabel(
block,
text=label,
text_color=MUTED,
anchor="w",
).pack(fill="x")
row = ctk.CTkFrame(block, fg_color="transparent")
row.pack(fill="x", pady=(4, 0))
entry = ctk.CTkEntry(
row,
height=36,
fg_color=ENTRY_BG,
border_color="#39434f",
text_color=TEXT,
)
entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
button = ctk.CTkButton(
row,
text="Browse",
width=90,
height=36,
fg_color=PANEL,
hover_color=HOVER,
text_color=TEXT,
command=command,
)
button.pack(side="right")
return entry, button
def browse_exe(self):
if sys.platform.startswith("win"):
filetypes = [
("Executable", "*.exe *.bat *.cmd *.lnk"),
("All files", "*.*"),
]
else:
filetypes = [
("All files", "*"),
("Executable", "*"),
]
path = filedialog.askopenfilename(
title="Select Game Executable",
filetypes=filetypes,
)
if path:
self.exe_entry.delete(0, "end")
self.exe_entry.insert(0, path)
def browse_icon(self):
path = filedialog.askopenfilename(
title="Select Icon Image",
filetypes=[
("Images", "*.ico *.png *.jpg *.jpeg *.bmp"),
("All files", "*.*"),
],
)
if path:
self.icon_entry.delete(0, "end")
self.icon_entry.insert(0, path)
def save(self):
name = self.name_entry.get().strip()
exe = self.exe_entry.get().strip()
icon = self.icon_entry.get().strip()
if not name:
messagebox.showwarning(
"Missing Name",
"Please enter a game name.",
parent=self,
)
return
if not exe:
messagebox.showwarning(
"Missing Executable",
"Please choose the game executable.",
parent=self,
)
return
self.on_save(name, exe, icon)
self.destroy()
# ------------------------------------------------------------
# MAIN LAUNCHER WINDOW
# ------------------------------------------------------------
class Launcher(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Game Library")
self.geometry("1180x700")
self.minsize(980, 600)
self.configure(fg_color=BG)
self.games = self.load_games()
self.selected_id = None
self.detail_refs = []
# Root grid
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# ------------------------------------------------------------
# SIDEBAR
# ------------------------------------------------------------
sidebar = ctk.CTkFrame(self, corner_radius=0, fg_color=SIDEBAR, width=290)
sidebar.grid(row=0, column=0, sticky="nsew")
sidebar.grid_propagate(False)
side_head = ctk.CTkFrame(sidebar, fg_color="transparent")
side_head.pack(fill="x", padx=16, pady=(18, 10))
ctk.CTkLabel(
side_head,
text="LIBRARY",
font=ctk.CTkFont(size=25, weight="bold"),
text_color=TEXT,
).pack(anchor="w")
ctk.CTkButton(
side_head,
text="+ Add Game",
height=38,
corner_radius=8,
fg_color=ACCENT,
hover_color="#4fa3d1",
text_color="#08121c",
font=ctk.CTkFont(size=14, weight="bold"),
command=self.open_add_dialog,
).pack(fill="x", pady=(14, 0))
self.search = ctk.CTkEntry(
sidebar,
placeholder_text="Search",
height=36,
corner_radius=8,
fg_color=ENTRY_BG,
border_color="#39434f",
text_color=TEXT,
)
self.search.pack(fill="x", padx=16, pady=(0, 10))
self.search.bind("<KeyRelease>", lambda e: self.render_list())
self.list_frame = ctk.CTkScrollableFrame(sidebar, fg_color="transparent")
self.list_frame.pack(fill="both", expand=True, padx=10, pady=(0, 14))
self.list_content = ctk.CTkFrame(self.list_frame, fg_color="transparent")
self.list_content.pack(fill="both", expand=True)
# ------------------------------------------------------------
# MAIN AREA
# ------------------------------------------------------------
main = ctk.CTkFrame(self, corner_radius=0, fg_color=BG)
main.grid(row=0, column=1, sticky="nsew")
main.grid_columnconfigure(0, weight=1)
main.grid_rowconfigure(1, weight=1)
topbar = ctk.CTkFrame(main, corner_radius=0, fg_color=TOPBAR, height=68)
topbar.grid(row=0, column=0, sticky="ew")
topbar.grid_propagate(False)
self.top_title = ctk.CTkLabel(
topbar,
text="Library",
font=ctk.CTkFont(size=20, weight="bold"),
text_color=TEXT,
)
self.top_title.pack(side="left", padx=26)
self.top_status = ctk.CTkLabel(
topbar,
text="",
text_color=MUTED,
)
self.top_status.pack(side="right", padx=26)
self.details = ctk.CTkScrollableFrame(main, fg_color="transparent")
self.details.grid(row=1, column=0, sticky="nsew", padx=26, pady=22)
self.details_content = ctk.CTkFrame(self.details, fg_color="transparent")
self.details_content.pack(fill="both", expand=True)
# Initial render
self.render_list()
if self.games:
first = sorted(self.games, key=lambda g: g.get("name", "").lower())[0]
self.select_game(first["id"])
else:
self.show_home()
# ------------------------------------------------------------
# DATA
# ------------------------------------------------------------
def load_games(self):
try:
with open(DATA_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
cleaned = []
for item in data:
if not isinstance(item, dict):
continue
if not item.get("name"):
continue
if not item.get("id"):
item["id"] = str(uuid.uuid4())
cleaned.append(item)
return cleaned
except Exception:
pass
return []
def save_games(self):
try:
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(self.games, f, indent=2)
except Exception as e:
messagebox.showerror(
"Save Error",
f"Could not save library:\n{e}",
parent=self,
)
def get_game(self, game_id):
for game in self.games:
if game.get("id") == game_id:
return game
return None
# ------------------------------------------------------------
# SIDEBAR LIST
# ------------------------------------------------------------
def render_list(self):
for child in self.list_content.winfo_children():
child.destroy()
query = self.search.get().strip().lower()
games = sorted(self.games, key=lambda g: g.get("name", "").lower())
shown = 0
for game in games:
if query and query not in game.get("name", "").lower():
continue
selected = game.get("id") == self.selected_id
button = ctk.CTkButton(
self.list_content,
text=game.get("name", "Unknown"),
anchor="w",
height=40,
corner_radius=8,
fg_color=PANEL if selected else "transparent",
hover_color=HOVER,
text_color="#ffffff" if selected else TEXT,
font=ctk.CTkFont(size=14),
command=lambda gid=game.get("id"): self.select_game(gid),
)
button.pack(fill="x", padx=4, pady=2)
button.bind(
"<Button-3>",
lambda e, g=game: self.show_context_menu(e, g),
)
shown += 1
if shown == 0:
ctk.CTkLabel(
self.list_content,
text="No games found",
text_color=MUTED,
).pack(pady=18)
self.top_status.configure(text=f"{len(self.games)} games")
def show_context_menu(self, event, game):
menu = tk.Menu(
self,
tearoff=0,
bg="#202630",
fg=TEXT,
activebackground=HOVER,
activeforeground="white",
relief="flat",
)
menu.add_command(
label="Play",
command=lambda: self.launch_game(game),
)
menu.add_command(
label="Open Folder",
command=lambda: self.open_folder(game),
)
menu.add_separator()
menu.add_command(
label="Delete",
foreground="#ff7b7b",
activeforeground="#ff7b7b",
command=lambda: self.delete_game(game),
)
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
# ------------------------------------------------------------
# DETAILS AREA
# ------------------------------------------------------------
def clear_details(self):
for child in self.details_content.winfo_children():
child.destroy()
self.detail_refs.clear()
def show_home(self):
self.selected_id = None
self.top_title.configure(text="Library")
self.clear_details()
wrap = ctk.CTkFrame(self.details_content, fg_color="transparent")
wrap.pack(expand=True, pady=80)
if self.games:
ctk.CTkLabel(
wrap,
text="Select a game from the library.",
font=ctk.CTkFont(size=24),
text_color=MUTED,
).pack()
else:
ctk.CTkLabel(
wrap,
text="Your library is empty",
font=ctk.CTkFont(size=30, weight="bold"),
text_color=TEXT,
).pack(pady=(0, 10))
ctk.CTkLabel(
wrap,
text="Click + Add Game to add your first game.",
text_color=MUTED,
).pack(pady=(0, 18))
ctk.CTkButton(
wrap,
text="+ Add Game",
width=160,
height=42,
fg_color=ACCENT,
hover_color="#4fa3d1",
text_color="#08121c",
font=ctk.CTkFont(weight="bold"),
command=self.open_add_dialog,
).pack()
def show_game_details(self, game):
self.clear_details()
self.top_title.configure(text=game.get("name", "Game"))
# Hero panel
hero = ctk.CTkFrame(self.details_content, corner_radius=14, fg_color=PANEL)
hero.pack(fill="x")
hero.grid_columnconfigure(1, weight=1)
# Icon
image = load_image(
game.get("icon", ""),
(180, 180),
game.get("name", "G"),
)
ctk_image = ctk.CTkImage(
light_image=image,
dark_image=image,
size=(180, 180),
)
# Keep reference so image does not disappear
self.detail_refs.append(ctk_image)
icon_label = ctk.CTkLabel(hero, image=ctk_image, text="")
icon_label.grid(row=0, column=0, rowspan=2, padx=30, pady=30, sticky="w")
# Info
info = ctk.CTkFrame(hero, fg_color="transparent")
info.grid(row=0, column=1, sticky="nsew", padx=(8, 30), pady=(30, 8))
ctk.CTkLabel(
info,
text=game.get("name", "Unknown"),
anchor="w",
font=ctk.CTkFont(size=36, weight="bold"),
text_color="#f2f6fa",
).pack(anchor="w")
ctk.CTkLabel(
info,
text="Installed game",
anchor="w",
text_color=ACCENT,
font=ctk.CTkFont(size=13),
).pack(anchor="w", pady=(2, 16))
exe = game.get("exe", "")
ctk.CTkLabel(
info,
text="Executable",
anchor="w",
text_color=MUTED,
).pack(anchor="w")
ctk.CTkLabel(
info,
text=exe if exe else "Not set",
anchor="w",
text_color=TEXT,
wraplength=560,
justify="left",
).pack(anchor="w", pady=(0, 10))
icon = game.get("icon", "")
ctk.CTkLabel(
info,
text="Icon",
anchor="w",
text_color=MUTED,
).pack(anchor="w")
ctk.CTkLabel(
info,
text=icon if icon else "Default placeholder",
anchor="w",
text_color=TEXT,
wraplength=560,
justify="left",
).pack(anchor="w")
# Actions
actions = ctk.CTkFrame(hero, fg_color="transparent")
actions.grid(row=1, column=1, sticky="nsew", padx=(8, 30), pady=(8, 30))
ctk.CTkButton(
actions,
text="Play",
width=170,
height=52,
corner_radius=10,
fg_color=GREEN,
hover_color=GREEN_HOVER,
text_color="white",
font=ctk.CTkFont(size=20, weight="bold"),
command=lambda: self.launch_game(game),
).pack(side="left")
ctk.CTkButton(
actions,
text="Open Folder",
width=140,
height=52,
corner_radius=10,
fg_color="#3a4858",
hover_color=HOVER,
text_color=TEXT,
command=lambda: self.open_folder(game),
).pack(side="left", padx=12)
ctk.CTkButton(
actions,
text="Delete",
width=110,
height=52,
corner_radius=10,
fg_color=DANGER,
hover_color=DANGER_HOVER,
text_color="white",
command=lambda: self.delete_game(game),
).pack(side="left")
# Extra card to make it feel more like a library page
card = ctk.CTkFrame(self.details_content, corner_radius=14, fg_color="#20303f")
card.pack(fill="x", pady=(18, 0))
ctk.CTkLabel(
card,
text="Quick Notes",
anchor="w",
font=ctk.CTkFont(size=18, weight="bold"),
text_color=TEXT,
).pack(anchor="w", padx=22, pady=(18, 6))
ctk.CTkLabel(
card,
text=(
"Right-click a game in the left list for quick actions.\n"
"Your library is saved locally in a JSON file."
),
anchor="w",
text_color=MUTED,
justify="left",
).pack(anchor="w", padx=22, pady=(0, 20))
# ------------------------------------------------------------
# GAME ACTIONS
# ------------------------------------------------------------
def select_game(self, game_id):
game = self.get_game(game_id)
if not game:
return
self.selected_id = game_id
self.render_list()
self.show_game_details(game)
def open_add_dialog(self):
AddGameDialog(self, self.add_game)
def add_game(self, name, exe, icon):
game = {
"id": str(uuid.uuid4()),
"name": name,
"exe": exe,
"icon": icon,
}
self.games.append(game)
self.save_games()
self.selected_id = game["id"]
self.render_list()
self.show_game_details(game)
def delete_game(self, game):
name = game.get("name", "this game")
confirmed = messagebox.askyesno(
"Delete Game",
f"Remove '{name}' from the library?\n\n"
"This only removes the shortcut, not the game files.",
parent=self,
)
if not confirmed:
return
self.games = [g for g in self.games if g.get("id") != game.get("id")]
self.save_games()
if self.selected_id == game.get("id"):
self.selected_id = None
self.render_list()
if self.games:
first = sorted(self.games, key=lambda g: g.get("name", "").lower())[0]
self.select_game(first["id"])
else:
self.show_home()
def launch_game(self, game):
exe = os.path.normpath(game.get("exe", ""))
if not exe or not os.path.isfile(exe):
messagebox.showerror(
"Executable not found",
f"Cannot find:\n{exe}\n\nUpdate or restore the game executable.",
parent=self,
)
return
cwd = os.path.dirname(exe)
try:
if sys.platform.startswith("win"):
ext = os.path.splitext(exe)[1].lower()
if ext == ".exe":
subprocess.Popen([exe], cwd=cwd)
else:
# Handles .lnk, .bat, .cmd, etc.
os.startfile(exe) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", exe], cwd=cwd)
else:
subprocess.Popen(["xdg-open", exe], cwd=cwd)
except Exception as e:
messagebox.showerror(
"Launch Failed",
f"Could not start game:\n{e}",
parent=self,
)
def open_folder(self, game):
exe = os.path.normpath(game.get("exe", ""))
folder = os.path.dirname(exe) if exe else os.path.expanduser("~")
if not os.path.isdir(folder):
messagebox.showerror(
"Folder not found",
f"Cannot find folder:\n{folder}",
parent=self,
)
return
try:
if sys.platform.startswith("win"):
os.startfile(folder) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", folder])
else:
subprocess.Popen(["xdg-open", folder])
except Exception as e:
messagebox.showerror(
"Open Folder Failed",
f"Could not open folder:\n{e}",
parent=self,
)
# ------------------------------------------------------------
# RUN
# ------------------------------------------------------------
if __name__ == "__main__":
app = Launcher()
app.mainloop()