-
Notifications
You must be signed in to change notification settings - Fork 0
/
rmskin_builder.py
317 lines (282 loc) · 11.8 KB
/
rmskin_builder.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
#! /usr/bin/python3
"""
A script that will attempt to assemble a validating Rainmeter skin package for
quick and easy distribution on Github.
Ideal Repo Structure
********************
- root directory
* ``Skins`` <- a folder to contain all necessary Rainmeter skins
* ``RMSKIN.ini`` <- list of options specific to installing the skin(s)
* ``Layouts`` <- a folder that contains Rainmeter layout files
* ``Plugins`` <- a folder that contains Rainmeter plugins
* ``@Vault`` <- resources folder accessible by all installed skins
.. seealso::
`A cookiecutter repository <https://github.com/2bndy5/Rainmeter-Cookiecutter>`_
has also been created to facilitate development of Rainmeter skins on Github
quickly.
"""
import os
from pathlib import Path
import sys
import argparse
import configparser
import zipfile
import struct
import logging
import pefile
from PIL import Image
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--path",
metavar='"str"',
type=str,
default=".",
help="Base path of a git repository. Defaults to working directory.",
)
parser.add_argument(
"--version",
metavar='"str"',
type=str,
default="auto",
help="Version of release. This should be the github action env var "
"GITHUB_REF ('refs/tags') or last 8 digits of GITHUB_SHA.",
)
parser.add_argument(
"--author",
metavar='"str"',
type=str,
default="Unknown",
help="Author of release. This should be the github action env var GITHUB_ACTOR.",
)
parser.add_argument(
"--title",
metavar='"str"',
type=str,
default=Path.cwd().name,
help="Title of released package. This should be just the github repo name.",
)
parser.add_argument(
"--dir_out",
metavar='"str"',
type=str,
default=None,
help="Output path to save released package file. "
"This optional & only used when specified.",
)
# setup logging output
logging.basicConfig()
MODULE_NAME = Path(__file__).name.strip(".py").split("_")
assert len(MODULE_NAME) == 2
logger = logging.getLogger(" ".join([MODULE_NAME[0].upper(), MODULE_NAME[1].title()]))
logger.setLevel(logging.INFO)
#: The `dict` of package components discovered by the `main()` loop
HAS_COMPONENTS = {
"RMSKIN.ini": False,
"Skins": 0,
"Layouts": 0,
"Plugins": False,
"@Vault": 0,
"RMSKIN.bmp": False,
}
def discover_components(path):
"""The method that does preliminary discovery of rmskin package components."""
for dirpath, dirnames, filenames in os.walk(path):
dirpath = dirpath.replace(path, "")
if dirpath.endswith("Skins"):
HAS_COMPONENTS["Skins"] = len(dirnames)
logger.info("Found %d possible Skin(s)", HAS_COMPONENTS["Skins"])
elif dirpath.endswith("@Vault"):
HAS_COMPONENTS["@Vault"] = len(filenames) + len(dirnames)
logger.info("Found %d possible @Vault item(s)", HAS_COMPONENTS["@Vault"])
elif dirpath.endswith("Plugins"):
if len(dirnames) > 0:
HAS_COMPONENTS["Plugins"] = True
logger.info("Found Plugins folder")
elif dirpath.endswith("Layouts"):
HAS_COMPONENTS["Layouts"] = len(filenames) + len(dirnames)
logger.info("Found %d possible Layout(s)", HAS_COMPONENTS["Layouts"])
elif len(dirpath) == 0:
if "RMSKIN.ini" in filenames:
HAS_COMPONENTS["RMSKIN.ini"] = True
logger.info("Found RMSKIN.ini file")
if "RMSKIN.bmp" in filenames:
HAS_COMPONENTS["RMSKIN.bmp"] = True
logger.info("Found header image")
for dir_name in dirnames: # exclude hidden directories
if dir_name.startswith("."):
del dir_name
# set depth of search to shallow (2 folders deep)
if len(dirpath) > 0:
dirnames.clear()
def parse_rmskin_ini(args, path, build_dir):
"""Read the RMSKIN.ini and write a copy for building the RMSKIN package."""
arc_name = args.title
version = args.version
config = configparser.ConfigParser()
config.read(str(Path(path, "RMSKIN.ini")))
if "rmskin" in config:
if "Version" in config["rmskin"]:
version = config["rmskin"]["Version"]
if version.endswith("auto"):
if not os.getenv("GITHUB_REF", "").startswith("refs/tags/"):
version = os.getenv("GITHUB_SHA", "x0x.x0xy")[-8:]
else:
version = os.getenv("GITHUB_REF", "refs/tags/0.0").replace(
"refs/tags/", ""
)
config["rmskin"]["Version"] = version
if "Author" not in config["rmskin"]:
# maybe someday, aggregate list of authors from
# discovered skins' metadata->Author fields
config["rmskin"]["Author"] = args.author
if "Name" in config["rmskin"]:
# use hard-coded name
arc_name = config["rmskin"]["Name"]
else:
# use repo name
config["rmskin"]["Name"] = args.title
logger.info("Using Name (%s) & Version (%s)", arc_name, version)
load_t = config["rmskin"]["LoadType"] # ex: "Skin"
load = config["rmskin"]["Load"] # ex: "Skin_Root\\skin.ini"
# for cross-platform compatibility, adjust windows-style path separators
load = load.replace("\\", os.sep)
if len(load_t): # if a file set to load on-install
# exit early if loaded file does not exist
temp = Path(
path,
f"{load_t}s",
(load if load_t == "Skin" else load + os.sep + "Rainmeter.ini"),
)
if not temp.exists():
raise RuntimeError("On-install loaded file does not exits.")
else:
raise RuntimeError("RMSKIN.ini is malformed")
with open(build_dir + "RMSKIN.ini", "w", encoding="utf-8") as conf:
config.write(conf) # Dump changes/corrections to temp build dir
return (arc_name, version)
def validate_header_image(path, build_dir):
"""Make sure header image (if any) is ready to package"""
if HAS_COMPONENTS["RMSKIN.bmp"]:
with Image.open(Path(path, "RMSKIN.bmp")) as img:
if img.width != 400 and img.height != 60:
logger.warning("Resizing header image to 400x60")
img = img.resize((400, 60))
if img.mode != "RGB":
logger.warning("Correcting color space in header image.")
img = img.convert(mode="RGB")
img.save(build_dir + "RMSKIN.bmp")
def is_dll_32(dll_file):
"""Returns True if dll file is compiled for 32 bit machines, otherwise False"""
# fast_load=True means just get headers
bitness = pefile.PE(dll_file, fast_load=True)
bitness.close() # do this now to copy file safely later
assert bitness.FILE_HEADER is not None and hasattr(bitness.FILE_HEADER, "Machine")
ret_val = bitness.FILE_HEADER.Machine == 0x014C
del bitness
return ret_val
def init_zip_for_package(arch_name, args, path, build_dir):
"""Create initial archive to use as RMSKIN package"""
output_path_to_archive = str(Path(args.dir_out or path, arch_name))
with zipfile.ZipFile(
output_path_to_archive,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as arc_file:
# write RMSKIN.ini and header image (RMSKIN.bmp) first
if HAS_COMPONENTS["RMSKIN.bmp"]:
arc_file.write(build_dir + "RMSKIN.bmp", arcname="RMSKIN.bmp")
arc_file.write(build_dir + "RMSKIN.ini", arcname="RMSKIN.ini")
for key, val in HAS_COMPONENTS.items():
if not key.endswith(".ini") and val:
for dirpath, _, filenames in os.walk(path + os.sep + key):
for file_name in filenames:
if ( # check bitness of plugins here & archive accordingly
key.endswith("Plugins")
and file_name.lower().endswith(".dll")
):
# let plugin_name be 2nd last folder name in dll's path
path_to_dll = dirpath + os.sep + file_name
arc_file.write(
str(Path(dirpath, file_name)),
arcname=str(
Path(
key,
(
"32bit"
if is_dll_32(path_to_dll)
else "64bit"
),
file_name,
)
),
)
else: # for all other files/folders
arc_file.write(
str(Path(dirpath, file_name)),
arcname=str(
Path(dirpath.replace(path + os.sep, ""), file_name)
),
)
# archive assembled; closing file
return output_path_to_archive
def main(*argv):
"""The main execution loop for creating a rmskin package."""
# collect cmd args
args = parser.parse_args(argv or None)
root_path = args.path
# truncate trailing path separator
root_path = root_path.rstrip(os.sep)
root_path = os.path.abspath(root_path)
# The temporary build dir for storing altered files
build_dir = root_path + os.sep + "build" + os.sep
if not os.path.isdir(build_dir):
os.mkdir(build_dir)
if args.dir_out is not None and args.dir_out.endswith(os.sep):
args.dir_out = args.dir_out.rstrip(os.sep)
logger.info("Searching path: %s", root_path)
# capture the directory tree
discover_components(root_path)
# quit if bad dir struct
if not (
HAS_COMPONENTS["Layouts"]
or HAS_COMPONENTS["Skins"]
or HAS_COMPONENTS["Plugins"]
or HAS_COMPONENTS["@Vault"]
):
raise RuntimeError(
f"Repository structure for {root_path} is malformed. Found no Skins,"
" Layouts, Plugins, or @Vault assets."
)
# quit if no RMSKIN.ini
if not HAS_COMPONENTS["RMSKIN.ini"]:
raise RuntimeError(
f"Repository structure for {root_path} is malformed. RMSKIN.ini file "
"not found."
)
# read options from RMSKIN.ini
arc_name, version = parse_rmskin_ini(args, root_path, build_dir)
# make sure header image is correct size (400x60) & correct color space
validate_header_image(root_path, build_dir)
# Now creating the archive
archive_name = arc_name + "_" + version + ".rmskin"
path_to_archive = init_zip_for_package(archive_name, args, root_path, build_dir)
compressed_size = os.path.getsize(path_to_archive)
logger.info("Archive size = %d (0x%X)", compressed_size, compressed_size)
# convert size to a bytes obj & prepend to custom footer
custom_footer = struct.pack("q", compressed_size) + b"\x00RMSKIN\x00"
# append footer to archive
with open(path_to_archive, "a+b") as arc_file:
logger.debug("appending footer: %s", repr(custom_footer))
arc_file.write(custom_footer)
logger.info("Archive successfully prepared.")
# env var CI is always true when executed on a github action runner
if "GITHUB_OUTPUT" in os.environ:
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as gh_out:
gh_out.write(f"arc_name={archive_name}")
else: # pragma: no cover
logger.info("Archive name: %s", archive_name)
if __name__ == "__main__": # pragma: no cover
sys.exit(main())