Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions updater.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Updater description

## Releases
Updater analyses the Releases section of the repository. Each game, to be downloaded by the updater, should have:

- ${GAME_NAME}-${OS_NAME}-script.sh (__required__, file type on the OS) - goes into LaunchScripts folder
- ${GAME_NAME}-${OS_NAME}-${ARCHITECTURE}.tar.gz (_optional_) - files to be put in the game's directory - OS and architecture specific
- ${GAME_NAME}-gamelists-entry.xml (_optional_) - entry for gamelists, must be an XML file - _this feature is not developed_
- ${GAME_NAME}-assets.tar.gz (_optional_) - files to be put into the game's directory, non-OS specific

### Packing of the releases
The game will be unpacking everything that is put in the release directly under the directory with the same name. In other words, this would be the script to pack exactly the contents of a given game titles `${GAME_NAME}`
```sh
cd ./${GAME_NAME}-linux-aarch64
tar -czvf ../${GAME_NAME}-linux-aarch64.tar.gz .
cd ..
```

### Run script
As the user may change any directory settings, denote game's directory as `${GAME_DIR}`. Run script will be put into the respective directory
346 changes: 346 additions & 0 deletions updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import json
from posixpath import dirname
import sys
from packaging import version
import platform
from collections import defaultdict
from pathlib import Path
import shutil
import tarfile
import requests
import os
import stat

# Path to the file where updater stores the info on last checked release etc.
# The file MUST be writable
# Overwritten with --updater-info-file
updater_info_path = "./updater.json"
# Where to put games, can be overridden via --destination-path
dest_path = '/tmp/.games'
# Where to put launch scripts, overwritten via --launch-scripts-directory
launch_scripts_dir = '/tmp/.arcade-games-launch-scripts'
# Github repository to check for updates. Format: "owner/repo"
GITHUB_REPO = "thoth-tech/arcade-games"
LAUNCHSCRIPT_SUFFIX = '-launchscript.sh'

def clear_file(fd):
fd.truncate(0)
fd.seek(0)


# Downgrading may be allowed with --allow-downgrade.
allow_downgrading = False

# This may be allowed with --allow-removing-destination-path
# Allows to remove the destination path if it is a file.
allow_removing_destination_path = False

def profile_platform(platform_title: str):
if platform_title == 'linux':
return "linux"
elif platform_title == 'win32':
return "win"
elif platform_title == 'macos':
return "macos"
else:
return None

# We are accepting everything compiled for 32-bit from repo
def profile_architecture(architecture_title: str):
arch = platform.machine().lower()

if "aarch64" in arch or "arm64" in arch:
return "aarch64"
elif "arm" in arch:
return "arm"
elif "x86_64" in arch or "amd64" in arch or "x64" in arch:
return "amd64"
elif "i386" in arch or "i686" in arch or "x86" in arch:
return "x86"
else:
return None

def file_readable_to_updater(file_name: str):
# TODO: for Win32, this should be changed to bat
return file_name.endswith('.tar.gz') or file_name.endswith('.sh')

# downloads the given archive and puts it under the given name into destination_path
def download_and_unpack(file_name: str, url: str, destination_path: str|Path):
if file_name.endswith('.tar.gz'):
try:
response = requests.get(url, stream=True)
if response.status_code == 200:
with tarfile.open(fileobj=response.raw, mode='r:gz') as tar:
tar.extractall(path=destination_path)
else:
sys.stderr.write("Error: failed to download from the url " + url + ". Status code: " + str(response.status_code) + "\n")

except ConnectionError:
sys.stderr.write("Failed to connect to the internet. No updates have been pulled. The old game might have been deleted.")
exit(14)
else:
sys.stderr.write(f"Error: {file_name} is not a .tar.gz file and hence may not be unpacked here")

# downloads the given file into the destination path, changes ${GAME_DIR} to the given one, gives the file the run privilege
def download_launch_script(file_name: str, url: str, destination_path: str|Path, game_dir: str|Path):
try:
response = requests.get(url, stream=True)
if response.status_code == 200:
update_script_txt = response.text.replace("${GAME_DIR}", str(game_dir))
update_script_dest_path = destination_path / file_name
# tries to delete the file, ignores if it never existed anyway
try:
update_script_dest_path.unlink()
except FileNotFoundError:
pass
with open(update_script_dest_path, "w+") as script_fd:
script_fd.write(update_script_txt)
# these are chmod +x
new_mode = update_script_dest_path.stat().st_mode | stat.S_IXUSR
update_script_dest_path.chmod(new_mode)
else:
sys.stderr.write("Error: failed to download from the url " + url + ". Status code: " + str(response.status_code) + "\n")

