Support GDM installation for GNOME versions below 44

- Fixes #33, #19;
- Show user-friendly message if glib2-devel is not installed;
- Use tempfile for temp folder;
- Little code improvements;
- Tried to fix not passing test.
This commit is contained in:
Vladyslav Hroshev
2025-03-16 18:25:00 +02:00
parent ad3078e498
commit 1bc6a89d77
7 changed files with 119 additions and 89 deletions

View File

@@ -86,6 +86,10 @@ Icon theme: https://github.com/vinceliuice/Colloid-icon-theme
> [!WARNING] > [!WARNING]
> I am not responsible for any damage caused by the installation of the theme. If you have any problems, please open an issue. > I am not responsible for any damage caused by the installation of the theme. If you have any problems, please open an issue.
> [!IMPORTANT]
> Install `glib2-devel` package before running the program.
> For Debian-based systems, use the `libglib2.0-dev` package.
1. Open the terminal. 1. Open the terminal.
2. Go to the directory with the theme. 2. Go to the directory with the theme.
3. Run the program with the `--gdm` option 3. Run the program with the `--gdm` option

View File

@@ -127,7 +127,7 @@ def apply_colors(args, theme, colors, gdm=False):
theme_name = args.name if args.name else f'hue{hue}' theme_name = args.name if args.name else f'hue{hue}'
install_theme(theme, hue, theme_name, args.sat, gdm) install_theme(theme, hue, theme_name, args.sat, gdm)
return return None
else: else:
for color in colors["colors"]: for color in colors["colors"]:
@@ -140,10 +140,13 @@ def apply_colors(args, theme, colors, gdm=False):
install_theme(theme, hue, color, sat, gdm) install_theme(theme, hue, color, sat, gdm)
if gdm: if gdm:
return return None
if not is_colors: if not is_colors:
print('No color arguments specified. Use -h or --help to see the available options.') print('No color arguments specified. Use -h or --help to see the available options.')
return 1
return None
def global_theme(args, colors): def global_theme(args, colors):
@@ -155,20 +158,16 @@ def global_theme(args, colors):
gdm_theme = GlobalTheme(colors, f"{config.raw_theme_folder}/{config.gnome_folder}", gdm_theme = GlobalTheme(colors, f"{config.raw_theme_folder}/{config.gnome_folder}",
config.global_gnome_shell_theme, config.gnome_shell_gresource, config.global_gnome_shell_theme, config.gnome_shell_gresource,
config.temp_folder, is_filled=args.filled) config.temp_folder, mode=args.mode, is_filled=args.filled)
if args.remove: if args.remove:
gdm_rm_status = gdm_theme.remove() gdm_rm_status = gdm_theme.remove()
if gdm_rm_status == 0: if gdm_rm_status == 0:
print("GDM theme removed successfully.") print("GDM theme removed successfully.")
return 0 return
try:
apply_colors(args, gdm_theme, colors, gdm=True) if apply_colors(args, gdm_theme, colors, gdm=True) is None:
except Exception as e:
print(f"Error: {e}")
return 1
else:
print("\nGDM theme installed successfully.") print("\nGDM theme installed successfully.")
print("You need to restart gdm.service to apply changes.") print("You need to restart gdm.service to apply changes.")
print("Run \"systemctl restart gdm.service\" to restart GDM.") print("Run \"systemctl restart gdm.service\" to restart GDM.")
@@ -203,10 +202,10 @@ def main():
else: else:
local_theme(args, colors) local_theme(args, colors)
apply_gnome_theme() apply_gnome_theme()
# TODO: inform user about already applied theme. if not, apply it manually
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
shutil.rmtree(config.temp_folder, ignore_errors=True) finally:
shutil.rmtree(config.temp_folder, ignore_errors=True)

View File

@@ -1,5 +1,7 @@
from tempfile import gettempdir
# folder definitions # folder definitions
temp_folder = ".temp" temp_folder = f"{gettempdir()}/marble"
gnome_folder = "gnome-shell" gnome_folder = "gnome-shell"
temp_gnome_folder = f"{temp_folder}/{gnome_folder}" temp_gnome_folder = f"{temp_folder}/{gnome_folder}"
tweaks_folder = "tweaks" tweaks_folder = "tweaks"

View File

