19 Commits

Author SHA1 Message Date
Yannik Schmidt
3368048dd7 fix: pywin32 version in requirements 2025-04-17 11:32:28 +02:00
Yannik Schmidt
7f608019ed fix: install requirements & only build tags 2025-04-17 11:26:44 +02:00
Yannik Schmidt
839efae1a3 fix: use single line command because of windows 2025-04-17 11:10:31 +02:00
Yannik Schmidt
36e5cc3842 fix: indent for release job 2025-04-17 11:05:36 +02:00
Yannik Schmidt
5ab17e6f4c fix: use native gh commands instead of release action 2025-04-17 11:04:43 +02:00
Yannik Schmidt
2e5676d5a6 fix: move release.yaml to workflows 2025-04-17 10:50:51 +02:00
Yannik Schmidt
a2702d7f70 feat: github release & build 2025-04-17 10:48:49 +02:00
Yannik Schmidt
344d32901e fix: use binary write for reg files 2025-04-13 18:55:13 +02:00
Yannik Schmidt
aefab57bb0 fix: add extra debug output to jinja helper 2025-04-13 18:54:55 +02:00
Yannik Schmidt
d3840c216c feat: make infowidget part of main window 2025-04-13 18:54:43 +02:00
Yannik Schmidt
3912c66bb3 fix: wait for registry file download 2025-04-13 11:55:29 +02:00
Yannik Schmidt
8e5bfd9ae3 feat: implement age limit 2025-04-13 00:52:51 +02:00
Yannik Schmidt
4e9b85ee6d feat: fully working async install 2025-04-13 00:18:02 +02:00
Yannik Schmidt
8e9e4db3fa add: new todo (2025) 2025-02-16 21:17:07 +01:00
Yannik Schmidt
47f9912dc7 feat: basic background progress bars (downloads) 2025-02-16 21:05:24 +01:00
Yannik Schmidt
c256a8ae3b whitespaces: infowidget fixes 2025-02-15 20:07:57 +01:00
Yannik Schmidt
554b4ece7a fix: add database.db to gitignore 2025-02-15 19:44:08 +01:00
Yannik Schmidt
a01d8992c0 feat: spawn info widget from client.py 2025-02-15 19:43:35 +01:00
Yannik Schmidt
e35803ce1a feat: basic download tracking in db 2025-02-15 19:43:20 +01:00
12 changed files with 417 additions and 85 deletions

36
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build and Release EXE
on:
push:
tags:
- '*'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r requirements.txt
- name: Build EXE
run: pyinstaller -F client.py
- name: Archive EXE
run: Compress-Archive -Path dist\ -DestinationPath release.zip
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create ${{ github.ref_name }} "release.zip" --generate-notes --title "release-${{ github.ref_name }}"

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ example_software_root/
# windows dir on linux ä # windows dir on linux ä
**\\install-dir/ **\\install-dir/
server/data/ server/data/
database.db

View File