except ConnectionError:
sys.stderr.write("Failed to connect to the internet. No updates have been pulled. The old game might have been deleted.")
exit(15)


# unpacks the .tar.gz's and puts the scripts in the respective folder
def unpack_games(available_games, file_contents, destination_path, launch_scripts_dir):
for game_title, available_files in available_games.items():
launch_script = available_files.get('launchscript')
if launch_script == None:
# no launch script - don't touch the whole game, as the machine can't work with it
pass

# at this point, the launchscript is definitely there
game_directory = destination_path / game_title
download_launch_script(launch_script["filename"],launch_script["url"], launch_scripts_dir, game_directory)

# deal with assets & executable
dir_recreated = [False]
def recreate_dir(dir_recreated): # recreates the dir for the current game_title
dir_recreated[0] = True
if game_directory.exists():
shutil.rmtree(game_directory)
game_directory.mkdir(parents=True)

# check if there are assets and unpack
assets_file = available_files.get('assets')
if assets_file != None:
if not dir_recreated[0]: recreate_dir(dir_recreated)
# untar the respective file and push into the game directory
download_and_unpack(assets_file['filename'], assets_file['url'], game_directory)
pass

# check if there are executables and unpack
executable_file = available_files.get('executable')
if executable_file != None:
if not dir_recreated[0]: recreate_dir(dir_recreated)
# untar the respective file and push into the game directory
download_and_unpack(executable_file['filename'], executable_file['url'], game_directory)
pass


for file_type, file_name in available_files.items():
pass

# the main function here - checks the repo. Downloads the new files, if required
def check_updates(fd, destination_system, destination_architecture, destination_path, allow_downgrading):
file_contents = {}
try:
json_contents = json.load(fd)
if type(json_contents) != dict:
raise json.JSONDecodeError("Invalid content in the updater info file. Expected a JSON object.", doc=str(json_contents), pos=0)
file_contents = json_contents
except json.JSONDecodeError as e:
# Invalid content, treat it as if it isn't there
sys.stderr.write(f"Warning: invalid content in the updater info file. Treating it as if it isn't there. File path: {updater_info_path.__str__()}\n")
# Clearing and initializing the file
clear_file(fd)
fd.write("{}")

try:
response = requests.get("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest");
release_info = response.json()
except ConnectionError:
sys.stderr.write("Failed to connect to the internet. No updates have been pulled.")
exit(13)


# destination path is a file. We don't want to remove it unless required.
if os.path.isfile(destination_path):
if allow_removing_destination_path:
try:
os.unlink(destination_path)
except PermissionError as e:
sys.stderr.write("Attempted to remove the file, but did not have permissions. Refusing to continue.\n")
exit(6)
else:
sys.stderr.write("The destination path is a file. Refusing to continue.\n")
exit(5)

try:
# Compare the version of the last release with the version of currently installed app.
remote_version = version.parse(release_info["tag_name"])
# get the current version, default to "v0"
local_version = version.parse(file_contents.get("last_checked_release_id", "v0"))
if local_version > remote_version:
if allow_downgrading:
sys.stderr.write("Warning: the version of currently installed app is higher than the version of last release.\n")
else:
sys.stderr.write("Error: the version of currently installed app is higher than the version of last release. No downgrading allowed. Local version: " + str(local_version) + ", remote version: " + str(remote_version) + "\n")
sys.stderr.write("Error: downgrading may be allowed with --allow-downgrade\n")
exit(3)
elif local_version == remote_version:
sys.stderr.write("The version of currently installed app is the same as the version of last release. No update needed.\n")
exit(0)

available_games = defaultdict(dict)

file_contents["last_checked_release_id"] = release_info["tag_name"]
if file_contents.get("files") is None:
file_contents["files"] = {}

# We got here means we take the new release
for asset in release_info["assets"]:
if not file_readable_to_updater(asset["name"]):
sys.stderr.write(f"Warning: updater does not work with file type: {asset["name"]}.\n")
continue

# unloading the filename into variables, so we can work with it
first_dot = asset["name"].find(".") # never -1, because it's either .tar.gz or .sh
file_name = asset["name"][:first_dot] or ""
file_name_ext = asset["name"][first_dot:] or ""
filename_segments = file_name.split("-") or [None]

game_title = filename_segments[0]