@@ -1,21 +1,32 @@
import os import os
import subprocess import subprocess
import shutil
from .theme import Theme from .theme import Theme
from .utils import label_files, remove_properties, remove_keywords from .utils import label_files, remove_properties, remove_keywords, gnome
from . import config from . import config
class ThemePrepare:
"""
Theme object prepared for installation
"""
def __init__(self, theme, theme_file, should_label=False):
self.theme = theme
self.theme_file = theme_file
self.should_label = should_label
class GlobalTheme: class GlobalTheme:
def __init__(self, colors_json, theme_folder, destination_folder, destination_file, temp_folder, def __init__(self, colors_json, theme_folder, destination_folder, destination_file, temp_folder,
is_filled=False): mode=None, is_filled=False):
""" """
Initialize GlobalTheme class Initialize GlobalTheme class
:param colors_json: location of a json file with colors :param colors_json: location of a json file with colors
:param theme_folder: raw theme location :param theme_folder: raw theme location
:param destination_folder: folder where themes will be installed :param destination_folder: folder where themes will be installed
:param temp_folder: folder where files will be collected :param temp_folder: folder where files will be collected
:param mode: theme mode (light or dark). applied only for gnome-shell < 44
:param is_filled: if True, theme will be filled :param is_filled: if True, theme will be filled
""" """
@@ -23,32 +34,40 @@ class GlobalTheme:
self.theme_folder = theme_folder self.theme_folder = theme_folder
self.destination_folder = destination_folder self.destination_folder = destination_folder
self.destination_file = destination_file self.destination_file = destination_file
self.temp_folder = f"{temp_folder}/gdm" self.temp_folder = os.path.join(temp_folder, "gdm")
self.backup_file = f"{self.destination_file}.backup" self.backup_file = f"{self.destination_file}.backup"
self.backup_trigger = "\n/* Marble theme */\n" # trigger to check if theme is installed self.backup_trigger = "\n/* Marble theme */\n" # trigger to check if theme is installed
self.extracted_theme = f"{self.temp_folder}/{config.extracted_gdm_folder}" self.extracted_theme = os.path.join(self.temp_folder, config.extracted_gdm_folder)
self.gst = f"{self.destination_folder}/{self.destination_file}" # use backup file if theme is installed self.gst = os.path.join(self.destination_folder, self.destination_file) # use backup file if theme is installed
self.extracted_light_theme = f"{self.extracted_theme}/gnome-shell-light.css"
self.extracted_dark_theme = f"{self.extracted_theme}/gnome-shell-dark.css"
os.makedirs(self.temp_folder, exist_ok=True) # create temp folder self.themes: list[ThemePrepare] = []
# create light and dark themes try:
self.light_theme = Theme("gnome-shell-light", self.colors_json, self.theme_folder, gnome_version = gnome.gnome_version()
self.extracted_theme, self.temp_folder, mode='light', is_filled=is_filled) gnome_major = gnome_version.split(".")[0]
self.dark_theme = Theme("gnome-shell-dark", self.colors_json, self.theme_folder, if int(gnome_major) >= 44:
self.extracted_theme, self.temp_folder, mode='dark', is_filled=is_filled) self.themes += [
self.__create_theme("gnome-shell-light", mode='light', should_label=True, is_filled=is_filled),
self.__create_theme("gnome-shell-dark", mode='dark', is_filled=is_filled)
]
except Exception as e:
print(f"Error: {e}")
print("Using single theme.")
def __del__(self): if not self.themes:
""" self.themes.append(
Delete temp folder self.__create_theme(
""" "gnome-shell", mode=mode if mode else 'dark', is_filled=is_filled))
del self.light_theme
del self.dark_theme
shutil.rmtree(self.temp_folder) def __create_theme(self, theme_type, mode=None, should_label=False, is_filled=False):
"""Helper to create theme objects"""
theme = Theme(theme_type, self.colors_json, self.theme_folder,
self.extracted_theme, self.temp_folder,
mode=mode, is_filled=is_filled)
theme_file = os.path.join(self.extracted_theme, f"{theme_type}.css")
return ThemePrepare(theme=theme, theme_file=theme_file, should_label=should_label)
def __is_installed(self): def __is_installed(self):
""" """
@@ -56,33 +75,37 @@ class GlobalTheme:
:return: True if theme is installed, False otherwise :return: True if theme is installed, False otherwise
""" """
with open(f"{self.destination_folder}/{self.destination_file}", "rb") as f: with open(self.gst, "rb") as f:
content = f.read() return self.backup_trigger.encode() in f.read()
return self.backup_trigger.encode() in content
def __extract(self): def __extract(self):
""" """
Extract gresource files to temp folder Extract gresource files to temp folder
""" """
print("Extracting gresource files...") print("Extracting gresource files...")
gst = self.gst resources = subprocess.getoutput(f"gresource list {self.gst}").split("\n")
workdir = self.temp_folder prefix = "/org/gnome/shell/"
# Get the list of resources try:
resources = subprocess.getoutput(f"gresource list {gst}").split("\n") for resource in resources:
resource_path = resource.replace(prefix, "")
dir_path = os.path.join(self.temp_folder, os.path.dirname(resource_path))
output_path = os.path.join(self.temp_folder, resource_path)
# Create directories os.makedirs(dir_path, exist_ok=True)
for r in resources: with open(output_path, 'wb') as f:
r = r.replace("/org/gnome/shell/", "") subprocess.run(["gresource", "extract", self.gst, resource], stdout=f, check=True)
directory = os.path.join(workdir, os.path.dirname(r))
os.makedirs(directory, exist_ok=True)
# Extract resources except FileNotFoundError as e:
for r in resources: if "gresource" in str(e):
output_path = os.path.join(workdir, r.replace("/org/gnome/shell/", "")) print("Error: 'gresource' command not found.")
subprocess.run(f"gresource extract {gst} {r} > {output_path}", shell=True) print("Please install the glib2-devel package:")
print(" - For Fedora/RHEL: sudo dnf install glib2-devel")
print(" - For Ubuntu/Debian: sudo apt install libglib2.0-dev")
print(" - For Arch: sudo pacman -S glib2-devel")
raise Exception("Missing required dependency: glib2-devel") from e
raise
def __add_gnome_styles(self, theme): def __add_gnome_styles(self, theme):
""" """
@@ -102,25 +125,17 @@ class GlobalTheme:
:param sat: color saturation :param sat: color saturation
""" """
# add -light label to light theme files because they are installed to the same folder for theme in self.themes:
label_files(self.light_theme.temp_folder, "light", self.light_theme.main_styles) if theme.should_label:
label_files(theme.theme.temp_folder, "light", theme.theme.main_styles)
# remove !important from the gnome file remove_keywords(theme.theme_file, "!important")
remove_keywords(self.extracted_light_theme, "!important") remove_properties(theme.theme_file, "background-color", "color", "box-shadow", "border-radius")
remove_keywords(self.extracted_dark_theme, "!important")
# remove properties from the gnome file self.__add_gnome_styles(theme.theme)
props_to_remove = ("background-color", "color", "box-shadow", "border-radius")
remove_properties(self.extracted_light_theme, *props_to_remove)
remove_properties(self.extracted_dark_theme, *props_to_remove)
# add gnome styles to the start of the file theme.theme.install(hue, color, sat, destination=self.extracted_theme)
self.__add_gnome_styles(self.light_theme)
self.__add_gnome_styles(self.dark_theme)
# build code for gnome-shell-theme.gresource.xml
self.light_theme.install(hue, color, sat, destination=self.extracted_theme)
self.dark_theme.install(hue, color, sat, destination=self.extracted_theme)
def __backup(self): def __backup(self):
""" """
@@ -132,26 +147,24 @@ class GlobalTheme:
# backup installed theme # backup installed theme
print("Backing up default theme...") print("Backing up default theme...")
os.system(f"cp -aT {self.gst} {self.gst}.backup") subprocess.run(["cp", "-aT", self.gst, f"{self.gst}.backup"], cwd=self.destination_folder, check=True)
def __generte_gresource_xml(self): def __generate_gresource_xml(self):
""" """
Generates.gresource.xml Generates.gresource.xml
""" """
# list of files to add to gnome-shell-theme.gresource.xml # list of files to add to gnome-shell-theme.gresource.xml
files = list(f"<file>{file}</file>" for file in os.listdir(self.extracted_theme)) files = [f"<file>{file}</file>" for file in os.listdir(self.extracted_theme)]
nl = "\n" # fstring doesn't support newline character nl = "\n" # fstring doesn't support newline character
ready_xml = f"""<?xml version="1.0" encoding="UTF-8"?> return f"""<?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/org/gnome/shell/theme"> <gresource prefix="/org/gnome/shell/theme">
{nl.join(files)} {nl.join(files)}
</gresource> </gresource>
</gresources>""" </gresources>"""
return ready_xml
def install(self, hue, sat=None): def install(self, hue, sat=None):
""" """
Install theme globally Install theme globally
@@ -173,13 +186,13 @@ class GlobalTheme:
# generate gnome-shell-theme.gresource.xml # generate gnome-shell-theme.gresource.xml
with open(f"{self.extracted_theme}/{self.destination_file}.xml", 'w') as gresource_xml: with open(f"{self.extracted_theme}/{self.destination_file}.xml", 'w') as gresource_xml:
generated_xml = self.__generte_gresource_xml() generated_xml = self.__generate_gresource_xml()
gresource_xml.write(generated_xml) gresource_xml.write(generated_xml)
# compile gnome-shell-theme.gresource.xml # compile gnome-shell-theme.gresource.xml
print("Compiling theme...") print("Compiling theme...")
subprocess.run(f"glib-compile-resources {self.destination_file}.xml", subprocess.run(["glib-compile-resources" , f"{self.destination_file}.xml"],
shell=True, cwd=self.extracted_theme) cwd=self.extracted_theme, check=True)
# backup installed theme # backup installed theme
self.__backup() self.__backup()
@@ -191,7 +204,8 @@ class GlobalTheme:
f"{self.destination_folder}/{self.destination_file}"], f"{self.destination_folder}/{self.destination_file}"],
check=True) check=True)
return 0 print("Theme installed successfully.")
def remove(self): def remove(self):
""" """
@@ -201,18 +215,15 @@ class GlobalTheme:
# use backup file if theme is installed # use backup file if theme is installed
if self.__is_installed(): if self.__is_installed():
print("Theme is installed. Removing...") print("Theme is installed. Removing...")
backup_path = os.path.join(self.destination_folder, self.backup_file)
dest_path = os.path.join(self.destination_folder, self.destination_file)
if os.path.isfile(f"{self.destination_folder}/{self.backup_file}"): if os.path.isfile(backup_path):
subprocess.run(f"sudo mv {self.backup_file} {self.destination_file}", subprocess.run(["sudo", "mv", backup_path, dest_path], check=True)
shell=True, cwd=self.destination_folder)
else: else:
print("Backup file not found. Try reinstalling gnome-shell package.") print("Backup file not found. Try reinstalling gnome-shell package.")
return 1
else: else:
print("Theme is not installed. Nothing to remove.") print("Theme is not installed. Nothing to remove.")
print("If theme is still installed globally, try reinstalling gnome-shell package.") print("If theme is still installed globally, try reinstalling gnome-shell package.")
return 1
return 0

