-
Notifications
You must be signed in to change notification settings - Fork 97
Expand file tree
/
Copy pathbuild.py
More file actions
1503 lines (1249 loc) · 56.9 KB
/
build.py
File metadata and controls
1503 lines (1249 loc) · 56.9 KB
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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MTools 跨平台构建脚本
使用 Nuitka 将 Python 项目打包为可执行文件。
"""
import os
import sys
# 设置 stdout/stderr 编码为 UTF-8(解决 Windows CI 环境的编码问题)
if sys.stdout.encoding != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if sys.stderr.encoding != 'utf-8':
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
import shutil
import platform
import subprocess
from pathlib import Path
import zipfile
import importlib.util
import argparse
import signal
import atexit
# 路径配置
PROJECT_ROOT = Path(__file__).parent.absolute()
ASSETS_DIR = PROJECT_ROOT / "src" / "assets"
APP_CONFIG_FILE = PROJECT_ROOT / "src" / "constants" / "app_config.py"
def write_cuda_variant_to_config():
"""将 CUDA 变体信息写入 app_config.py
在构建时读取 CUDA_VARIANT 环境变量,并将其写入到
app_config.py 的 BUILD_CUDA_VARIANT 常量中,使得编译后的
程序能够知道自己的 CUDA 变体类型。
"""
cuda_variant = os.environ.get('CUDA_VARIANT', 'none').lower()
# 验证值是否合法
if cuda_variant not in ('none', 'cuda', 'cuda_full'):
print(f" ⚠️ 无效的 CUDA_VARIANT 值: {cuda_variant},使用默认值 'none'")
cuda_variant = 'none'
print(f" 📝 写入 CUDA 变体信息: {cuda_variant}")
try:
# 读取配置文件
with open(APP_CONFIG_FILE, 'r', encoding='utf-8') as f:
content = f.read()
# 替换 BUILD_CUDA_VARIANT 的值
import re
pattern = r'BUILD_CUDA_VARIANT:\s*Final\[str\]\s*=\s*"[^"]*"'
replacement = f'BUILD_CUDA_VARIANT: Final[str] = "{cuda_variant}"'
new_content = re.sub(pattern, replacement, content)
# 写回文件
with open(APP_CONFIG_FILE, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f" ✅ 已将 BUILD_CUDA_VARIANT 设置为: {cuda_variant}")
except Exception as e:
print(f" ⚠️ 写入 CUDA 变体信息失败: {e}")
print(f" ⚠️ 将继续构建,但程序可能无法正确检测 CUDA 变体")
def get_dist_dir(mode="release"):
"""根据构建模式获取输出目录
Args:
mode: 构建模式 ('release' 或 'dev')
Returns:
Path: 输出目录路径
"""
return PROJECT_ROOT / "dist" / mode
def get_platform_name():
"""获取平台相关的输出名称(统一目录和 zip 命名)
支持通过环境变量 CUDA_VARIANT 指定 CUDA 版本后缀:
- 无环境变量或 'none': 标准版本,无后缀
- 'cuda': CUDA 版本,添加 '_CUDA' 后缀
- 'cuda_full': CUDA Full 版本,添加 '_CUDA_FULL' 后缀
Returns:
str: 平台名称,例如 "Windows_amd64", "Windows_amd64_CUDA", "Linux_amd64_CUDA_FULL"
"""
system = platform.system()
machine = platform.machine().upper()
# 统一机器架构名称
arch_map = {
'X86_64': 'amd64', # Linux/macOS 常用
'AMD64': 'amd64', # Windows 常用
'ARM64': 'arm64', # Apple Silicon
'AARCH64': 'arm64', # Linux ARM64
'I386': 'x86',
'I686': 'x86',
}
arch = arch_map.get(machine, machine)
base_name = f"{system}_{arch}"
# 检查 CUDA 变体环境变量
cuda_variant = os.environ.get('CUDA_VARIANT', 'none').lower()
if cuda_variant == 'cuda':
return f"{base_name}_CUDA"
elif cuda_variant == 'cuda_full':
return f"{base_name}_CUDA_FULL"
else:
return base_name
# 全局状态标记
_build_interrupted = False
_cleanup_handlers = []
def signal_handler(signum, frame):
"""处理中断信号(Ctrl+C)"""
global _build_interrupted
if _build_interrupted:
# 如果已经中断过一次,强制退出
print("\n\n❌ 强制退出")
sys.exit(1)
_build_interrupted = True
print("\n\n⚠️ 检测到中断信号,正在清理...")
print(" (再次按 Ctrl+C 强制退出)")
# 执行清理
cleanup_on_exit()
print("\n✅ 清理完成,已退出构建")
sys.exit(130) # 标准的 SIGINT 退出码
def register_cleanup_handler(handler):
"""注册清理处理函数
Args:
handler: 清理函数,无参数
"""
if handler not in _cleanup_handlers:
_cleanup_handlers.append(handler)
def cleanup_on_exit():
"""执行所有清理处理器"""
for handler in _cleanup_handlers:
try:
handler()
except Exception as e:
print(f" 清理时出错: {e}")
def get_app_config():
"""从配置文件中导入应用信息"""
config = {
"APP_TITLE": "MTools",
"APP_VERSION": "0.1.0",
"APP_DESCRIPTION": "MTools Desktop App"
}
if not APP_CONFIG_FILE.exists():
print(f"⚠️ 警告: 未找到配置文件 {APP_CONFIG_FILE}")
return config
try:
# 动态导入模块,无需将 src 加入 sys.path
spec = importlib.util.spec_from_file_location("app_config", APP_CONFIG_FILE)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 获取常量
if hasattr(module, "APP_TITLE"):
full_title = module.APP_TITLE
config["APP_TITLE"] = full_title.split(" - ")[0] if " - " in full_title else full_title
if hasattr(module, "APP_VERSION"):
config["APP_VERSION"] = module.APP_VERSION
if hasattr(module, "APP_DESCRIPTION"):
config["APP_DESCRIPTION"] = module.APP_DESCRIPTION
except Exception as e:
print(f"⚠️ 导入配置文件失败: {e}")
return config
# 加载配置
APP_CONFIG = get_app_config()
# 项目配置
APP_NAME = APP_CONFIG["APP_TITLE"]
MAIN_SCRIPT = "src/main.py"
VERSION = APP_CONFIG["APP_VERSION"]
COMPANY_NAME = "HG-ha"
COPYRIGHT = f"Copyright (C) 2025 by {COMPANY_NAME}"
DESCRIPTION = APP_CONFIG["APP_DESCRIPTION"]
def get_variant_suffix():
"""获取变体后缀(用于版本信息显示)
Returns:
str: 变体后缀,例如 " (CUDA)", " (CUDA FULL)", 或空字符串(标准版)
"""
cuda_variant = os.environ.get('CUDA_VARIANT', 'none').lower()
if cuda_variant == 'cuda':
return " (CUDA)"
elif cuda_variant == 'cuda_full':
return " (CUDA FULL)"
else:
return "" # 标准版不添加后缀
def get_file_version(version: str) -> str:
"""将版本号转换为 Windows 文件版本格式(4 段纯数字)。
Args:
version: 版本号,如 "0.0.1-beta", "1.2.3"
Returns:
4 段数字格式,如 "0.0.1.0", "1.2.3.0"
"""
import re
# 移除预发布标签(如 -beta, -alpha, -rc1 等)
clean_version = re.split(r'[-+]', version)[0]
# 分割版本号
parts = clean_version.split('.')
# 确保有 4 段数字
while len(parts) < 4:
parts.append('0')
# 只取前 4 段,确保都是数字
return '.'.join(parts[:4])
def clean_dist(mode="release"):
"""清理构建目录
Args:
mode: 构建模式 ('release' 或 'dev')
"""
dist_dir = get_dist_dir(mode)
print(f"🧹 清理旧的构建文件 ({mode} 模式)...")
if dist_dir.exists():
try:
shutil.rmtree(dist_dir)
print(f" 已删除: {dist_dir}")
except Exception as e:
print(f" ❌ 清理失败: {e}")
def cleanup_incomplete_build(mode="release"):
"""清理未完成的构建文件
Args:
mode: 构建模式 ('release' 或 'dev')
"""
dist_dir = get_dist_dir(mode)
try:
# 清理 .dist 临时目录
if dist_dir.exists():
for item in dist_dir.glob("*.dist"):
if item.is_dir():
print(f" 清理临时目录: {item.name}")
shutil.rmtree(item)
# 清理 .build 临时目录
for item in dist_dir.glob("*.build"):
if item.is_dir():
print(f" 清理临时目录: {item.name}")
shutil.rmtree(item)
except Exception as e:
print(f" 清理临时文件时出错: {e}")
def cleanup_build_cache():
"""清理构建缓存目录(dist/.build_cache)
这个目录包含 flet_client 等缓存文件,可在多次构建之间复用。
如果需要节省磁盘空间,可以在构建完成后清理。
"""
cache_dir = PROJECT_ROOT / "dist" / ".build_cache"
if cache_dir.exists():
try:
print("🧹 清理构建缓存目录...")
shutil.rmtree(cache_dir)
print(f" 已删除: {cache_dir}")
except Exception as e:
print(f" ❌ 清理缓存失败: {e}")
def check_upx(upx_path=None):
"""检查 UPX 是否可用
Args:
upx_path: 自定义 UPX 路径(可选)
Returns:
tuple: (是否可用, UPX路径或None)
"""
# 如果指定了路径,优先使用
if upx_path:
upx_exe = Path(upx_path)
if upx_exe.exists() and upx_exe.is_file():
try:
result = subprocess.run([str(upx_exe), "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(f"✅ 找到 UPX: {upx_exe}")
return True, str(upx_exe)
except Exception as e:
print(f"⚠️ 指定的 UPX 路径无效: {e}")
else:
print(f"⚠️ 指定的 UPX 路径不存在: {upx_path}")
# 检查环境变量 PATH
try:
result = subprocess.run(["upx", "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print("✅ 在系统 PATH 中找到 UPX")
return True, "upx"
except FileNotFoundError:
pass
except Exception as e:
print(f"⚠️ 检查 UPX 时出错: {e}")
print("⚠️ 未找到 UPX 工具")
print(" 提示: 下载 UPX https://github.com/upx/upx/releases")
return False, None
def check_onnxruntime_version():
"""检查 onnxruntime 版本并给出建议
支持的版本(所有平台都接受以下任一版本):
- onnxruntime==1.24.4 (Windows/macOS/Linux CPU,macOS Apple Silicon 内置 CoreML 加速)
- onnxruntime-gpu==1.24.4 (Linux/Windows NVIDIA CUDA加速)
- onnxruntime-directml==1.24.4 (Windows DirectML加速,推荐)
注意:仅显示提示信息,不会阻断构建过程
Returns:
bool: 始终返回 True,不阻断构建
"""
system = platform.system()
machine = platform.machine().lower()
try:
# 检查已安装的 onnxruntime 包
# 优先使用 uv pip list,如果失败则回退到 python -m pip list
result = None
# 尝试使用 uv pip list
try:
result = subprocess.run(
["uv", "pip", "list"],
capture_output=True,
text=True,
timeout=10,
cwd=PROJECT_ROOT
)
except FileNotFoundError:
# uv 命令不存在,使用传统 pip
pass
# 如果 uv 失败或不存在,使用 python -m pip list
if not result or result.returncode != 0:
result = subprocess.run(
[sys.executable, "-m", "pip", "list"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
print("⚠️ 无法检查已安装的包,跳过 onnxruntime 版本检查")
return True
installed_packages = result.stdout.lower()
# 检测安装的 onnxruntime 变体
installed_variant = None
installed_version = None
for line in installed_packages.split('\n'):
if 'onnxruntime' in line:
parts = line.split()
if len(parts) >= 2:
installed_variant = parts[0]
installed_version = parts[1]
break
if not installed_variant:
print("⚠️ 未检测到 onnxruntime,某些 AI 功能可能无法使用")
print(" 提示:安装 onnxruntime 以启用 AI 功能(背景移除、图像增强等)")
return True
# 显示当前安装的版本
print(f"📦 ONNX Runtime: {installed_variant} {installed_version}")
# 检查版本号
if installed_version != "1.24.4":
print(f" ⚠️ 推荐版本: 1.24.4(当前: {installed_version})")
print(" ⚠️ 使用非推荐版本可能导致兼容性问题")
# 根据平台给出建议
is_apple_silicon = "arm" in machine or "aarch64" in machine
if system == "Windows":
if installed_variant == "onnxruntime-directml":
print(" ✅ 使用 DirectML 加速版本(推荐,支持 Intel/AMD/NVIDIA GPU)")
elif installed_variant == "onnxruntime-gpu":
print(" ✅ 使用 CUDA 加速版本(需要 NVIDIA GPU 和 CUDA Toolkit)")
print(" 💡 提示:Windows 推荐使用 onnxruntime-directml(兼容性更好)")
elif installed_variant == "onnxruntime":
print(" ℹ️ 使用 CPU 版本")
print(" 💡 推荐:uv add onnxruntime-directml==1.24.4(启用 GPU 加速)")
else:
print(f" ⚠️ {installed_variant} 在 Windows 上可能不受支持")
print(" 💡 推荐:uv add onnxruntime-directml==1.24.4")
elif system == "Darwin":
if installed_variant == "onnxruntime":
if is_apple_silicon:
print(" ✅ 使用标准版本(已内置 CoreML 加速,推荐)")
else:
print(" ℹ️ 使用 CPU 版本(Intel Mac)")
elif installed_variant == "onnxruntime-silicon":
print(" ⚠️ onnxruntime-silicon 已被弃用")
print(" 💡 推荐:uv remove onnxruntime-silicon && uv add onnxruntime==1.24.4")
print(" ℹ️ 说明:新版 onnxruntime 已内置 CoreML 支持,无需单独安装 silicon 版本")
elif installed_variant == "onnxruntime-gpu":
print(" ⚠️ macOS 不支持 CUDA")
print(" 💡 推荐:uv remove onnxruntime-gpu && uv add onnxruntime==1.24.4")
elif installed_variant == "onnxruntime-directml":
print(" ⚠️ macOS 不支持 DirectML")
print(" 💡 推荐:uv remove onnxruntime-directml && uv add onnxruntime==1.24.4")
elif system == "Linux":
if installed_variant == "onnxruntime-gpu":
print(" ✅ 使用 CUDA 加速版本(需要 NVIDIA GPU、CUDA Toolkit 和 cuDNN)")
elif installed_variant == "onnxruntime":
print(" ℹ️ 使用 CPU 版本")
print(" 💡 提示:如有 NVIDIA GPU,可使用 onnxruntime-gpu==1.24.4(需配置 CUDA)")
elif installed_variant == "onnxruntime-directml":
print(" ⚠️ Linux 不支持 DirectML")
print(" 💡 推荐:uv remove onnxruntime-directml && uv add onnxruntime==1.24.4")
elif installed_variant == "onnxruntime-silicon":
print(" ⚠️ onnxruntime-silicon 已被弃用且不支持 Linux")
print(" 💡 推荐:uv remove onnxruntime-silicon && uv add onnxruntime==1.24.4")
return True
except Exception as e:
print(f"⚠️ 检查 onnxruntime 版本时出错: {e}")
return True
def prepare_flet_client(enable_upx_compression=False, upx_path=None, output_base_dir=None):
"""准备 Flet 客户端目录(动态生成到构建输出目录)
新策略:不再放在源码目录,而是构建时动态准备到 dist/.build_cache/flet_client/,
然后通过 Nuitka 的 --include-data-dir 参数包含到最终程序中。
优点:
- 不污染源码目录
- 支持多版本并存(不同 flet 版本)
- 构建缓存可重用
Args:
enable_upx_compression: 是否对 flet 客户端的 exe/dll 进行 UPX 压缩
upx_path: UPX 可执行文件路径(可选)
output_base_dir: 输出基础目录,默认为 PROJECT_ROOT/dist/.build_cache
Returns:
Path: flet_client 目录路径,失败返回 None
"""
system = platform.system()
# 默认输出到 dist/.build_cache/flet_client/
if output_base_dir is None:
output_base_dir = PROJECT_ROOT / "dist" / ".build_cache"
# 获取 flet 版本
try:
import flet.version
flet_version = flet.version.flet_version
except ImportError:
print("❌ 错误: 未找到 flet 模块")
return None
# 目标目录:dist/.build_cache/flet_client-{version}/
flet_client_output = output_base_dir / f"flet_client-{flet_version}"
print("\n" + "="*60)
print(f"📦 准备 Flet 客户端 ({system})")
print("="*60)
# 通过 flet_desktop.ensure_client_cached() 获取/下载客户端
# Flet 0.84.0+ 不再在包目录内捆绑客户端,而是按需下载到 ~/.flet/client/
try:
import flet_desktop
print(f"⏳ 正在获取 Flet 客户端(如未缓存将自动从 GitHub 下载)...")
flet_client_dir = flet_desktop.ensure_client_cached()
if not flet_client_dir or not flet_client_dir.exists():
print("❌ 错误: 获取 Flet 客户端失败")
return None
print(f"源目录: {flet_client_dir}")
print(f"目标目录: {flet_client_output}")
print(f"版本: {flet_version}")
print("="*60)
except ImportError:
print("❌ 错误: 未找到 flet_desktop 模块")
print("\n请先安装依赖:")
print(" uv sync")
return None
except Exception as e:
print(f"❌ 错误: 获取 Flet 客户端失败: {e}")
import traceback
traceback.print_exc()
return None
# 如果目标目录已存在且完整,直接返回
if flet_client_output.exists():
# 检查是否完整(至少有 flet.exe 或主要文件)
if system == "Windows":
flet_exe = flet_client_output / "flet" / "flet.exe"
if flet_exe.exists():
file_count = len(list(flet_client_output.rglob('*')))
total_size = sum(f.stat().st_size for f in flet_client_output.rglob('*') if f.is_file())
size_mb = total_size / (1024 * 1024)
print(f"✅ 找到缓存: {flet_client_output.name} ({size_mb:.2f} MB)")
return flet_client_output
# 目录存在但不完整,删除重建
print(f" 清理不完整的缓存...")
shutil.rmtree(flet_client_output)
# 确保输出目录存在
output_base_dir.mkdir(parents=True, exist_ok=True)
try:
# 创建输出目录
flet_client_output.mkdir(parents=True, exist_ok=True)
# 复制 Flet 客户端文件
# ensure_client_cached() 返回的目录结构:
# Windows: {cache_dir}/flet/flet.exe + 依赖 DLL
# macOS: {cache_dir}/Flet.app/
# Linux: {cache_dir}/flet/flet
print(f"⏳ 正在复制 Flet 客户端...")
shutil.copytree(flet_client_dir, flet_client_output, dirs_exist_ok=True)
# 统计文件数量和大小
all_files = list(flet_client_output.rglob('*'))
file_count = len([f for f in all_files if f.is_file()])
total_size = sum(f.stat().st_size for f in all_files if f.is_file())
size_mb = total_size / (1024 * 1024)
# UPX 压缩(如果启用)
compressed_count = 0
if enable_upx_compression:
upx_available, upx_cmd = check_upx(upx_path)
if upx_available:
print("\n🗜️ 正在对 Flet 客户端进行 UPX 压缩...")
print(" ⚠️ 注意: 跳过 Flutter 核心引擎文件")
# 跳过 Flutter 核心引擎和 OpenGL 相关文件(这些文件压缩后可能无法运行)
skip_files = {
"flet.exe", # Flet 主程序
"flutter_windows.dll", # Flutter 引擎
"libEGL.dll", # OpenGL ES 库
"libGLESv2.dll", # OpenGL ES 2.0 库
"app.so", # Flutter 应用主体(不能压缩)
}
compressed_files = []
skipped_files = []
for file in all_files:
if file.is_file() and file.suffix.lower() in ['.dll', '.exe', '.so']:
if file.name in skip_files:
skipped_files.append(file.name)
continue
try:
# 获取压缩前大小
before_size = file.stat().st_size
result = subprocess.run(
[upx_cmd, "--best", "--lzma", str(file)],
capture_output=True,
timeout=60,
check=False
)
# 获取压缩后大小
after_size = file.stat().st_size
saved = before_size - after_size
if result.returncode == 0:
compressed_files.append((file.name, before_size, after_size, saved))
compressed_count += 1
else:
# UPX 失败(可能文件已压缩或不兼容)
pass
except subprocess.TimeoutExpired:
print(f" ⚠️ {file.name}: 压缩超时,跳过")
except Exception as e:
print(f" ⚠️ {file.name}: {e}")
# 重新计算总大小
compressed_size = sum(f.stat().st_size for f in all_files if f.is_file())
compressed_size_mb = compressed_size / (1024 * 1024)
saved_mb = size_mb - compressed_size_mb
print(f"\n ✅ 已压缩 {compressed_count} 个文件")
if compressed_files:
print(f" 📊 压缩详情(前 10 个):")
for name, before, after, saved in sorted(compressed_files, key=lambda x: x[3], reverse=True)[:10]:
ratio = (1 - after/before) * 100 if before > 0 else 0
print(f" • {name}: {before/1024/1024:.2f}MB → {after/1024/1024:.2f}MB (-{ratio:.1f}%)")
if skipped_files:
print(f" ⏭️ 跳过 {len(skipped_files)} 个核心文件: {', '.join(skipped_files[:5])}")
print(f" 💾 总节省: {saved_mb:.2f} MB ({saved_mb/size_mb*100:.1f}%)")
size_mb = compressed_size_mb
print("="*60)
print("✅ Flet 客户端准备完成!")
print("="*60)
print(f"缓存目录: {flet_client_output}")
print(f"文件数: {file_count}")
print(f"大小: {size_mb:.2f} MB")
if compressed_count > 0:
print(f"UPX 压缩: {compressed_count} 个文件")
print("="*60 + "\n")
return flet_client_output
except Exception as e:
print(f"\n❌ 准备失败: {e}")
import traceback
traceback.print_exc()
return None
def check_and_prepare_flet_client(enable_upx=False, upx_path=None):
"""检查并自动准备 Flet 客户端目录(到构建缓存)
新策略:动态生成到 dist/.build_cache/flet_client-{version}/,
避免污染源码目录。
Args:
enable_upx: 是否对 flet 客户端进行 UPX 压缩
upx_path: UPX 可执行文件路径(可选)
Returns:
Path: flet_client 目录路径,失败返回 None
"""
print("\n🔍 检查 Flet 客户端...")
# 调用 prepare_flet_client,它会自动检查缓存
flet_client_path = prepare_flet_client(
enable_upx_compression=enable_upx,
upx_path=upx_path
)
if not flet_client_path:
print("\n❌ Flet 客户端准备失败")
return None
return flet_client_path
def check_dependencies():
"""检查并同步依赖"""
print("🔍 检查依赖环境...")
# 检查 pyproject.toml 是否存在
if not (PROJECT_ROOT / "pyproject.toml").exists():
print("⚠️ 未找到 pyproject.toml,跳过依赖检查")
return True
try:
# 尝试使用 uv sync 同步依赖(包含 dev 依赖以获取 flet_desktop 和 nuitka)
# 这会确保环境与 uv.lock/pyproject.toml 一致
print(" 执行 uv sync --all-groups...")
subprocess.check_call(["uv", "sync", "--all-groups"], cwd=PROJECT_ROOT)
print("✅ 依赖已同步")
except FileNotFoundError:
print("⚠️ 未找到 uv 命令,请确保已安装 uv (https://github.com/astral-sh/uv)")
print(" 将尝试使用当前 Python 环境继续构建...")
except subprocess.CalledProcessError as e:
print(f"⚠️ 依赖同步失败: {e}")
print(" 尝试继续构建...")
# 检查 onnxruntime 版本
print("\n🔍 检查 ONNX Runtime 版本...")
if not check_onnxruntime_version():
return False
# Linux 上检查 patchelf
if platform.system() == "Linux":
print("\n🔍 检查 Linux 构建依赖...")
if not check_patchelf():
return False
return True
def check_patchelf():
"""检查 patchelf 是否已安装(仅 Linux)
patchelf 是 Nuitka 在 Linux 上修改 ELF 二进制文件所必需的工具。
Returns:
bool: 如果已安装或非 Linux 系统返回 True
"""
if platform.system() != "Linux":
return True
try:
result = subprocess.run(
["patchelf", "--version"],
capture_output=True,
timeout=5
)
if result.returncode == 0:
version = result.stdout.decode().strip() or result.stderr.decode().strip()
print(f" ✅ 找到 patchelf: {version}")
return True
except FileNotFoundError:
pass
except subprocess.TimeoutExpired:
pass
except Exception as e:
print(f"⚠️ 检查 patchelf 时出错: {e}")
print("\n" + "=" * 60)
print("❌ 未找到 patchelf")
print("=" * 60)
print("patchelf 是 Nuitka 在 Linux 上构建所必需的工具。")
print("\n请安装 patchelf:")
print(" Ubuntu/Debian: sudo apt-get install patchelf")
print(" Fedora/RHEL: sudo dnf install patchelf")
print(" Arch Linux: sudo pacman -S patchelf")
print("=" * 60)
return False
def check_compiler():
"""检查并推荐编译器(Windows)
Returns:
tuple: (是否找到编译器, 编译器类型)
"""
if platform.system() != "Windows":
return True, "system"
# 检查 MinGW
mingw_found = False
try:
result = subprocess.run(
["gcc", "--version"],
capture_output=True,
timeout=5
)
if result.returncode == 0:
mingw_found = True
gcc_version = result.stdout.decode().split('\n')[0]
print(f" ✅ 找到 MinGW: {gcc_version}")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# 检查 MSVC
msvc_found = False
try:
result = subprocess.run(
["cl"],
capture_output=True,
timeout=5
)
# cl 命令存在就认为 MSVC 可用(即使返回错误也是因为没有参数)
msvc_found = True
print(" ✅ 找到 MSVC (Visual Studio)")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
if mingw_found:
return True, "mingw"
elif msvc_found:
return True, "msvc"
else:
print("\n" + "=" * 60)
print("ℹ️ 未检测到系统已安装的 C 编译器")
print("=" * 60)
print("🎯 好消息:Nuitka 会在首次编译时自动下载 MinGW!")
print("\n构建过程中会:")
print(" 1. 自动下载 MinGW-w64 编译器(约 100MB)")
print(" 2. 缓存到 Nuitka 数据目录,后续编译无需重复下载")
print(" 3. 自动配置编译环境")
print("\n如果您想手动安装编译器(可选):")
print(" • MinGW: https://winlibs.com/")
print(" • MSVC: https://visualstudio.microsoft.com/downloads/")
print("=" * 60)
print("\n✅ 继续构建,Nuitka 将自动处理编译器下载...\n")
return True, "nuitka-auto" # Nuitka 会自动下载
def get_nuitka_cmd(mode="release", enable_upx=False, upx_path=None, jobs=2, flet_client_path=None):
"""获取 Nuitka 构建命令
Args:
mode: 构建模式 ('release' 或 'dev')
enable_upx: 是否启用 UPX 压缩
upx_path: UPX 工具路径(可选)
jobs: 并行编译进程数(默认 2)
"""
dist_dir = get_dist_dir(mode)
system = platform.system()
print(f"🖥️ 检测到操作系统: {system}")
print(f"📦 构建模式: {mode.upper()}")
print(f"📂 输出目录: {dist_dir}")
print(f"⚙️ 并行任务数: {jobs}")
# Windows 上检查编译器
if system == "Windows":
compiler_found, compiler_type = check_compiler()
# Nuitka 会自动下载编译器,所以总是返回 True
if compiler_type == "mingw":
print(" 🔧 使用编译器: MinGW (GCC)")
elif compiler_type == "msvc":
print(" 🔧 使用编译器: MSVC (Visual Studio)")
elif compiler_type == "nuitka-auto":
print(" 🔧 使用编译器: Nuitka 自动下载的 MinGW")
# 基础命令
# 优先使用 uv run 来执行 nuitka,确保环境正确
try:
subprocess.check_call(["uv", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# uv 可用,使用 uv run
executable_cmd = ["uv", "run", "python"]
except (FileNotFoundError, subprocess.CalledProcessError):
# uv 不可用,回退到当前 python
executable_cmd = [sys.executable]
cmd = executable_cmd + [
"-m", "nuitka",
"--standalone",
f"--output-dir={dist_dir}",
"--assume-yes-for-downloads",
"--follow-imports",
# 资源控制 - 防止系统卡死
f"--jobs={jobs}", # 并行编译进程数
# 显式包含 Flet 相关包(避免被 Nuitka 忽略)
"--include-package=flet",
"--include-package=flet_desktop",
"--include-package=flet.controls",
# 数据文件
f"--include-data-dir={ASSETS_DIR}=src/assets",
]
# 包含 Flet 客户端到输出目录的 flet_client/ 子目录
# 运行时 patch.py 会设置 FLET_VIEW_PATH 指向该目录,flet_desktop 据此找到客户端
if flet_client_path and flet_client_path.exists():
print(f" 🔧 包含 Flet 客户端到 flet_client/: {flet_client_path.name}")
for flet_file in flet_client_path.rglob('*'):
if flet_file.is_file():
rel_path = flet_file.relative_to(flet_client_path)
cmd.append(f"--include-data-files={flet_file}=flet_client/{rel_path}")
print(" ✅ Flet 客户端已添加到 flet_client/ 目录")
else:
print(" ⚠️ 未找到 Flet 客户端,flet_desktop 将从网络下载")
# 根据模式设置优化参数
if mode == "release":
# Release 模式:完整优化
cmd.extend([
"--python-flag=-O",
"--python-flag=no_site",
"--python-flag=no_warnings",
])
print(" 优化级别: 完整优化")
else: # dev 模式
# Dev 模式:保留调试信息,快速编译
cmd.extend([
"--python-flag=no_site",
])
print(" 优化级别: 调试模式")
# Tkinter 插件 - 用于快捷功能的区域选择
if sys.platform == "win32":
cmd.append("--enable-plugin=tk-inter")
print(" Tkinter 插件: 已启用(用于快捷功能区域选择)")
# UPX 压缩插件
if enable_upx:
upx_available, upx_cmd = check_upx(upx_path)
if upx_available:
cmd.append("--enable-plugin=upx")
# 禁用 onefile 内置压缩,避免与 UPX 双重压缩
# 参考: https://nuitka.net/doc/user-manual.html#upx-binary-compression
cmd.append("--onefile-no-compression")
if upx_path:
cmd.append(f"--upx-binary={upx_cmd}")
print(" UPX 压缩: 已启用(已禁用 onefile 内置压缩以避免双重压缩)")
else:
print(" UPX 压缩: 跳过(UPX 不可用)")
else:
print(" UPX 压缩: 未启用")
# 排除不需要的包以减小体积
# 注意:tkinter 已被原生方案替代(ctypes/PyObjC),可安全排除
excluded_packages = [
"unittest", "test", "pytest",
"setuptools", "distutils", "wheel", "pip",
"IPython", "matplotlib", "pdb"
]
for pkg in excluded_packages:
cmd.append(f"--nofollow-import-to={pkg}")
# macOS 特殊处理:解决 sherpa-onnx 与 onnxruntime 库冲突问题
if system == "Darwin":
print(" 🔧 macOS 特殊处理: 排除 sherpa-onnx 的嵌入式库文件")
# 在 macOS 上,sherpa-onnx 包含的 _sherpa_onnx.cpython-311-darwin.so
# 会尝试加载其 lib 目录中的 dylib 文件,导致 Nuitka 打包时出错
# 解决方案:让 Nuitka 不复制 sherpa_onnx/lib 目录
cmd.append("--nofollow-import-to=sherpa_onnx.lib")
# 检查 CUDA FULL 版本,包含 nvidia DLL
cuda_variant = os.environ.get('CUDA_VARIANT', 'none').lower()
if cuda_variant == 'cuda_full':
print(" 🎯 检测到 CUDA FULL 变体,正在包含 NVIDIA 库...")
# 定义需要包含的 NVIDIA CUDA 包列表(对应 pip 包名)
# 这些包安装后会在 site-packages/nvidia/ 目录下创建子目录
nvidia_cuda_packages = [
'nvidia-cublas-cu12',
'nvidia-cuda-nvrtc-cu12',
'nvidia-cuda-runtime-cu12',
'nvidia-cudnn-cu12',
'nvidia-cufft-cu12',
'nvidia-curand-cu12',
'nvidia-nvjitlink-cu12',
]
# 根据平台确定库文件扩展名
system = platform.system()
if system == "Windows":
lib_pattern = "*.dll"
lib_type = "DLL"
elif system == "Linux":
lib_pattern = "*.so*" # 匹配 .so 和 .so.12 等
lib_type = "SO"
elif system == "Darwin":
lib_pattern = "*.dylib"
lib_type = "DYLIB"
else:
print(f" ⚠️ 不支持的平台: {system}")
lib_pattern = None
lib_type = "LIB"
try:
import site
site_packages = site.getsitepackages()
nvidia_found = False
total_packages = 0
total_libs = 0
for site_pkg in site_packages:
nvidia_dir = Path(site_pkg) / "nvidia"
if nvidia_dir.exists():
print(f" ✅ 找到 NVIDIA 库: {nvidia_dir}")
print(f" 📦 包含 NVIDIA CUDA 包:")
# 遍历每个 NVIDIA 包
for pip_pkg_name in nvidia_cuda_packages:
# pip 包名转换为目录名:nvidia-cublas-cu12 -> cublas
# 规则:去掉 nvidia- 前缀和 -cu12 后缀
dir_name = pip_pkg_name.replace('nvidia-', '').replace('-cu12', '').replace('-', '_')
pkg_dir = nvidia_dir / dir_name
if pkg_dir.exists():
# 包含 bin 目录下的所有库文件(Windows: DLL, Linux: SO, macOS: DYLIB)
bin_dir = pkg_dir / "bin" if system == "Windows" else pkg_dir / "lib"
lib_count = 0
# 如果 bin 目录不存在,尝试 lib 目录(跨平台兼容)
if not bin_dir.exists():
alt_dir = pkg_dir / "lib" if system == "Windows" else pkg_dir / "bin"
if alt_dir.exists():
bin_dir = alt_dir