if filename_segments[1] == 'assets' and file_name_ext == '.tar.gz':
# enqueue this file for unpacking always
if available_games.get(game_title) == None:
available_games[game_title] = {}
available_games[game_title]["assets"] = {"filename": asset["name"], "url": asset["browser_download_url"]}
continue
elif filename_segments[1] == dest_platform and filename_segments[2] == dest_arch and file_name_ext == '.tar.gz':
if available_games.get(game_title) == None:
available_games[game_title] = {}
available_games[game_title]["executable"] = {"filename": asset["name"], "url": asset["browser_download_url"]}
continue
# TODO: change extension to .bat for win32
elif filename_segments[1] == 'launchscript' and file_name_ext == '.sh':
if available_games.get(game_title) == None:
available_games[game_title] = {}
available_games[game_title]["launchscript"] = {"filename": asset["name"], "url": asset["browser_download_url"]}
continue
else:
sys.stderr.write(f"Warning: file is not eligible for this machine: {asset["name"]}.\n")
continue


# we have collected all available games by this point
pass


# Create the directory for games if it does not exist yet
if not os.path.isdir(destination_path):
try:
Path(destination_path).mkdir(parents=True)
except PermissionError:
sys.stderr.write(f"Could not proceed with creating directory in {destination_path}. Refusing to proceed.\n")
exit(6)

if launch_scripts_dir.exists():
if not launch_scripts_dir.is_dir():
# exists, but not a directory

sys.stderr.write(f"{launch_scripts_dir} is not a directory. Refusing to proceed.\n")
exit(16)

# exists, is a dir
else:
# does not exist, so we create one
launch_scripts_dir.mkdir(parents=True)

# Unpack available games and assets for the given platform
unpack_games(available_games, file_contents, destination_path, launch_scripts_dir)

clear_file(fd)
json.dump(file_contents, fd)
except KeyError as e:
sys.stderr.write(f"Error: could not get last release from the GH Releases. Please ensure that repository https://github.com/{GITHUB_REPO}/ exists and has at least one current release.\n")
exit(17)





try:
# TODO: read argument from the input
dest_platform = profile_platform(sys.platform)
if dest_platform is None:
sys.stderr.write("Error: unsupported platform. Please use one of linux, win, macos\n")
exit(18)

dest_arch = profile_architecture(platform.machine())
if dest_arch == None:
sys.stderr.write("Error: unsupported platform. Please use one of arm, arm64, x86, x64\n")
exit(8)

for arg in sys.argv:
if arg == "--allow-downgrade":
allow_downgrading = True
sys.stderr.write("Warning: Downgrading allowed by the command.\n")
continue

dest_path_prefix = '--destination-path='
if arg.startswith(dest_path_prefix):
maybe_dest_path = arg[len(dest_path_prefix):]
if len(maybe_dest_path) == 0:
sys.stderr.write("Error: destination path has not been entered\n")
exit(9)
dest_path = maybe_dest_path

updater_info_file_prefix = '--updater-info-file='
if arg.startswith(updater_info_file_prefix):
maybe_updater_info_file = arg[len(updater_info_file_prefix):]
if len(maybe_updater_info_file) == 0:
sys.stderr.write("Error: updater info file path has not been entered\n")
exit(10)
updater_info_path = maybe_updater_info_file

launch_scripts_prefix = '--launch-scripts-directory='
if arg.startswith(launch_scripts_prefix):
maybe_launch_scripts_dir = arg[len(launch_scripts_prefix):]
if len(maybe_launch_scripts_dir) == 0:
sys.stderr.write("Error: updater info file path has not been entered\n")
exit(12)
launch_scripts_dir = maybe_launch_scripts_dir

updater_info_path = Path(dirname(__file__)).joinpath(updater_info_path).resolve()
dest_path = Path(dirname(__file__)).joinpath(dest_path).resolve()
launch_scripts_dir = Path(dirname(__file__)).joinpath(launch_scripts_dir).resolve()

f = None
try:
if not os.path.isfile(updater_info_path):
Path(updater_info_path).touch()
with open(updater_info_path, "r+") as f:
check_updates(f, destination_system=dest_platform, destination_architecture=dest_arch, destination_path=dest_path, allow_downgrading=allow_downgrading)
except PermissionError as e:
sys.stderr.write("Error: no permission to write to the updater info file. File path: " + updater_info_path + "\n")
exit(2)
except KeyboardInterrupt as e:
sys.stderr.write('Interrupting... ')
try:
# close the file in case of being here
f.__exit()
except Exception:
pass
exit(99)

exit(0)
except KeyboardInterrupt as e:
sys.stderr.write('Interrupting... Some files may not have been closed properly')
exit(99)
Loading