View File

@@ -4,6 +4,7 @@ import unittest
import os import os
import json import json
import shutil import shutil
from unittest.mock import patch
from . import config from . import config
from .theme import Theme from .theme import Theme
@@ -14,11 +15,22 @@ project_folder = '.'
class TestInstall(unittest.TestCase): class TestInstall(unittest.TestCase):
def setUp(self):
# Create necessary directories
os.makedirs(f"{project_folder}/{config.raw_theme_folder}/{config.gnome_folder}", exist_ok=True)
os.makedirs(f"{tests_folder}/.themes", exist_ok=True)
os.makedirs(f"{tests_folder}/.temp", exist_ok=True)
def test_install_theme(self): def tearDown(self):
# Clean up after tests
shutil.rmtree(tests_folder, ignore_errors=True)
@patch('scripts.utils.gnome.subprocess.check_output')
def test_install_theme(self, mock_check_output):
""" """
Test if theme is installed correctly (colors are replaced) Test if theme is installed correctly (colors are replaced)
""" """
mock_check_output.return_value = 'GNOME Shell 47.0\n'
# folders # folders
themes_folder = f"{tests_folder}/.themes" themes_folder = f"{tests_folder}/.themes"

View File

@@ -76,7 +76,7 @@ class Theme:
def __del__(self): def __del__(self):
# delete temp folder # delete temp folder
os.system(f"rm -r {self.temp_folder}") shutil.rmtree(self.temp_folder, ignore_errors=True)
def __apply_colors(self, hue, destination, theme_mode, apply_file, sat=None): def __apply_colors(self, hue, destination, theme_mode, apply_file, sat=None):
""" """