@@ -10,6 +10,7 @@ import cache_utils
import imagetools import imagetools
import webbrowser import webbrowser
import statekeeper import statekeeper
import infowidget
customtkinter.set_appearance_mode("dark") customtkinter.set_appearance_mode("dark")
customtkinter.set_default_color_theme("blue") customtkinter.set_default_color_theme("blue")
@@ -27,10 +28,13 @@ details_elements = []
non_disabled_entry_color = None non_disabled_entry_color = None
all_metadata = None all_metadata = None
infowidget_window = None
db = None # app data-backend (i.e. LocalFS or FTP) db = None # app data-backend (i.e. LocalFS or FTP)
CONFIG_FILE = "gamevault_config.json" CONFIG_FILE = "gamevault_config.json"
def close_input_window(input_window): def close_input_window(input_window):
'''Close the config window and save the settings''' '''Close the config window and save the settings'''
@@ -180,16 +184,22 @@ def load_main():
'''Load the main page overview''' '''Load the main page overview'''
global all_metadata global all_metadata
global infowidget_window
app.title("Lan Vault: Overview") app.title("Lan Vault: Overview")
if not infowidget_window:
infowidget_window = infowidget.ProgressBarApp(app, data_backend=db)
infowidget_window.root.grid(row=1, column=0, sticky="n")
# navbar should not expand when window is resized # navbar should not expand when window is resized
app.grid_rowconfigure(0, weight=0) app.grid_rowconfigure(0, weight=0)
# buttongrid (scrollable frame) should expand when window is resized # buttongrid (scrollable frame) should expand when window is resized
app.grid_rowconfigure(1, weight=1) app.grid_rowconfigure(1, weight=1)
app.grid_columnconfigure(0, weight=1) app.grid_columnconfigure(1, weight=1)
# place scrollable frame # place scrollable frame
scrollable_frame.grid(row=1, column=0, sticky="nsew", columnspan=2) scrollable_frame.grid(row=1, column=1, sticky="nsew", columnspan=2)
# create tiles from meta files # # create tiles from meta files #
@@ -234,7 +244,7 @@ def load_details(app, software):
global details_elements global details_elements
app.title("Lan Vault: {}".format(software.title)) app.title("Lan Vault: {}".format(software.title))
details_elements = client_details.create_details_page(app, software, switch_to_main) details_elements = client_details.create_details_page(app, software, switch_to_main, infowidget_window)
def create_main_window_tile(software, parent): def create_main_window_tile(software, parent):
'''Create the main window tile''' '''Create the main window tile'''
@@ -286,7 +296,7 @@ def update_button_positions(event=None):
scrollable_frame.configure(width=app.winfo_width(), height=app.winfo_height()) scrollable_frame.configure(width=app.winfo_width(), height=app.winfo_height())
# Calculate the number of columns based on the current width of the window # # Calculate the number of columns based on the current width of the window #
num_columns = app.winfo_width() // 201 # Adjust 100 as needed for button width num_columns = app.winfo_width() // 301 # Adjust 100 as needed for button width
# window became too small # # window became too small #
if num_columns == 0: if num_columns == 0:
@@ -304,7 +314,8 @@ def update_button_positions(event=None):
continue continue
else: else:
DOWNSHIFT = 1 # FIXME make real navbar DOWNSHIFT = 1 # FIXME make real navbar
button.grid(row=(i // num_columns)+ DOWNSHIFT, column=i % num_columns, sticky="we") RIGHTSHIFT = 1 # first column for loading stuff
button.grid(row=(i // num_columns)+ DOWNSHIFT, column=i % num_columns + RIGHTSHIFT, sticky="we")
if __name__ == "__main__": if __name__ == "__main__":
@@ -330,6 +341,8 @@ if __name__ == "__main__":
password = config_loaded.get("Password:") password = config_loaded.get("Password:")
install_dir = config_loaded["Install dir:"] install_dir = config_loaded["Install dir:"]
backend_type = config_loaded["Select option:"] backend_type = config_loaded["Select option:"]
hide_above_age = config_loaded.get("hide_above_age") or 100
# fix abs path if not set # # fix abs path if not set #
if os.path.abspath(install_dir): if os.path.abspath(install_dir):
@@ -357,7 +370,8 @@ if __name__ == "__main__":
# add db backend # # add db backend #
if backend_type == "HTTP/HTTPS": if backend_type == "HTTP/HTTPS":
db = data_backend.HTTP(None, None, install_dir, remote_root_dir="./", server=server, progress_bar_wrapper=pgw, tkinter_root=app) db = data_backend.HTTP(None, None, install_dir, remote_root_dir="./", server=server, progress_bar_wrapper=pgw,
tkinter_root=app, hide_above_age=hide_above_age)
elif backend_type == "FTP/FTPS": elif backend_type == "FTP/FTPS":
db = data_backend.FTP(user, password, install_dir, server=server, db = data_backend.FTP(user, password, install_dir, server=server,
remote_root_dir=remote_root_dir, progress_bar_wrapper=pgw, tkinter_root=app) remote_root_dir=remote_root_dir, progress_bar_wrapper=pgw, tkinter_root=app)

View File

@@ -25,9 +25,11 @@ def show_large_picture(app, path):
large_image.place(x=30, y=30) large_image.place(x=30, y=30)
def create_details_page(app, software, backswitch_function): def create_details_page(app, software, backswitch_function, infowidget_window):
'''Create the details page for a software and return its elements for later destruction''' '''Create the details page for a software and return its elements for later destruction'''
infowidget_window.root.grid(row=1, column=0, sticky="n")
elements = [] elements = []
if software.get_thumbnail(): if software.get_thumbnail():
@@ -42,7 +44,7 @@ def create_details_page(app, software, backswitch_function):
# navbar # # navbar #
navbar = customtkinter.CTkFrame(app, fg_color="transparent") navbar = customtkinter.CTkFrame(app, fg_color="transparent")
navbar.grid(column=0, row=0, padx=10, pady=5, sticky="ew") navbar.grid(column=1, row=0, padx=10, pady=5, sticky="ew")
# back button # back button
back_button = customtkinter.CTkButton(navbar, text="Back", command=backswitch_function) back_button = customtkinter.CTkButton(navbar, text="Back", command=backswitch_function)
@@ -65,7 +67,7 @@ def create_details_page(app, software, backswitch_function):
thumbnail_image = customtkinter.CTkButton(app, text="", image=img, width=500, height=700, thumbnail_image = customtkinter.CTkButton(app, text="", image=img, width=500, height=700,
fg_color="transparent", hover_color="black", corner_radius=0, fg_color="transparent", hover_color="black", corner_radius=0,
command=lambda path=path: show_large_picture(app, path)) command=lambda path=path: show_large_picture(app, path))
thumbnail_image.grid(column=0, row=1, padx=10) thumbnail_image.grid(column=1, row=1, padx=10)
elements.append(thumbnail_image) elements.append(thumbnail_image)
# fonts # # fonts #
@@ -74,7 +76,7 @@ def create_details_page(app, software, backswitch_function):
# info box # # info box #
info_frame = customtkinter.CTkFrame(app, width=500) info_frame = customtkinter.CTkFrame(app, width=500)
info_frame.grid(column=1, row=1, sticky="nswe", padx=10) info_frame.grid(column=2, row=1, sticky="nswe", padx=10)
elements.append(info_frame) elements.append(info_frame)
# title # # title #

View File

@@ -16,7 +16,7 @@ class DataBackend:
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
def __init__(self, user, password, install_dir, server=None, remote_root_dir=None, def __init__(self, user, password, install_dir, server=None, remote_root_dir=None,
progress_bar_wrapper=None, tkinter_root=None): progress_bar_wrapper=None, tkinter_root=None, hide_above_age=100):
self.user = user self.user = user
self.password = password self.password = password
@@ -26,6 +26,7 @@ class DataBackend:
self.progress_bar_wrapper = progress_bar_wrapper self.progress_bar_wrapper = progress_bar_wrapper
self.root = tkinter_root self.root = tkinter_root
self.cache_dir = "./cache/" self.cache_dir = "./cache/"
self.hide_above_age = hide_above_age
def get(self, path, return_content=False): def get(self, path, return_content=False):
'''Return the contents of this path''' '''Return the contents of this path'''
@@ -100,19 +101,7 @@ class HTTP(DataBackend):
#print(self.server + HTTP.REMOTE_PATH) #print(self.server + HTTP.REMOTE_PATH)
return self.server + HTTP.REMOTE_PATH return self.server + HTTP.REMOTE_PATH
def get(self, path, cache_dir="", return_content=False, wait=False): def get_local_target(self, path):
print("Getting", path, "cache dir", cache_dir, "return content:", return_content)
if cache_dir is None:
print("Setting cache dir from backend default", "cur:", cache_dir, "new (default):", self.cache_dir)
cache_dir = self.cache_dir
# check the load cache dir #
if cache_dir:
self._create_cache_dir(cache_dir)
elif not cache_dir and not return_content:
AssertionError("Need to set either cache_dir or return_content!")
# prepend root dir if not given # # prepend root dir if not given #
fullpath = path fullpath = path
@@ -120,7 +109,36 @@ class HTTP(DataBackend):
fullpath = os.path.join(self.remote_root_dir, path) fullpath = os.path.join(self.remote_root_dir, path)
fullpath = fullpath.replace("\\", "/") fullpath = fullpath.replace("\\", "/")
local_file = os.path.join(cache_dir, fullpath) local_file = os.path.join(self.cache_dir, fullpath)
print("Local Target is", local_file)
return local_file
def local_delete_cache_file(self, path, cache_dir=None):
'''Delete a local cache file'''
print("WARNING: removing:", self.get_local_target(path))
os.remove(self.get_local_target(path))
def get(self, path, cache_dir=None, return_content=False, wait=False):
print("Getting", path, "cache dir", cache_dir, "return content:", return_content)
if cache_dir is None:
print("Setting cache dir from backend default", "cur:", cache_dir, "new (default):", self.cache_dir)
cache_dir = self.cache_dir
# fix cache path reuse #
if path.startswith(cache_dir):
path = path[len(cache_dir):]
print("Fixed path to not duble include cache dir, path:", path)
# check the load cache dir #
if cache_dir:
self._create_cache_dir(cache_dir)
elif not cache_dir and not return_content:
AssertionError("Need to set either cache_dir or return_content!")
local_file = self.get_local_target(path)
local_dir = os.path.dirname(local_file) local_dir = os.path.dirname(local_file)
# sanity check and create directory # # sanity check and create directory #
@@ -129,22 +147,50 @@ class HTTP(DataBackend):
else: else:
os.makedirs(local_dir, exist_ok=True) os.makedirs(local_dir, exist_ok=True)
print("Requiring:", fullpath) print("Requiring:", path)
if not os.path.isfile(local_file) or os.stat(local_file).st_size == 0: if not os.path.isfile(local_file) or os.stat(local_file).st_size == 0:
if return_content or wait: if return_content or wait:
print("Sync Requested")
# the content is needed for the UI now and not cached, it's needs to be downloaded synchroniously # # the content is needed for the UI now and not cached, it's needs to be downloaded synchroniously #
# as there cannot be a meaningful UI-draw without it. # # as there cannot be a meaningful UI-draw without it. #
r = requests.get(self._get_url(), params={ "path" : path, "as_string": True }) # THIS IS THE OLD WAY
# r = requests.get(self._get_url(), params={ "path" : path, "as_string": True })
# cache the download imediatelly # # # cache the download imediatelly #
with open(local_file, encoding="utf-8", mode="w") as f: # with open(local_file, encoding="utf-8", mode="w") as f:
f.write(r.text) # f.write(r.text)
# this is with streaming
chunk_size = 1024 * 1024 * 5 # 5MB
r = requests.get(self._get_url(), params={"path": path, "as_string": True}, stream=True)
r.raise_for_status()
if path.endswith(".txt"):
TYPE = "w"
else:
TYPE = "wb"
with open(local_file, TYPE) as f:
count = 0
for chunk in r.iter_content(chunk_size=chunk_size, decode_unicode=True):
print(f"Doing chunk.. {chunk_size*count}")
if chunk:
try:
f.write(chunk)
except TypeError as e:
print("Cannot write:", chunk, "..to ", path, " because it is the wrong type.", e)
raise e
f.flush()
count += 1
if return_content: if return_content:
print("Content for", fullpath, ":", r.text) print("Content for", path, ":", r.text)
return r.text return r.text
else: else:
return local_file return local_file
@@ -219,4 +265,12 @@ class HTTP(DataBackend):
print("Software List:", software_list) print("Software List:", software_list)
print("Invalid:", list(filter(lambda x: x.invalid, software_list))) print("Invalid:", list(filter(lambda x: x.invalid, software_list)))
print("Valid:", list(filter(lambda x: not x.invalid, software_list))) print("Valid:", list(filter(lambda x: not x.invalid, software_list)))
return list(filter(lambda x: not x.invalid, software_list))
# filter valid #
results_valid = list(filter(lambda x: not x.invalid, software_list))
# filer age #
print("Age limit set to", self.hide_above_age, "games have", [x.age_limit for x in software_list])
results_with_age = list(filter(lambda x: x.age_limit <= self.hide_above_age, results_valid))
return results_with_age

47
db.py Normal file
View File

@@ -0,0 +1,47 @@
from sqlalchemy import Column, String, Integer, Boolean, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session
Base = declarative_base()
import logging
logging.basicConfig()
logging.getLogger('sqlalchemy').setLevel(logging.ERROR)
class Download(Base):
__tablename__ = 'files'
path = Column(String, primary_key=True)
local_path = Column(String)
url = Column(String)
size = Column(Integer)
type = Column(String)
finished = Column(Boolean)
def __eq__(self, other):
return self.path == other.path
def __hash__(self):
return hash(self.path)
class Database:
def __init__(self, db_url="sqlite:///database.db"):
self.engine = create_engine(db_url, echo=False)
self.session_factory = sessionmaker(bind=self.engine)
self.Session = scoped_session(self.session_factory) # Thread-safe sessions
# Automatically create tables
Base.metadata.create_all(self.engine)
def session(self):
"""Returns a new session (or an existing one if in the same thread)."""
return self.Session()
def close_session(self):
"""Closes the current session."""
self.Session.remove()
# Singleton instance of Database
db = Database()

View File

@@ -8,64 +8,125 @@
# update list and widget # update list and widget
# update list and widget # update list and widget
import tkinter as tk import customtkinter as ctk
from tkinter import ttk from tkinter import ttk
import threading import threading
import random import random
import time import time
import string import string
import statekeeper
import os
class ProgressBarApp: class ProgressBarApp:
def __init__(self, root): def __init__(self, parent, data_backend):
self.root = root
self.root.title("Dynamic Progress Bars")
self.delete_all_button = tk.Button(root, text="Delete All Finished", command=self.delete_all_finished, state=tk.DISABLED) self.data_backend = data_backend
self.parent = parent
self.root = ctk.CTkFrame(parent)
#self.root.title("Dynamic Progress Bars")
self.delete_all_button = ctk.CTkLabel(self.root, text="Downloads")
self.delete_all_button.pack(pady=5) self.delete_all_button.pack(pady=5)
self.frame = tk.Frame(root) self.delete_all_button = ctk.CTkButton(self.root, text="Delete All Finished", command=self.delete_all_finished, state=ctk.DISABLED)
self.delete_all_button.pack(pady=5)
self.frame = ctk.CTkFrame(self.root)
self.frame.pack(pady=10) self.frame.pack(pady=10)
self.progress_bars = [] # Store tuples of (progressbar, frame, duration, delete_button) self.progress_bars = [] # Store tuples of (progressbar, frame, duration, delete_button)
self.running = True self.running = True
threading.Thread(target=self.add_progress_bars, daemon=True).start() self.root.after(0, self.start_tracking_progress_bars)
def add_progress_bars(self): def start_tracking_progress_bars(self):
while self.running:
time.sleep(3) # Wait before adding a new progress bar
frame = tk.Frame(self.frame) self.already_tracked = set()
frame.pack(fill=tk.X, pady=2) self.check_for_new_progress_bars()
def check_for_new_progress_bars(self):
downloads = set(statekeeper.get_download())
new = downloads - self.already_tracked
self.already_tracked |= downloads
for element in new:
frame = ctk.CTkFrame(self.frame)
frame.pack(fill=ctk.X, pady=2)
progress = ttk.Progressbar(frame, length=200, mode='determinate') progress = ttk.Progressbar(frame, length=200, mode='determinate')
progress.pack(side=tk.LEFT, padx=5) progress.pack(side=ctk.LEFT, padx=5)
delete_button = tk.Button(frame, text="Delete", command=lambda f=frame: self.delete_progress(f), state=tk.DISABLED) delete_button = ctk.CTkButton(frame, text="Delete", command=lambda f=frame: self.delete_progress(f), state=ctk.DISABLED)
delete_button.pack(side=tk.LEFT, padx=5) delete_button.pack(side=ctk.LEFT, padx=5)
random_letter = random.choice(string.ascii_uppercase) label = ctk.CTkLabel(frame, text=os.path.basename(element.path))
label = tk.Label(frame, text=random_letter) label.pack(side=ctk.LEFT, padx=5)
label.pack(side=tk.LEFT, padx=5)
duration = random.randint(1, 10) # Random fill time self.progress_bars.insert(0, (progress, frame, delete_button)) # Insert at the top
threading.Thread(target=self.fill_progress, args=(progress, duration, frame, delete_button), daemon=True).start() frame.pack(fill=ctk.X, pady=2, before=self.frame.winfo_children()[-1] if self.frame.winfo_children() else None)
print("Starting tracker for", element.path)
threading.Thread(target=self.fill_progress, args=(progress, element.path, frame, delete_button), daemon=True).start()
# Schedule the next check in 2 seconds
if self.running:
self.root.after(2000, self.check_for_new_progress_bars)
def fill_progress(self, progress, path, frame, delete_button):
fail_count = 0
same_size_count = 0
prev_precent = 0
while True:
print("Checking download progress..")
def fill_progress(self, progress, duration, frame, delete_button):
for i in range(101): # Fill progress bar over 'duration' seconds
time.sleep(duration / 100)
if not progress.winfo_exists(): # Check if progress bar still exists if not progress.winfo_exists(): # Check if progress bar still exists
return return
self.root.after(0, progress.config, {"value": i})
self.root.after(0, delete_button.config, {"state": tk.NORMAL}) try:
percent_filled = statekeeper.get_percent_filled(path)
except OSError as e:
fail_count += 1
if fail_count > 6:
raise e
else:
time.sleep(1)
continue
self.progress_bars.append((progress, frame, duration, delete_button)) print("Percent filled:", percent_filled, path)
if percent_filled >= 99.9:
self.root.after(0, progress.configure, { "value" : 100 })
print("Finished", path)
break
else:
self.root.after(0, progress.configure, { "value" : percent_filled })
time.sleep(0.5)
# check for stuck downloads #
print("same size count", same_size_count)
if prev_precent == percent_filled:
same_size_count += 1
else:
same_size_count = 0
if same_size_count > 100:
self.root.after(0, delete_button.configure, {"state": ctk.NORMAL, "text": "Failed - Delete file manually!"})
self.progress_bars.append((progress, frame, path, delete_button))
self.update_delete_all_button()
statekeeper.log_end_download(path)
self.data_backend.local_delete_cache_file(path)
break
prev_precent = percent_filled
# handle finished download #
self.root.after(0, delete_button.configure, {"state": ctk.NORMAL})
self.progress_bars.append((progress, frame, path, delete_button))
self.update_delete_all_button() self.update_delete_all_button()
def delete_progress(self, frame): def delete_progress(self, frame):
frame.destroy() frame.destroy()
self.progress_bars = [(p, f, d, b) for p, f, d, b in self.progress_bars if f != frame] self.progress_bars = [(p, f, d) for p, f, d in self.progress_bars if f != frame]
self.update_delete_all_button() self.update_delete_all_button()
def delete_all_finished(self): def delete_all_finished(self):
@@ -76,16 +137,10 @@ class ProgressBarApp:
def update_delete_all_button(self): def update_delete_all_button(self):
if self.progress_bars: if self.progress_bars:
self.delete_all_button.config(state=tk.NORMAL) self.delete_all_button.configure(state=ctk.NORMAL)
else: else:
self.delete_all_button.config(state=tk.DISABLED) self.delete_all_button.configure(state=ctk.DISABLED)
def on_close(self): def on_close(self):
self.running = False self.running = False
self.root.destroy() self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = ProgressBarApp(root)
root.protocol("WM_DELETE_WINDOW", app.on_close)
root.mainloop()

View File

@@ -11,6 +11,7 @@ def render_path(path, install_location, game_directory,):
result_path = path[:-len(".j2")] result_path = path[:-len(".j2")]
# prepare template # # prepare template #
print("JINJA-> cwd: ", os.getcwd(), "path:", path)
input_content = "" input_content = ""
with open(path, encoding="utf-16") as f: with open(path, encoding="utf-16") as f:
input_content = f.read() input_content = f.read()

View File

@@ -4,4 +4,4 @@ customtkinter
tqdm tqdm
Jinja2 Jinja2
pyyaml pyyaml
pywin32==<version>; platform_system=="Windows" pywin32==306; platform_system=="Windows"

View File

@@ -11,6 +11,8 @@ import jinja_helper
import threading import threading
import sys import sys
import tkinter import tkinter
import statekeeper
from tkinter import messagebox
class Software: class Software:
@@ -57,6 +59,7 @@ class Software:
self.run_exe = meta.get("run_exe") self.run_exe = meta.get("run_exe")
self.installer = meta.get("installer") self.installer = meta.get("installer")
self.installer_no_admin = meta.get("installer_no_admin") self.installer_no_admin = meta.get("installer_no_admin")
self.age_limit = meta.get("age_limit") or 20
self.pictures = [ self.backend.get(pp, self.cache_dir) for pp in self.pictures = [ self.backend.get(pp, self.cache_dir) for pp in
self.backend.list(os.path.join(self.directory, "pictures"), fullpaths=True) ] self.backend.list(os.path.join(self.directory, "pictures"), fullpaths=True) ]
@@ -81,8 +84,16 @@ class Software:
'''Extract a cached, downloaded zip to the target location''' '''Extract a cached, downloaded zip to the target location'''
software_path = os.path.join(target, self.title) software_path = os.path.join(target, self.title)
if os.path.isdir(software_path): if os.path.isdir(software_path):
return # TODO better skip overwrite = messagebox.askyesno(
"Overwrite Existing Directory",
f"The directory '{software_path}' already exists.\nDo you want to overwrite it?"
)
if not overwrite:
print("Skipping install as instructed by user...")
return
os.makedirs(software_path, exist_ok=True) os.makedirs(software_path, exist_ok=True)
with zipfile.ZipFile(cache_src, 'r') as zip_ref: with zipfile.ZipFile(cache_src, 'r') as zip_ref:
@@ -98,6 +109,7 @@ class Software:
#self.progress_bar_wrapper.set_text( #self.progress_bar_wrapper.set_text(
# text="Extracting: {:.2f}%".format(count/len(total_count)*100)) # text="Extracting: {:.2f}%".format(count/len(total_count)*100))
except zipfile.error as e: except zipfile.error as e:
print(e)
pass # TODO ??? pass # TODO ???
#zip_ref.extractall(software_path) #zip_ref.extractall(software_path)
@@ -131,20 +143,27 @@ class Software:
except IndexError: except IndexError:
print("No main_dir:", path) print("No main_dir:", path)
raise AssertionError("No main_dir for this software") raise AssertionError("No main_dir for this software")
statekeeper.log_begin_download(remote_file, self.backend.get_local_target(remote_file), self.backend._get_url())
local_file = self.backend.get(remote_file, self.cache_dir, wait=True) local_file = self.backend.get(remote_file, self.cache_dir, wait=True)
statekeeper.log_end_download(remote_file)
print("Deciding on installer...")
# execute or unpack # # execute or unpack #
if local_file.endswith(".exe"): if local_file.endswith(".exe"):
print("Target is an executable.. running as installer.")
if os.name != "nt" and not os.path.isabs(local_file): if os.name != "nt" and not os.path.isabs(local_file):
# need abs path for wine # # need abs path for wine #
local_file = os.path.join(os.getcwd(), local_file) local_file = os.path.join(os.getcwd(), local_file)
localaction.run_exe(local_file) localaction.run_exe(local_file)
elif local_file.endswith(".zip"): elif local_file.endswith(".zip"):
print("Target is a zip.. unpacking first.")
self._extract_to_target(local_file, self.backend.install_dir) self._extract_to_target(local_file, self.backend.install_dir)
# download & install registry files # # download & install registry files #
for rf in self.reg_files: for rf in self.reg_files:
path = self.backend.get(rf, cache_dir=self.cache_dir) path = self.backend.get(rf, cache_dir=self.cache_dir, wait=True)
if path.endswith(".j2"): if path.endswith(".j2"):
target_install_dir = os.path.join(self.backend.install_dir, self.title) target_install_dir = os.path.join(self.backend.install_dir, self.title)
print("Install dir Registry:", target_install_dir) print("Install dir Registry:", target_install_dir)

View File

@@ -1,6 +1,12 @@
import requests import requests
import os import os
import sqlalchemy
import threading import threading
from db import db, Download
from sqlalchemy import or_, and_
def _bytes_to_mb(size):
return size / (1024*1024)
def add_to_download_queue(url, path): def add_to_download_queue(url, path):
'''The download is added to the global queue and downloaded eventually''' '''The download is added to the global queue and downloaded eventually'''
@@ -34,3 +40,86 @@ def _download(url, path):
else: else:
raise AssertionError("Non-200 Response for:", url, path, response.status_code, response.text) raise AssertionError("Non-200 Response for:", url, path, response.status_code, response.text)
def log_begin_download(path, local_path, url):
session = db.session()
print("Download path", path)
path_exists = session.query(Download).filter(and_(Download.path==path, Download.finished==False)).first()
if path_exists and False: # TODO FIX THIS
print("DAFUG", path_exists)
print("WTF", path_exists.path)
raise AssertionError("ERROR: {} is already downloading.".format(path))
else:
print("Adding to download log:", path)
session.merge(Download(path=path, size=-1, type="download", local_path=local_path, url=url, finished=False))
session.commit()
db.close_session()
def log_end_download(path):
print("Downlod end logged", path)
session = db.session()
obj = session.query(Download).filter(Download.path==path).first()
if not obj:
raise AssertionError("ERROR: {} is not downloading/cannot remove.".format(path))
else:
print("Removing from download log:", path)
obj.finished = True
session.merge(obj)
session.commit()
db.close_session()
def get_download_size(path):
session = db.session()
obj = session.query(Download).filter(Download.path==path).first()
if not obj :
print("Warning: Download-Object does no longe exist in DB. Returning -1")
return -1
elif obj.size != -1:
session.close()
return obj.size
# query size #
r = requests.get(obj.url, params={"path": path, "info": 1})
r.raise_for_status()
size = r.json()["size"]
obj.size = _bytes_to_mb(size)
session.merge(obj)
session.commit()
session.close()
return size
def get_percent_filled(path):
session = db.session()
obj = session.query(Download).filter(Download.path==path, Download.finished==False).first()
if not obj:
return 100 # means its finished
size = _bytes_to_mb(os.stat(obj.local_path).st_size)
total_size = get_download_size(obj.path)
session.close()
if total_size == 0:
return 0
print("Current filled:", size / total_size * 100)
return size / total_size * 100
def get_download(path=None):
session = db.session()
if path:
MIN_SIZE_PGBAR_LIMIT = 1024*1024*100 # 100mb
downloads = session.query(Download).filter(Download.size>MIN_SIZE_PGBAR_LIMIT, Download.finished==False).all()
else:
downloads = session.query(Download).filter(Download.finished==False).all()
session.close()
return downloads

14
todo.txt Normal file
View File

@@ -0,0 +1,14 @@
# important
## downloaded file hash sum check
## apply custom tkinter look to pg window & move pg window to better relativ start location so it's no longer blocking the back button by default
## zip extraction progress
## fix initial startup pictures not loading
## implement flush download cache button
## fix Call of duty installation chain
## test on fresh windows
## prepare bf2
nice to have
# implement full remove
# player name templating (e.g. in regex or game file folder)
# oauth login server