PyInstallerEx 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyInstallerEx/PyInstallerEx.py +0 -0
- PyInstallerEx/__init__.py +13 -0
- PyInstallerEx/__main__.py +144 -0
- PyInstallerEx/core/__init__.py +9 -0
- PyInstallerEx/core/config.py +91 -0
- PyInstallerEx/core/packager.py +961 -0
- PyInstallerEx/utils/__init__.py +14 -0
- PyInstallerEx/utils/compression.py +69 -0
- PyInstallerEx/utils/file_utils.py +50 -0
- PyInstallerEx/utils/icon_helper.py +78 -0
- PyInstallerEx/utils/logging.py +154 -0
- PyInstallerEx/utils/system_utils.py +59 -0
- pyinstallerex-0.1.6.data/data/bin/launcher_windows.exe +0 -0
- pyinstallerex-0.1.6.dist-info/METADATA +528 -0
- pyinstallerex-0.1.6.dist-info/RECORD +19 -0
- pyinstallerex-0.1.6.dist-info/WHEEL +5 -0
- pyinstallerex-0.1.6.dist-info/entry_points.txt +3 -0
- pyinstallerex-0.1.6.dist-info/licenses/LICENSE +21 -0
- pyinstallerex-0.1.6.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
核心打包逻辑模块
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import print_function
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import os
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
|
|
15
|
+
from ..utils.system_utils import SystemInfo
|
|
16
|
+
from ..utils.file_utils import FileUtils
|
|
17
|
+
from ..utils.compression import CompressionUtils
|
|
18
|
+
from ..utils.logging import PackageError, handle_error
|
|
19
|
+
from .config import PackageConfig
|
|
20
|
+
from .. import __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PackageBuilder(object):
|
|
24
|
+
"""包构建器类"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config, verbose=False):
|
|
27
|
+
self.config = config
|
|
28
|
+
self.verbose = verbose
|
|
29
|
+
self.logger = None
|
|
30
|
+
self._initialize_logger()
|
|
31
|
+
|
|
32
|
+
def _initialize_logger(self):
|
|
33
|
+
"""初始化日志系统"""
|
|
34
|
+
try:
|
|
35
|
+
# 尝试获取logger
|
|
36
|
+
self.logger = logging.getLogger(__name__)
|
|
37
|
+
self.logger.propagate = False
|
|
38
|
+
# 确保logger有处理器
|
|
39
|
+
if not self.logger.handlers:
|
|
40
|
+
# 添加一个默认处理器
|
|
41
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
42
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
43
|
+
handler.setFormatter(formatter)
|
|
44
|
+
self.logger.addHandler(handler)
|
|
45
|
+
self.logger.setLevel(logging.INFO)
|
|
46
|
+
except:
|
|
47
|
+
# 如果日志初始化失败,使用备用方案
|
|
48
|
+
class DummyLogger:
|
|
49
|
+
def info(self, msg):
|
|
50
|
+
print("INFO: {0}".format(msg))
|
|
51
|
+
|
|
52
|
+
def warning(self, msg):
|
|
53
|
+
print("WARNING: {0}".format(msg))
|
|
54
|
+
|
|
55
|
+
def error(self, msg):
|
|
56
|
+
print("ERROR: {0}".format(msg))
|
|
57
|
+
|
|
58
|
+
def debug(self, msg):
|
|
59
|
+
if self.verbose:
|
|
60
|
+
print("DEBUG: {0}".format(msg))
|
|
61
|
+
|
|
62
|
+
self.logger = DummyLogger()
|
|
63
|
+
|
|
64
|
+
def build(self, script_path, output_dir=None):
|
|
65
|
+
"""执行完整的打包流程"""
|
|
66
|
+
try:
|
|
67
|
+
# 1. 验证输入
|
|
68
|
+
self._validate_inputs(script_path, output_dir)
|
|
69
|
+
|
|
70
|
+
# 2. 根据引擎模式调用对应的打包工具
|
|
71
|
+
engine_mode = getattr(self.config, 'engine_mode', 'pyinstaller')
|
|
72
|
+
if engine_mode == 'nuitka':
|
|
73
|
+
engine_dir = self._run_nuitka(script_path)
|
|
74
|
+
elif engine_mode == 'pyoxidizer':
|
|
75
|
+
engine_dir = self._run_pyoxidizer(script_path)
|
|
76
|
+
else:
|
|
77
|
+
engine_dir = self._run_pyinstaller(script_path)
|
|
78
|
+
|
|
79
|
+
# 3. 处理配置文件
|
|
80
|
+
rendered_config = self._process_configuration(script_path, engine_dir)
|
|
81
|
+
|
|
82
|
+
# 4. 压缩目录为ZIP文件
|
|
83
|
+
zip_path = self._compress_directory(engine_dir)
|
|
84
|
+
|
|
85
|
+
# 5. 获取对应的二进制启动器
|
|
86
|
+
binary_path = self._get_binary_launcher()
|
|
87
|
+
|
|
88
|
+
# 6. 合并为最终的单文件
|
|
89
|
+
final_output = self._combine_final_file(binary_path, zip_path, output_dir, script_path)
|
|
90
|
+
|
|
91
|
+
return final_output
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
handle_error(e, "Packaging process failed")
|
|
95
|
+
|
|
96
|
+
def build_folder(self, folder_path, output_dir=None):
|
|
97
|
+
"""文件夹直接打包模式:跳过引擎构建,直接将指定文件夹压缩并附加到启动器尾部"""
|
|
98
|
+
try:
|
|
99
|
+
# 1. 验证输入
|
|
100
|
+
if not os.path.isdir(folder_path):
|
|
101
|
+
raise PackageError("Folder not found: {0}".format(folder_path))
|
|
102
|
+
|
|
103
|
+
if output_dir and not os.path.exists(output_dir):
|
|
104
|
+
FileUtils.ensure_directory_exists(output_dir)
|
|
105
|
+
|
|
106
|
+
self.config.validate()
|
|
107
|
+
|
|
108
|
+
# 2. 处理配置文件(使用文件夹名作为脚本名)
|
|
109
|
+
folder_name = os.path.basename(os.path.normpath(folder_path))
|
|
110
|
+
rendered_config = self._process_configuration_for_folder(folder_name, folder_path)
|
|
111
|
+
|
|
112
|
+
# 3. 压缩文件夹为 ZIP
|
|
113
|
+
self.logger.info("Step 2: Compressing folder to ZIP...")
|
|
114
|
+
zip_path = folder_path.rstrip(os.sep) + ".zip"
|
|
115
|
+
CompressionUtils.create_zip_from_directory(folder_path, zip_path)
|
|
116
|
+
self.logger.info("ZIP file created: {0}".format(zip_path))
|
|
117
|
+
|
|
118
|
+
# 4. 获取对应的二进制启动器
|
|
119
|
+
binary_path = self._get_binary_launcher()
|
|
120
|
+
|
|
121
|
+
# 5. 合并为最终单文件(复用现有逻辑,以文件夹名作为 script_path)
|
|
122
|
+
final_output = self._combine_final_file_for_folder(binary_path, zip_path, output_dir, folder_name)
|
|
123
|
+
|
|
124
|
+
return final_output
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
handle_error(e, "Folder packaging failed")
|
|
128
|
+
|
|
129
|
+
def _process_configuration_for_folder(self, folder_name, folder_path):
|
|
130
|
+
"""处理文件夹模式的配置文件"""
|
|
131
|
+
# 确定 main 字段:优先使用 config.main(来自 --run),否则使用文件夹名
|
|
132
|
+
if self.config.main:
|
|
133
|
+
main_entry = self.config.main
|
|
134
|
+
else:
|
|
135
|
+
# 默认使用文件夹名作为主程序名
|
|
136
|
+
if SystemInfo().get_platform() in ["windows", "win"]:
|
|
137
|
+
main_entry = "{0}.exe".format(folder_name)
|
|
138
|
+
else:
|
|
139
|
+
main_entry = folder_name
|
|
140
|
+
|
|
141
|
+
config_data = {
|
|
142
|
+
"main": main_entry,
|
|
143
|
+
"name": self.config.filename or folder_name,
|
|
144
|
+
"version": self.config.version or "1.0.0",
|
|
145
|
+
"description": self.config.description or "Application packaged with PyInstallerEx",
|
|
146
|
+
"tmp_dir": self.config.tmp_dir or "ex_{name}_{md5}",
|
|
147
|
+
"author": self.config.author or "Unknown",
|
|
148
|
+
"extract_mode": self.config.extract_mode or "temp"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
rendered_config = json.dumps(config_data, indent=2, ensure_ascii=False)
|
|
152
|
+
self._rendered_config = rendered_config
|
|
153
|
+
|
|
154
|
+
# 将配置写入目标文件夹
|
|
155
|
+
config_path = FileUtils.copy_config_to_dir(rendered_config, folder_path, "ex.config.json")
|
|
156
|
+
self.logger.info("Configuration written to: {0}".format(config_path))
|
|
157
|
+
return rendered_config
|
|
158
|
+
|
|
159
|
+
def _combine_final_file_for_folder(self, binary_path, zip_path, output_dir, folder_name):
|
|
160
|
+
"""合并启动器和 ZIP 文件为最终输出(文件夹模式)"""
|
|
161
|
+
self.logger.info("Step 4: Combining launcher and ZIP file...")
|
|
162
|
+
|
|
163
|
+
# 确定输出目录
|
|
164
|
+
if output_dir is None:
|
|
165
|
+
output_dir = os.getcwd()
|
|
166
|
+
|
|
167
|
+
FileUtils.ensure_directory_exists(output_dir)
|
|
168
|
+
|
|
169
|
+
# 确定输出文件名
|
|
170
|
+
if SystemInfo().get_platform() in ["windows", "win"]:
|
|
171
|
+
output_filename = folder_name + ".exe"
|
|
172
|
+
else:
|
|
173
|
+
output_filename = folder_name
|
|
174
|
+
|
|
175
|
+
output_path = os.path.join(output_dir, output_filename)
|
|
176
|
+
|
|
177
|
+
# 在 Windows 上,如果指定了图标,先对启动器设置图标
|
|
178
|
+
icon = getattr(self.config, 'icon', None)
|
|
179
|
+
if SystemInfo().get_platform() in ["windows", "win"] and icon and os.path.exists(icon):
|
|
180
|
+
self.logger.info("Setting custom icon on launcher: {0}".format(icon))
|
|
181
|
+
temp_launcher_path = binary_path + ".tmp"
|
|
182
|
+
shutil.copy2(binary_path, temp_launcher_path)
|
|
183
|
+
self._set_windows_icon(temp_launcher_path, icon)
|
|
184
|
+
binary_path = temp_launcher_path
|
|
185
|
+
self.logger.info("Icon set successfully on launcher")
|
|
186
|
+
|
|
187
|
+
# 合并文件
|
|
188
|
+
import hashlib
|
|
189
|
+
with open(output_path, "wb") as output_file:
|
|
190
|
+
with open(binary_path, "rb") as launcher_file:
|
|
191
|
+
launcher_data = launcher_file.read()
|
|
192
|
+
output_file.write(launcher_data)
|
|
193
|
+
|
|
194
|
+
zip_offset = len(launcher_data)
|
|
195
|
+
|
|
196
|
+
with open(zip_path, "rb") as zip_file:
|
|
197
|
+
zip_data = zip_file.read()
|
|
198
|
+
|
|
199
|
+
zip_md5 = hashlib.md5(zip_data).hexdigest()
|
|
200
|
+
self.logger.debug("ZIP MD5: {0}".format(zip_md5))
|
|
201
|
+
|
|
202
|
+
output_file.write(zip_data)
|
|
203
|
+
|
|
204
|
+
config_data = json.loads(self._rendered_config)
|
|
205
|
+
config_data["zip_offset"] = zip_offset
|
|
206
|
+
config_data["zip_md5"] = zip_md5
|
|
207
|
+
config_json = json.dumps(config_data, indent=2, ensure_ascii=False)
|
|
208
|
+
output_file.write(config_json.encode("utf-8"))
|
|
209
|
+
|
|
210
|
+
# 清理临时启动器文件
|
|
211
|
+
if SystemInfo().get_platform() in ["windows", "win"] and icon and os.path.exists(icon):
|
|
212
|
+
try:
|
|
213
|
+
os.remove(binary_path)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
self.logger.debug("Failed to remove temp launcher: {0}".format(e))
|
|
216
|
+
|
|
217
|
+
if SystemInfo().get_platform() not in ["windows", "win"]:
|
|
218
|
+
os.chmod(output_path, 0o755)
|
|
219
|
+
|
|
220
|
+
self.logger.info("Final executable created: {0}".format(output_path))
|
|
221
|
+
return output_path
|
|
222
|
+
|
|
223
|
+
def _validate_inputs(self, script_path, output_dir):
|
|
224
|
+
"""验证输入参数"""
|
|
225
|
+
if not os.path.exists(script_path):
|
|
226
|
+
raise PackageError("Script file not found: {0}".format(script_path))
|
|
227
|
+
|
|
228
|
+
if output_dir and not os.path.exists(output_dir):
|
|
229
|
+
FileUtils.ensure_directory_exists(output_dir)
|
|
230
|
+
|
|
231
|
+
self.config.validate()
|
|
232
|
+
|
|
233
|
+
def _run_pyinstaller(self, script_path):
|
|
234
|
+
"""步骤1: 调用PyInstaller --onedir打包"""
|
|
235
|
+
self.logger.info("Step 1: Running PyInstaller...")
|
|
236
|
+
|
|
237
|
+
# 获取脚本的绝对路径和所在目录
|
|
238
|
+
script_path = os.path.abspath(script_path)
|
|
239
|
+
script_dir = os.path.dirname(script_path)
|
|
240
|
+
|
|
241
|
+
# 在脚本所在目录创建临时工作目录
|
|
242
|
+
work_dir = os.path.join(script_dir, "_pyinstallerex_temp")
|
|
243
|
+
|
|
244
|
+
# 清理之前的临时目录
|
|
245
|
+
import shutil
|
|
246
|
+
if os.path.exists(work_dir):
|
|
247
|
+
try:
|
|
248
|
+
shutil.rmtree(work_dir)
|
|
249
|
+
except OSError as e:
|
|
250
|
+
self.logger.warning("Failed to remove old temp directory: {0}".format(e))
|
|
251
|
+
|
|
252
|
+
FileUtils.ensure_directory_exists(work_dir)
|
|
253
|
+
|
|
254
|
+
# 定义输出目录
|
|
255
|
+
dist_dir = os.path.join(work_dir, "dist")
|
|
256
|
+
FileUtils.ensure_directory_exists(dist_dir)
|
|
257
|
+
|
|
258
|
+
# 构建PyInstaller命令(添加-y选项自动覆盖)
|
|
259
|
+
cmd = [
|
|
260
|
+
sys.executable,
|
|
261
|
+
"-m",
|
|
262
|
+
"PyInstaller",
|
|
263
|
+
"--onedir",
|
|
264
|
+
"--distpath", os.path.abspath(dist_dir),
|
|
265
|
+
"--workpath", os.path.abspath(work_dir),
|
|
266
|
+
"--specpath", os.path.abspath(work_dir),
|
|
267
|
+
"--clean",
|
|
268
|
+
"-y", # 自动覆盖现有输出目录
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
# 添加图标
|
|
272
|
+
icon = getattr(self.config, 'icon', None)
|
|
273
|
+
if icon and os.path.exists(icon):
|
|
274
|
+
cmd.extend(["--icon", os.path.abspath(icon)])
|
|
275
|
+
|
|
276
|
+
cmd.append(script_path) # 使用完整路径
|
|
277
|
+
|
|
278
|
+
if self.verbose:
|
|
279
|
+
cmd.append("--log-level")
|
|
280
|
+
cmd.append("DEBUG")
|
|
281
|
+
|
|
282
|
+
self.logger.debug("Running command: {0}".format(" ".join(cmd)))
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
# 在脚本目录中运行PyInstaller
|
|
286
|
+
result = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
287
|
+
cwd=script_dir, universal_newlines=True)
|
|
288
|
+
stdout, stderr = result.communicate()
|
|
289
|
+
|
|
290
|
+
if result.returncode != 0:
|
|
291
|
+
raise subprocess.CalledProcessError(result.returncode, cmd, stderr)
|
|
292
|
+
|
|
293
|
+
if self.verbose:
|
|
294
|
+
self.logger.debug("PyInstaller output: {0}".format(stdout))
|
|
295
|
+
self.logger.debug("PyInstaller errors: {0}".format(stderr))
|
|
296
|
+
except subprocess.CalledProcessError as e:
|
|
297
|
+
raise PackageError("PyInstaller failed: {0}".format(e.output or e.stderr))
|
|
298
|
+
|
|
299
|
+
# 等待一段时间确保文件系统同步
|
|
300
|
+
import time
|
|
301
|
+
time.sleep(1)
|
|
302
|
+
|
|
303
|
+
# 查找生成的目录(直接在dist目录下)
|
|
304
|
+
if not os.path.exists(dist_dir):
|
|
305
|
+
self.logger.error("PyInstaller dist directory not found: {0}".format(dist_dir))
|
|
306
|
+
if os.path.exists(work_dir):
|
|
307
|
+
self.logger.error("Current contents of work_dir: {0}".format(os.listdir(work_dir)))
|
|
308
|
+
raise PackageError("PyInstaller output directory not found: {0}".format(dist_dir))
|
|
309
|
+
|
|
310
|
+
# 获取dist目录下的内容
|
|
311
|
+
dist_contents = os.listdir(dist_dir)
|
|
312
|
+
if not dist_contents:
|
|
313
|
+
self.logger.error("PyInstaller dist directory is empty: {0}".format(dist_dir))
|
|
314
|
+
raise PackageError("PyInstaller output directory is empty: {0}".format(dist_dir))
|
|
315
|
+
|
|
316
|
+
# PyInstaller在dist目录下创建了脚本名称的目录
|
|
317
|
+
script_name = FileUtils.get_script_name(script_path)
|
|
318
|
+
expected_dist_path = os.path.join(dist_dir, script_name)
|
|
319
|
+
|
|
320
|
+
# 检查预期的目录是否存在
|
|
321
|
+
if os.path.exists(expected_dist_path):
|
|
322
|
+
self.logger.info("Found PyInstaller output directory: {0}".format(expected_dist_path))
|
|
323
|
+
return expected_dist_path
|
|
324
|
+
else:
|
|
325
|
+
# 如果预期目录不存在,但dist目录中有内容,则使用dist目录
|
|
326
|
+
self.logger.warning("Expected PyInstaller output directory not found: {0}".format(expected_dist_path))
|
|
327
|
+
self.logger.info("Using dist directory: {0}".format(dist_dir))
|
|
328
|
+
self.logger.info("Contents of dist directory: {0}".format(dist_contents))
|
|
329
|
+
return dist_dir
|
|
330
|
+
|
|
331
|
+
def _run_nuitka(self, script_path):
|
|
332
|
+
"""步骤1(Nuitka模式): 调用Nuitka --onedir打包"""
|
|
333
|
+
self.logger.info("Step 1: Running Nuitka...")
|
|
334
|
+
|
|
335
|
+
script_path = os.path.abspath(script_path)
|
|
336
|
+
script_dir = os.path.dirname(script_path)
|
|
337
|
+
|
|
338
|
+
work_dir = os.path.join(script_dir, "_pyinstallerex_temp")
|
|
339
|
+
|
|
340
|
+
import shutil
|
|
341
|
+
if os.path.exists(work_dir):
|
|
342
|
+
try:
|
|
343
|
+
shutil.rmtree(work_dir)
|
|
344
|
+
except OSError as e:
|
|
345
|
+
self.logger.warning("Failed to remove old temp directory: {0}".format(e))
|
|
346
|
+
|
|
347
|
+
FileUtils.ensure_directory_exists(work_dir)
|
|
348
|
+
|
|
349
|
+
dist_dir = os.path.join(work_dir, "dist")
|
|
350
|
+
FileUtils.ensure_directory_exists(dist_dir)
|
|
351
|
+
|
|
352
|
+
cmd = [
|
|
353
|
+
sys.executable,
|
|
354
|
+
"-m", "nuitka",
|
|
355
|
+
"--standalone",
|
|
356
|
+
"--output-dir=" + os.path.abspath(dist_dir),
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
# 添加图标
|
|
360
|
+
icon = getattr(self.config, 'icon', None)
|
|
361
|
+
if icon and os.path.exists(icon):
|
|
362
|
+
cmd.append("--windows-icon-from-ico=" + os.path.abspath(icon))
|
|
363
|
+
|
|
364
|
+
cmd.append(script_path)
|
|
365
|
+
|
|
366
|
+
if self.verbose:
|
|
367
|
+
cmd.append("--verbose")
|
|
368
|
+
|
|
369
|
+
self.logger.debug("Running command: {0}".format(" ".join(cmd)))
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
result = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
373
|
+
cwd=script_dir, universal_newlines=True)
|
|
374
|
+
stdout, stderr = result.communicate()
|
|
375
|
+
|
|
376
|
+
if result.returncode != 0:
|
|
377
|
+
raise subprocess.CalledProcessError(result.returncode, cmd, stderr)
|
|
378
|
+
|
|
379
|
+
if self.verbose:
|
|
380
|
+
self.logger.debug("Nuitka output: {0}".format(stdout))
|
|
381
|
+
self.logger.debug("Nuitka errors: {0}".format(stderr))
|
|
382
|
+
except subprocess.CalledProcessError as e:
|
|
383
|
+
raise PackageError("Nuitka failed: {0}".format(e.output or e.stderr))
|
|
384
|
+
|
|
385
|
+
import time
|
|
386
|
+
time.sleep(1)
|
|
387
|
+
|
|
388
|
+
if not os.path.exists(dist_dir):
|
|
389
|
+
raise PackageError("Nuitka output directory not found: {0}".format(dist_dir))
|
|
390
|
+
|
|
391
|
+
dist_contents = os.listdir(dist_dir)
|
|
392
|
+
if not dist_contents:
|
|
393
|
+
raise PackageError("Nuitka output directory is empty: {0}".format(dist_dir))
|
|
394
|
+
|
|
395
|
+
script_name = FileUtils.get_script_name(script_path)
|
|
396
|
+
expected_dist_path = os.path.join(dist_dir, script_name + ".dist")
|
|
397
|
+
|
|
398
|
+
if os.path.exists(expected_dist_path):
|
|
399
|
+
self.logger.info("Found Nuitka output directory: {0}".format(expected_dist_path))
|
|
400
|
+
return expected_dist_path
|
|
401
|
+
else:
|
|
402
|
+
self.logger.warning("Expected Nuitka output directory not found: {0}".format(expected_dist_path))
|
|
403
|
+
return dist_dir
|
|
404
|
+
|
|
405
|
+
def _run_pyoxidizer(self, script_path):
|
|
406
|
+
"""步骤1(PyOxidizer模式): 调用PyOxidizer打包"""
|
|
407
|
+
self.logger.info("Step 1: Running PyOxidizer...")
|
|
408
|
+
|
|
409
|
+
script_path = os.path.abspath(script_path)
|
|
410
|
+
script_dir = os.path.dirname(script_path)
|
|
411
|
+
|
|
412
|
+
work_dir = os.path.join(script_dir, "_pyinstallerex_temp")
|
|
413
|
+
|
|
414
|
+
import shutil
|
|
415
|
+
if os.path.exists(work_dir):
|
|
416
|
+
try:
|
|
417
|
+
shutil.rmtree(work_dir)
|
|
418
|
+
except OSError as e:
|
|
419
|
+
self.logger.warning("Failed to remove old temp directory: {0}".format(e))
|
|
420
|
+
|
|
421
|
+
FileUtils.ensure_directory_exists(work_dir)
|
|
422
|
+
|
|
423
|
+
dist_dir = os.path.join(work_dir, "dist")
|
|
424
|
+
FileUtils.ensure_directory_exists(dist_dir)
|
|
425
|
+
|
|
426
|
+
# 生成 pyoxidizer.bzl 配置文件
|
|
427
|
+
icon = getattr(self.config, 'icon', None)
|
|
428
|
+
icon_line = ""
|
|
429
|
+
if icon and os.path.exists(icon):
|
|
430
|
+
icon_line = ' exe.windows_icon_path = "{0}"'.format(os.path.abspath(icon).replace("\\", "\\\\"))
|
|
431
|
+
|
|
432
|
+
pyoxidizer_config = '''
|
|
433
|
+
def make_exe():
|
|
434
|
+
dist = default_python_distribution()
|
|
435
|
+
policy = dist.make_python_packaging_policy()
|
|
436
|
+
policy.resources_location = "filesystem-relative:lib"
|
|
437
|
+
python_config = dist.make_python_interpreter_config()
|
|
438
|
+
python_config.run_command = open("{script_path}", "r").read()
|
|
439
|
+
exe = dist.to_python_executable(
|
|
440
|
+
name="{script_name}",
|
|
441
|
+
packaging_policy=policy,
|
|
442
|
+
config=python_config,
|
|
443
|
+
)
|
|
444
|
+
{icon_line}
|
|
445
|
+
exe.add_python_resources(file="{script_path}")
|
|
446
|
+
return exe
|
|
447
|
+
|
|
448
|
+
def make_embedded_resources(exe):
|
|
449
|
+
return exe.to_embedded_resources()
|
|
450
|
+
|
|
451
|
+
def make_install(exe):
|
|
452
|
+
files = exe.to_install_files()
|
|
453
|
+
return files
|
|
454
|
+
|
|
455
|
+
register_target("exe", make_exe)
|
|
456
|
+
register_target("resources", make_embedded_resources, depends_on=["exe"], default_build_script=True)
|
|
457
|
+
register_target("install_files", make_install, depends_on=["exe"])
|
|
458
|
+
resolve_targets()
|
|
459
|
+
'''.format(script_path=script_path, script_name=FileUtils.get_script_name(script_path), icon_line=icon_line)
|
|
460
|
+
|
|
461
|
+
bzl_path = os.path.join(work_dir, "pyoxidizer.bzl")
|
|
462
|
+
with open(bzl_path, 'w') as f:
|
|
463
|
+
f.write(pyoxidizer_config)
|
|
464
|
+
|
|
465
|
+
cmd = [
|
|
466
|
+
"pyoxidizer",
|
|
467
|
+
"run",
|
|
468
|
+
"--path", os.path.abspath(work_dir),
|
|
469
|
+
"install",
|
|
470
|
+
"--path", os.path.abspath(dist_dir)
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
self.logger.debug("Running command: {0}".format(" ".join(cmd)))
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
result = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
477
|
+
cwd=work_dir, universal_newlines=True)
|
|
478
|
+
stdout, stderr = result.communicate()
|
|
479
|
+
|
|
480
|
+
if result.returncode != 0:
|
|
481
|
+
raise subprocess.CalledProcessError(result.returncode, cmd, stderr)
|
|
482
|
+
|
|
483
|
+
if self.verbose:
|
|
484
|
+
self.logger.debug("PyOxidizer output: {0}".format(stdout))
|
|
485
|
+
self.logger.debug("PyOxidizer errors: {0}".format(stderr))
|
|
486
|
+
except subprocess.CalledProcessError as e:
|
|
487
|
+
raise PackageError("PyOxidizer failed: {0}".format(e.output or e.stderr))
|
|
488
|
+
|
|
489
|
+
import time
|
|
490
|
+
time.sleep(1)
|
|
491
|
+
|
|
492
|
+
if not os.path.exists(dist_dir):
|
|
493
|
+
raise PackageError("PyOxidizer output directory not found: {0}".format(dist_dir))
|
|
494
|
+
|
|
495
|
+
dist_contents = os.listdir(dist_dir)
|
|
496
|
+
if not dist_contents:
|
|
497
|
+
raise PackageError("PyOxidizer output directory is empty: {0}".format(dist_dir))
|
|
498
|
+
|
|
499
|
+
self.logger.info("Found PyOxidizer output directory: {0}".format(dist_dir))
|
|
500
|
+
return dist_dir
|
|
501
|
+
|
|
502
|
+
def _get_binary_launcher(self):
|
|
503
|
+
"""获取对应平台的二进制启动器(如果指定了图标则动态编译)"""
|
|
504
|
+
# 确定平台
|
|
505
|
+
system_info = SystemInfo()
|
|
506
|
+
platform_name = system_info.get_platform()
|
|
507
|
+
|
|
508
|
+
# 根据平台选择启动器(处理不同平台名称)
|
|
509
|
+
if platform_name in ["windows", "win"]:
|
|
510
|
+
launcher_name = "launcher_windows.exe" if sys.maxsize > 2**32 else "launcher_windows.exe"
|
|
511
|
+
elif platform_name in ["linux", "linux2", "linux_x86"]:
|
|
512
|
+
launcher_name = "launcher_linux_x86"
|
|
513
|
+
elif platform_name in ["linux_arm"]:
|
|
514
|
+
launcher_name = "launcher_linux_arm"
|
|
515
|
+
elif platform_name in ["linux_arm64"]:
|
|
516
|
+
launcher_name = "launcher_linux_arm64"
|
|
517
|
+
else:
|
|
518
|
+
raise PackageError("Unsupported platform: {0}".format(platform_name))
|
|
519
|
+
|
|
520
|
+
# 图标设置已移至 _combine_final_file 中通过 win32api.UpdateResource 实现
|
|
521
|
+
# 这样可以在合并 ZIP 数据之前对纯启动器设置图标,避免破坏附加数据
|
|
522
|
+
|
|
523
|
+
# 多种方式查找启动器文件
|
|
524
|
+
launcher_paths = []
|
|
525
|
+
|
|
526
|
+
# 1. 首先尝试从安装包的数据文件中查找启动器
|
|
527
|
+
try:
|
|
528
|
+
import pkg_resources
|
|
529
|
+
launcher_path = pkg_resources.resource_filename(
|
|
530
|
+
'PyInstallerEx',
|
|
531
|
+
'../bin/' + launcher_name
|
|
532
|
+
)
|
|
533
|
+
launcher_paths.append(launcher_path)
|
|
534
|
+
except:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
# 2. 尝试使用pkg_resources查找(适用于wheel包)
|
|
538
|
+
try:
|
|
539
|
+
import pkg_resources
|
|
540
|
+
launcher_path = pkg_resources.resource_filename(
|
|
541
|
+
'pyinstallerex',
|
|
542
|
+
'bin/' + launcher_name
|
|
543
|
+
)
|
|
544
|
+
launcher_paths.append(launcher_path)
|
|
545
|
+
except:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
# 3. 尝试从wheel包的data目录查找启动器
|
|
549
|
+
try:
|
|
550
|
+
import pkg_resources
|
|
551
|
+
launcher_path = pkg_resources.resource_filename(
|
|
552
|
+
'pyinstallerex',
|
|
553
|
+
'pyinstallerex-0.1.6.data/data/bin/' + launcher_name
|
|
554
|
+
)
|
|
555
|
+
launcher_paths.append(launcher_path)
|
|
556
|
+
except:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
# 4. 尝试从wheel包的data目录查找启动器(动态版本)
|
|
560
|
+
try:
|
|
561
|
+
# 获取当前模块路径
|
|
562
|
+
current_path = os.path.dirname(os.path.abspath(__file__))
|
|
563
|
+
# 动态生成所有可能的版本路径
|
|
564
|
+
parent_dir = os.path.dirname(os.path.dirname(current_path))
|
|
565
|
+
data_dirs = []
|
|
566
|
+
# 尝试当前版本及几个历史版本
|
|
567
|
+
versions_to_try = [__version__] + ['0.1.' + str(i) for i in range(5, -1, -1)]
|
|
568
|
+
for ver in versions_to_try:
|
|
569
|
+
data_dir = os.path.join(parent_dir, 'pyinstallerex-{0}.data/data/bin'.format(ver))
|
|
570
|
+
data_dirs.append(data_dir)
|
|
571
|
+
|
|
572
|
+
for data_dir in data_dirs:
|
|
573
|
+
launcher_path = os.path.join(data_dir, launcher_name)
|
|
574
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
575
|
+
except Exception:
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
# 5. 尝试从源码目录查找启动器
|
|
579
|
+
launcher_path = os.path.join(
|
|
580
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), # src/PyInstallerEx
|
|
581
|
+
"..", # src
|
|
582
|
+
"..", # 项目根目录
|
|
583
|
+
"bin",
|
|
584
|
+
launcher_name
|
|
585
|
+
)
|
|
586
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
587
|
+
|
|
588
|
+
# 6. 尝试从site-packages的数据目录查找启动器
|
|
589
|
+
try:
|
|
590
|
+
# 获取当前模块的路径
|
|
591
|
+
current_module_path = os.path.dirname(os.path.abspath(__file__))
|
|
592
|
+
# 构造从site-packages开始的路径
|
|
593
|
+
launcher_path = os.path.join(
|
|
594
|
+
os.path.dirname(os.path.dirname(os.path.dirname(current_module_path))), # site-packages
|
|
595
|
+
"bin",
|
|
596
|
+
launcher_name
|
|
597
|
+
)
|
|
598
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
599
|
+
except:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
# 7. 尝试从通过data_files安装的数据目录查找启动器
|
|
603
|
+
try:
|
|
604
|
+
# 查找通过data_files安装的文件通常在site-packages同级目录的bin目录下
|
|
605
|
+
for path in sys.path:
|
|
606
|
+
# 检查标准的data_files安装路径
|
|
607
|
+
if 'site-packages' in path:
|
|
608
|
+
# 构造bin目录路径
|
|
609
|
+
launcher_path = os.path.join(
|
|
610
|
+
os.path.dirname(path), # site-packages的上级目录
|
|
611
|
+
"bin",
|
|
612
|
+
launcher_name
|
|
613
|
+
)
|
|
614
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
615
|
+
|
|
616
|
+
# 检查site-packages下的bin目录(直接安装)
|
|
617
|
+
launcher_path = os.path.join(path, "bin", launcher_name)
|
|
618
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
619
|
+
except:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
# 8. 尝试从标准数据目录查找
|
|
623
|
+
try:
|
|
624
|
+
for path in sys.path:
|
|
625
|
+
launcher_path = os.path.join(path, "bin", launcher_name)
|
|
626
|
+
launcher_paths.append(os.path.abspath(launcher_path))
|
|
627
|
+
except:
|
|
628
|
+
pass
|
|
629
|
+
|
|
630
|
+
# 查找第一个存在的启动器文件
|
|
631
|
+
found_launcher_path = None
|
|
632
|
+
for launcher_path in launcher_paths:
|
|
633
|
+
if os.path.exists(launcher_path):
|
|
634
|
+
found_launcher_path = launcher_path
|
|
635
|
+
break
|
|
636
|
+
|
|
637
|
+
# 如果找不到启动器文件,则提供详细的错误信息
|
|
638
|
+
if not found_launcher_path:
|
|
639
|
+
error_msg = "Binary launcher not found: {0}".format(launcher_name)
|
|
640
|
+
error_msg += "\nSearched platform: {0}".format(platform_name)
|
|
641
|
+
error_msg += "\nTried paths:"
|
|
642
|
+
for path in launcher_paths:
|
|
643
|
+
error_msg += "\n - {0} (exists: {1})".format(path, os.path.exists(path))
|
|
644
|
+
raise PackageError(error_msg)
|
|
645
|
+
|
|
646
|
+
self.logger.info("Using binary launcher: {0}".format(found_launcher_path))
|
|
647
|
+
return found_launcher_path
|
|
648
|
+
|
|
649
|
+
def _process_configuration(self, script_path, pyinstaller_dir):
|
|
650
|
+
"""处理配置文件"""
|
|
651
|
+
# 获取脚本名称
|
|
652
|
+
script_name = FileUtils.get_script_name(script_path)
|
|
653
|
+
|
|
654
|
+
# 生成配置内容
|
|
655
|
+
import os
|
|
656
|
+
script_exe_name = "{0}.exe".format(script_name) if SystemInfo().get_platform() in ["windows", "win"] else script_name
|
|
657
|
+
config_data = {
|
|
658
|
+
"main": script_exe_name,
|
|
659
|
+
"name": self.config.filename or script_name,
|
|
660
|
+
"version": self.config.version or "1.0.0",
|
|
661
|
+
"description": self.config.description or "Application packaged with PyInstallerEx",
|
|
662
|
+
"tmp_dir": self.config.tmp_dir or "ex_{name}_{md5}",
|
|
663
|
+
"author": self.config.author or "Unknown",
|
|
664
|
+
"extract_mode": self.config.extract_mode or "temp"
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
# 渲染配置为JSON
|
|
668
|
+
rendered_config = json.dumps(config_data, indent=2, ensure_ascii=False)
|
|
669
|
+
|
|
670
|
+
# 保存配置供后续合并使用
|
|
671
|
+
self._rendered_config = rendered_config
|
|
672
|
+
|
|
673
|
+
# 将配置写入PyInstaller输出目录
|
|
674
|
+
config_path = FileUtils.copy_config_to_dir(rendered_config, pyinstaller_dir, "ex.config.json")
|
|
675
|
+
|
|
676
|
+
self.logger.info("Configuration written to: {0}".format(config_path))
|
|
677
|
+
return rendered_config
|
|
678
|
+
|
|
679
|
+
def _compress_directory(self, directory):
|
|
680
|
+
"""压缩目录为ZIP文件"""
|
|
681
|
+
self.logger.info("Step 4: Compressing directory to ZIP...")
|
|
682
|
+
|
|
683
|
+
# 创建临时ZIP文件路径
|
|
684
|
+
zip_path = directory + ".zip"
|
|
685
|
+
|
|
686
|
+
# 压缩目录
|
|
687
|
+
CompressionUtils.create_zip_from_directory(directory, zip_path)
|
|
688
|
+
|
|
689
|
+
self.logger.info("ZIP file created: {0}".format(zip_path))
|
|
690
|
+
return zip_path
|
|
691
|
+
|
|
692
|
+
def _calculate_file_md5(self, file_path):
|
|
693
|
+
"""计算文件的MD5哈希值"""
|
|
694
|
+
import hashlib
|
|
695
|
+
hash_md5 = hashlib.md5()
|
|
696
|
+
with open(file_path, "rb") as f:
|
|
697
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
698
|
+
hash_md5.update(chunk)
|
|
699
|
+
return hash_md5.hexdigest()
|
|
700
|
+
|
|
701
|
+
def _combine_final_file(self, binary_path, zip_path, output_dir, script_path):
|
|
702
|
+
"""合并启动器和ZIP文件为最终输出"""
|
|
703
|
+
self.logger.info("Step 6: Combining launcher and ZIP file...")
|
|
704
|
+
|
|
705
|
+
# 确定输出目录
|
|
706
|
+
if output_dir is None:
|
|
707
|
+
output_dir = os.path.dirname(os.path.abspath(script_path))
|
|
708
|
+
|
|
709
|
+
FileUtils.ensure_directory_exists(output_dir)
|
|
710
|
+
|
|
711
|
+
# 确定输出文件名
|
|
712
|
+
script_name = FileUtils.get_script_name(script_path)
|
|
713
|
+
if SystemInfo().get_platform() in ["windows", "win"]:
|
|
714
|
+
output_filename = script_name + ".exe"
|
|
715
|
+
else:
|
|
716
|
+
output_filename = script_name
|
|
717
|
+
|
|
718
|
+
output_path = os.path.join(output_dir, output_filename)
|
|
719
|
+
|
|
720
|
+
# 在Windows上,如果指定了图标,先对启动器设置图标
|
|
721
|
+
icon = getattr(self.config, 'icon', None)
|
|
722
|
+
if SystemInfo().get_platform() in ["windows", "win"] and icon and os.path.exists(icon):
|
|
723
|
+
self.logger.info("Setting custom icon on launcher: {0}".format(icon))
|
|
724
|
+
|
|
725
|
+
# 创建临时启动器文件用于设置图标
|
|
726
|
+
temp_launcher_path = binary_path + ".tmp"
|
|
727
|
+
shutil.copy2(binary_path, temp_launcher_path)
|
|
728
|
+
|
|
729
|
+
# 对临时启动器设置图标
|
|
730
|
+
self._set_windows_icon(temp_launcher_path, icon)
|
|
731
|
+
|
|
732
|
+
# 使用设置了图标的启动器
|
|
733
|
+
binary_path = temp_launcher_path
|
|
734
|
+
self.logger.info("Icon set successfully on launcher")
|
|
735
|
+
|
|
736
|
+
# 合并文件
|
|
737
|
+
with open(output_path, "wb") as output_file:
|
|
738
|
+
# 写入启动器
|
|
739
|
+
with open(binary_path, "rb") as launcher_file:
|
|
740
|
+
launcher_data = launcher_file.read()
|
|
741
|
+
output_file.write(launcher_data)
|
|
742
|
+
|
|
743
|
+
# 记录 ZIP 数据的起始偏移
|
|
744
|
+
zip_offset = len(launcher_data)
|
|
745
|
+
|
|
746
|
+
# 读取 ZIP 文件内容
|
|
747
|
+
with open(zip_path, "rb") as zip_file:
|
|
748
|
+
zip_data = zip_file.read()
|
|
749
|
+
|
|
750
|
+
# 计算 ZIP 数据的 MD5 哈希值(仅 ZIP 数据,不包括配置)
|
|
751
|
+
import hashlib
|
|
752
|
+
zip_md5 = hashlib.md5(zip_data).hexdigest()
|
|
753
|
+
self.logger.debug("ZIP MD5: {0}".format(zip_md5))
|
|
754
|
+
|
|
755
|
+
# 追加ZIP数据
|
|
756
|
+
output_file.write(zip_data)
|
|
757
|
+
|
|
758
|
+
# 写入配置JSON到文件末尾(启动器从尾部读取)
|
|
759
|
+
config_data = json.loads(self._rendered_config)
|
|
760
|
+
config_data["zip_offset"] = zip_offset
|
|
761
|
+
config_data["zip_md5"] = zip_md5
|
|
762
|
+
config_json = json.dumps(config_data, indent=2, ensure_ascii=False)
|
|
763
|
+
output_file.write(config_json.encode("utf-8"))
|
|
764
|
+
|
|
765
|
+
# 清理临时启动器文件
|
|
766
|
+
if SystemInfo().get_platform() in ["windows", "win"] and icon and os.path.exists(icon):
|
|
767
|
+
try:
|
|
768
|
+
os.remove(binary_path)
|
|
769
|
+
except Exception as e:
|
|
770
|
+
self.logger.debug("Failed to remove temp launcher: {0}".format(e))
|
|
771
|
+
|
|
772
|
+
# 在Unix系统上设置可执行权限
|
|
773
|
+
if SystemInfo().get_platform() not in ["windows", "win"]:
|
|
774
|
+
os.chmod(output_path, 0o755)
|
|
775
|
+
|
|
776
|
+
self.logger.info("Final executable created: {0}".format(output_path))
|
|
777
|
+
return output_path
|
|
778
|
+
|
|
779
|
+
def _set_windows_icon(self, exe_path, icon_path):
|
|
780
|
+
"""在Windows上为exe文件设置图标(使用 pywin32 - 参考 PyInstaller 实现)"""
|
|
781
|
+
try:
|
|
782
|
+
import win32api
|
|
783
|
+
import struct
|
|
784
|
+
|
|
785
|
+
# 定义资源类型常量
|
|
786
|
+
RT_ICON = 3
|
|
787
|
+
RT_GROUP_ICON = 14
|
|
788
|
+
|
|
789
|
+
# 读取 ICO 文件
|
|
790
|
+
with open(icon_path, 'rb') as f:
|
|
791
|
+
icon_data = f.read()
|
|
792
|
+
|
|
793
|
+
# 验证 ICO 文件格式
|
|
794
|
+
if len(icon_data) < 6:
|
|
795
|
+
self.logger.warning("Invalid ICO file: too short")
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
reserved, icon_type, count = struct.unpack('<HHH', icon_data[:6])
|
|
799
|
+
if icon_type != 1:
|
|
800
|
+
self.logger.warning("Not a valid ICO file")
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
# 解析图标入口(参考 PyInstaller 的 ICONDIRENTRY 格式:bbbbhhii)
|
|
804
|
+
offset = 6
|
|
805
|
+
icon_entries = []
|
|
806
|
+
images = []
|
|
807
|
+
for i in range(count):
|
|
808
|
+
if offset + 16 > len(icon_data):
|
|
809
|
+
break
|
|
810
|
+
# 使用与 PyInstaller 相同的格式:BBBBHHII (width, height, colors, reserved, planes, bitcount, size, offset)
|
|
811
|
+
entry_data = icon_data[offset:offset+16]
|
|
812
|
+
width, height, colors, reserved2, planes, bitcount, size, file_offset = \
|
|
813
|
+
struct.unpack('<BBBBHHII', entry_data)
|
|
814
|
+
icon_entries.append({
|
|
815
|
+
'width': width,
|
|
816
|
+
'height': height,
|
|
817
|
+
'planes': planes,
|
|
818
|
+
'bitcount': bitcount,
|
|
819
|
+
'size': size,
|
|
820
|
+
'file_offset': file_offset
|
|
821
|
+
})
|
|
822
|
+
# 提取图像数据
|
|
823
|
+
img_data = icon_data[file_offset:file_offset + size]
|
|
824
|
+
images.append(img_data)
|
|
825
|
+
offset += 16
|
|
826
|
+
|
|
827
|
+
if not icon_entries:
|
|
828
|
+
self.logger.warning("No icons found in ICO file")
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
# 使用 win32api.BeginUpdateResource 开始更新(和 PyInstaller 一样)
|
|
832
|
+
hdst = win32api.BeginUpdateResource(exe_path, 0)
|
|
833
|
+
|
|
834
|
+
# 写入 RT_ICON 资源(直接使用 ICO 中的原始图像数据)
|
|
835
|
+
iconid = 1
|
|
836
|
+
for data in images:
|
|
837
|
+
win32api.UpdateResource(hdst, RT_ICON, iconid, data)
|
|
838
|
+
self.logger.debug("Writing RT_ICON %d resource with %d bytes", iconid, len(data))
|
|
839
|
+
iconid = iconid + 1
|
|
840
|
+
|
|
841
|
+
# 构建并写入 RT_GROUP_ICON 资源(参考 PyInstaller 的 GRPICONDIRENTRY 格式)
|
|
842
|
+
# GRPICONDIRENTRY 格式: bbbb h h i h (width, height, colors, reserved, planes, bitcount, size, id)
|
|
843
|
+
group_data = struct.pack('<HHH', 0, 1, len(icon_entries)) # GRPICONDIR header
|
|
844
|
+
for idx, entry in enumerate(icon_entries, start=1):
|
|
845
|
+
# 使用与 PyInstaller 相同的格式:bbbbhhih
|
|
846
|
+
group_entry = struct.pack('<BBBBHHIH',
|
|
847
|
+
entry['width'], # bWidth (BYTE)
|
|
848
|
+
entry['height'], # bHeight (BYTE)
|
|
849
|
+
0, # bColorCount (BYTE)
|
|
850
|
+
0, # bReserved (BYTE)
|
|
851
|
+
entry['planes'], # wPlanes (WORD) - 可能超过65535,需要截断
|
|
852
|
+
entry['bitcount'], # wBitCount (WORD)
|
|
853
|
+
entry['size'], # dwBytesInRes (DWORD)
|
|
854
|
+
idx # nID (WORD)
|
|
855
|
+
)
|
|
856
|
+
group_data += group_entry
|
|
857
|
+
|
|
858
|
+
win32api.UpdateResource(hdst, RT_GROUP_ICON, 1, group_data)
|
|
859
|
+
self.logger.debug("Writing RT_GROUP_ICON resource with %d bytes", len(group_data))
|
|
860
|
+
|
|
861
|
+
# 完成更新
|
|
862
|
+
win32api.EndUpdateResource(hdst, 0)
|
|
863
|
+
self.logger.info("Icon set successfully using win32api: {0}".format(icon_path))
|
|
864
|
+
|
|
865
|
+
except ImportError as e:
|
|
866
|
+
self.logger.warning("pywin32 not available, skipping icon setting: {0}".format(e))
|
|
867
|
+
except Exception as e:
|
|
868
|
+
self.logger.warning("Failed to set icon: {0}".format(e))
|
|
869
|
+
import traceback
|
|
870
|
+
self.logger.debug(traceback.format_exc())
|
|
871
|
+
|
|
872
|
+
def _compile_rust_launcher_with_icon(self, icon_path):
|
|
873
|
+
"""动态编译带图标的 Rust 启动器
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
icon_path: 图标文件路径(必须是 .ico 格式)
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
str: 编译后的启动器路径,失败则返回 None
|
|
880
|
+
"""
|
|
881
|
+
try:
|
|
882
|
+
import shutil
|
|
883
|
+
import subprocess
|
|
884
|
+
|
|
885
|
+
# 获取 Rust 项目目录
|
|
886
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
887
|
+
# packager.py 在 src/PyInstallerEx/core/
|
|
888
|
+
# Rust 项目在 src/launcher/
|
|
889
|
+
rust_project_dir = os.path.join(
|
|
890
|
+
os.path.dirname(os.path.dirname(current_dir)), # 向上两级到 src/
|
|
891
|
+
'launcher'
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if not os.path.exists(rust_project_dir):
|
|
895
|
+
self.logger.warning("Rust project directory not found: {0}".format(rust_project_dir))
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
# 创建临时工作目录
|
|
899
|
+
temp_work_dir = os.path.join(rust_project_dir, '_temp_build')
|
|
900
|
+
if os.path.exists(temp_work_dir):
|
|
901
|
+
shutil.rmtree(temp_work_dir)
|
|
902
|
+
os.makedirs(temp_work_dir)
|
|
903
|
+
|
|
904
|
+
# 复制所有 Rust 源文件到临时目录
|
|
905
|
+
for item in ['src', 'Cargo.toml', 'build.rs']:
|
|
906
|
+
src_item = os.path.join(rust_project_dir, item)
|
|
907
|
+
dst_item = os.path.join(temp_work_dir, item)
|
|
908
|
+
if os.path.isdir(src_item):
|
|
909
|
+
shutil.copytree(src_item, dst_item)
|
|
910
|
+
elif os.path.isfile(src_item):
|
|
911
|
+
shutil.copy2(src_item, dst_item)
|
|
912
|
+
|
|
913
|
+
# 复制图标文件并重命名为 icon.ico
|
|
914
|
+
icon_dst = os.path.join(temp_work_dir, 'icon.ico')
|
|
915
|
+
shutil.copy2(icon_path, icon_dst)
|
|
916
|
+
self.logger.info("Copied icon to: {0}".format(icon_dst))
|
|
917
|
+
|
|
918
|
+
# 执行 cargo build --release
|
|
919
|
+
self.logger.info("Compiling Rust launcher with custom icon...")
|
|
920
|
+
result = subprocess.run(
|
|
921
|
+
['cargo', 'build', '--release'],
|
|
922
|
+
cwd=temp_work_dir,
|
|
923
|
+
capture_output=True,
|
|
924
|
+
text=True,
|
|
925
|
+
timeout=300 # 5分钟超时
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
if result.returncode != 0:
|
|
929
|
+
self.logger.error("Rust compilation failed:")
|
|
930
|
+
self.logger.error(result.stderr)
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
# 获取编译后的启动器路径
|
|
934
|
+
target_dir = os.path.join(temp_work_dir, 'target', 'release')
|
|
935
|
+
compiled_launcher = os.path.join(target_dir, 'pyinstallerex-launcher.exe')
|
|
936
|
+
|
|
937
|
+
if not os.path.exists(compiled_launcher):
|
|
938
|
+
self.logger.warning("Compiled launcher not found: {0}".format(compiled_launcher))
|
|
939
|
+
return None
|
|
940
|
+
|
|
941
|
+
# 复制到 bin 目录以便后续使用
|
|
942
|
+
bin_dir = os.path.join(os.path.dirname(os.path.dirname(current_dir)), 'bin')
|
|
943
|
+
FileUtils.ensure_directory_exists(bin_dir)
|
|
944
|
+
final_launcher = os.path.join(bin_dir, 'launcher_windows_custom.exe')
|
|
945
|
+
shutil.copy2(compiled_launcher, final_launcher)
|
|
946
|
+
|
|
947
|
+
self.logger.info("Custom launcher compiled successfully: {0}".format(final_launcher))
|
|
948
|
+
|
|
949
|
+
# 清理临时目录
|
|
950
|
+
try:
|
|
951
|
+
shutil.rmtree(temp_work_dir)
|
|
952
|
+
except Exception as e:
|
|
953
|
+
self.logger.debug("Failed to cleanup temp dir: {0}".format(e))
|
|
954
|
+
|
|
955
|
+
return final_launcher
|
|
956
|
+
|
|
957
|
+
except Exception as e:
|
|
958
|
+
self.logger.error("Error compiling Rust launcher: {0}".format(e))
|
|
959
|
+
import traceback
|
|
960
|
+
self.logger.debug(traceback.format_exc())
|
|
961
|
+
return None
|