View File

@@ -1,17 +1,18 @@
import subprocess import subprocess
def gnome_version(): def gnome_version() -> str | None:
""" """
Get gnome-shell version Get gnome-shell version
""" """
try: try:
output = subprocess.check_output(['gnome-shell', '--version'], text=True).strip() output = subprocess.check_output(['gnome-shell', '--version'], text=True).strip()
print(output)
return output.split(' ')[2] return output.split(' ')[2]
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return None return None
def apply_gnome_theme(theme=None): def apply_gnome_theme(theme=None) -> bool:
""" """
Apply gnome-shell theme Apply gnome-shell theme
:param theme: theme name :param theme: theme name
@@ -27,6 +28,7 @@ def apply_gnome_theme(theme=None):
subprocess.run(['dconf', 'reset', '/org/gnome/shell/extensions/user-theme/name'], check=True) subprocess.run(['dconf', 'reset', '/org/gnome/shell/extensions/user-theme/name'], check=True)
subprocess.run(['dconf', 'write', '/org/gnome/shell/extensions/user-theme/name', f"'{theme}'"], check=True) subprocess.run(['dconf', 'write', '/org/gnome/shell/extensions/user-theme/name', f"'{theme}'"], check=True)
print(f"Theme '{theme}' applied.")
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return False return False
return True return True