-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPMCL.py
More file actions
5594 lines (4559 loc) · 256 KB
/
PMCL.py
File metadata and controls
5594 lines (4559 loc) · 256 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
import minecraft_launcher_lib
import subprocess
import sys
import os
import psutil
import platform
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog
from PIL import Image, ImageTk
import threading
import urllib.error
import urllib.request
import urllib.parse
import json
import uuid
import time
class MinecraftLauncherGUI:
def __init__(self, root):
try:
# 创建窗口
self.root = root
self.root.title("Python Minecraft Launcher")
# 记录日志
self.start_time = int(time.time())
if not os.path.exists("logs"):
os.makedirs("logs")
with open(f"logs/log_{self.start_time}.log", "a", encoding="utf-8") as f:
print("""
*********************************************************
*欢迎使用Python Minecraft Launcher! *
*以下是版权声明: *
*Copyright © 2025 Dilideguazi. All rights reserved. *
*未经许可禁止转载 *
*本软件遵循GPLv3开源协议,请严格遵守协议使用 *
*********************************************************
""")
print(f"[{time.asctime()}] 程序开始运行")
f.write(f"[{time.asctime()}] 程序开始运行\n")
# 使用 PIL 打开PNG图片
image = Image.open(self.resource_path('PMCL.png'))
self.photo = ImageTk.PhotoImage(image)
# 设置窗口图标
self.root.iconphoto(True, self.photo)
# 绑定退出事件
self.root.protocol("WM_DELETE_WINDOW", self._exit)
# Minecraft目录
# self.minecraft_directory = minecraft_launcher_lib.utils.get_minecraft_directory()
self.minecraft_directory = f'{os.path.abspath("")}/.minecraft'
# 初始化设置
self.use_custom_java = True
self.use_java = False
self.java_path = None
self.skin_path = None
self.memory = None
# 启动器配置文件
if not os.path.exists(f'{self.minecraft_directory}'):
os.makedirs(self.minecraft_directory)
if not os.path.exists(f'{self.minecraft_directory}/launcher_profiles.json'):
with open(f'{self.minecraft_directory}/launcher_profiles.json','w') as launcher_profiles:
launcher_profiles.write("""{
"profiles": {
"PMCL": {
"icon": "Grass",
"name": "PMCL",
"lastVersionId": "latest-release",
"type": "latest-release",
"lastUsed": "2025-08-27T08:09:00.0000Z"
}
},
"selectedProfile": "PMCL",
"clientToken": "23323323323323323323323323323333"
}""")
# 初始化版本列表
self.version_list = []
self.installed_versions = []
# 初始化版本隔离
self.isolation_var = tk.BooleanVar()
self.isolation_dir = ''
# 显示启动画面
if platform.system().lower() == 'windows':
self.root.overrideredirect(True)
else:
self.root.attributes('-type', 'dock')
self.start_image = ttk.Label(self.root, image=self.photo)
self.start_image.grid(row=0, column=0)
self.root.geometry(f"{image.width}x{image.height}+{int((self.root.winfo_screenwidth()-image.width)/2)}+{int((self.root.winfo_screenheight()-image.height)/2)}")
self.root.config(cursor='watch')
def create_gui():
"""创建GUI"""
# 创建界面
self.create_widgets()
# 创建菜单
self.create_menu()
# 获取版本列表
self.load_installed_versions()
# 尝试从配置文件加载设置
self.load_settings()
# 加载LittleSkin设置
self.load_littleskin_credentials()
# 检查更新
self.check_update(False)
# 取消显示启动画面
def cancel_show():
self.start_image.grid_remove()
if platform.system().lower() == 'windows':
self.root.overrideredirect(False)
else:
self.root.attributes('-type', 'normal')
self.root.geometry(f"800x700+{int((self.root.winfo_screenwidth()-800)/2)}+{int((self.root.winfo_screenheight()-700)/2)}")
self.root.config(cursor='arrow')
self.root.after(10, create_gui)
self.root.after(2000, cancel_show)
except Exception as e:
messagebox.showerror("错误", f"程序初始化失败: {e}")
self.log(f"程序初始化失败: {e}", "ERROR")
sys.exit(-1)
def resource_path(self, relative_path):
"""获取资源的绝对路径"""
try:
# Nuitka 打包后,__file__ 指向临时目录或可执行文件位置
base_path = os.path.dirname(os.path.abspath(__file__))
except:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def get_from_server(self, url):
"""从PMCL服务器获取文件"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as response:
data = response.read()
# 返回获取到的数据
return data
except Exception as e:
self.log(f"请求失败: {e}", "ERROR")
messagebox.showerror("错误", f"请求失败: {e}")
def check_update(self, from_menu):
"""检查更新"""
try:
# 获取最新版本
check_update = self.get_from_server('https://pmcldownloadserver.dpdns.org/latest_version.json').decode('utf-8')
current_version = '1.2.0.1'
have_later_version = False
# 获取更新日志
patch_notes = json.loads(check_update).get('patch_notes', '')
# 一级一级版本号比对
for i, version_name in enumerate(json.loads(check_update).get('latest_version', '0')):
if i % 2 == 0:
if int(version_name) > int(current_version[i]):
have_later_version = True
# 如果存在更新版本,下载它
if have_later_version:
version = json.loads(check_update).get('latest_version')
if platform.system().lower() == 'windows':
if messagebox.askyesno("提示", f"存在新版本:{version[:-2] if not int(version[6]) else version[:-2] + '-hotfix.' + version[-1]},更新内容:{patch_notes},是否更新?"):
# 创建一个顶层窗口来显示进度条
progress_window = tk.Toplevel(self.root)
progress_window.title("下载进度")
progress_window.geometry("300x100")
progress_window.resizable(False, False)
progress_window.transient(self.root)
progress_window.grab_set()
# 添加进度条
progress_label = ttk.Label(progress_window, text="正在下载最新版本...")
progress_label.pack(pady=5)
progress_info_label = ttk.Label(progress_window, text="")
progress_info_label.pack(pady=5)
progress_bar = ttk.Progressbar(progress_window, orient="horizontal", length=280, mode="determinate")
progress_bar.pack(pady=10)
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
}
req = urllib.request.Request('https://pmcldownloadserver.dpdns.org/PMCL.exe', headers=headers)
response = urllib.request.urlopen(req)
# 获取文件大小
total_size = int(response.info().get('Content-Length', '0'))
progress_bar["maximum"] = total_size
# 下载文件并更新进度条
def download_with_progress():
downloaded = 0
with open('update.exe', 'wb') as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
progress_info_label["text"] = f"{self.format_file_size(downloaded)}/{self.format_file_size(total_size)} {downloaded / total_size * 100:.1f}%"
progress_bar["value"] = downloaded
progress_window.update_idletasks()
# 下载完成后关闭进度窗口并继续更新过程
progress_window.destroy()
self.install_update()
# 在新线程中下载更新
download_update_thread = threading.Thread(target=download_with_progress)
download_update_thread.daemon = True
download_update_thread.start()
else:
messagebox.showinfo("提示", f"存在新版本:{version[:-2] if not int(version[6]) else version[:-2] + '-hotfix.' + version[-1]},更新内容:{patch_notes}")
elif from_menu:
messagebox.showinfo("提示", "已是最新版本")
except Exception as e:
self.log(f"检查或更新新版本失败:{e}", "ERROR")
messagebox.showerror("错误", f"检查或更新新版本失败:{e}")
def create_widgets(self):
# 主框架
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# 标题
title_label = ttk.Label(main_frame, text="你好,用户!——Python Minecraft Launcher", font=("宋体", 20))
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 20))
# 启动版本选择框架
launch_frame = ttk.LabelFrame(main_frame, text="启动版本", padding="10")
launch_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
# 启动版本选择
ttk.Label(launch_frame, text="选择要启动的版本:").grid(row=0, column=0, sticky=tk.W, pady=(0, 5))
self.launch_version_var = tk.StringVar()
self.launch_version_combobox = ttk.Combobox(launch_frame, textvariable=self.launch_version_var, state="readonly", width=35)
self.launch_version_combobox.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
self.version_settings_button = ttk.Button(launch_frame, text="设置", width=5, command=self.create_version_settings_widgets)
self.version_settings_button.grid(row=1, column=1, padx=(5, 0), pady=(0, 10))
# 登录选项框架
login_frame = ttk.LabelFrame(main_frame, text="登录选项", padding="10")
login_frame.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
self.offline_frame = ttk.Frame(login_frame)
self.offline_frame.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0,10))
# 登录方式选择
ttk.Label(login_frame, text="登录方式:").grid(row=0, column=0, sticky=tk.W, pady=(0, 5))
self.login_method_var = tk.StringVar(value="离线模式")
self.login_method_combobox = ttk.Combobox(login_frame, textvariable=self.login_method_var,
values=["离线模式", "LittleSkin"], state="readonly", width=35)
self.login_method_combobox.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
self.login_method_combobox.bind("<<ComboboxSelected>>", self.on_login_method_change)
# 用户名输入
ttk.Label(self.offline_frame, text="用户名:").grid(row=0, column=0, sticky=tk.W, pady=(10, 5))
self.username_var = tk.StringVar()
self.username_entry = ttk.Entry(self.offline_frame, textvariable=self.username_var, width=40)
self.username_entry.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
# LittleSkin登录相关控件(默认隐藏)
self.littleskin_frame = ttk.Frame(login_frame)
self.littleskin_frame.grid(row=5, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
self.littleskin_frame.grid_remove() # 默认隐藏
ttk.Label(self.littleskin_frame, text="LittleSkin邮箱:").grid(row=0, column=0, sticky=tk.W, pady=(5, 5))
self.littleskin_email_var = tk.StringVar()
self.littleskin_email_entry = ttk.Entry(self.littleskin_frame, textvariable=self.littleskin_email_var, width=40)
self.littleskin_email_entry.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
ttk.Label(self.littleskin_frame, text="LittleSkin密码:").grid(row=2, column=0, sticky=tk.W, pady=(5, 5))
self.littleskin_password_var = tk.StringVar()
self.littleskin_password_entry = ttk.Entry(self.littleskin_frame, textvariable=self.littleskin_password_var,
show="*", width=40)
self.littleskin_password_entry.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
# 启动按钮
self.launch_button = ttk.Button(main_frame, text="启动Minecraft", command=self.launch_minecraft)
self.launch_button.grid(row=6, column=0, columnspan=2, pady=(0, 10))
# 日志显示区域
log_frame = ttk.LabelFrame(main_frame, text="日志", padding="10")
log_frame.grid(row=7, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10))
self.log_text = scrolledtext.ScrolledText(log_frame, height=10, state=tk.DISABLED)
self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# 配置网格权重
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
main_frame.rowconfigure(7, weight=1)
launch_frame.columnconfigure(0, weight=1)
login_frame.columnconfigure(1, weight=1)
self.offline_frame.columnconfigure(0, weight=1)
self.littleskin_frame.columnconfigure(0, weight=1)
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
def log(self, message, level):
"""在日志区域显示消息"""
print(f"[{time.asctime()}] [root/{level}] {message}")
with open(f"logs/log_{self.start_time}.log", "a", encoding="utf-8") as f:
f.write(f"[{time.asctime()}] [root/{level}] {message}\n")
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, message + "\n")
self.log_text.config(state=tk.DISABLED)
self.log_text.see(tk.END)
def on_login_method_change(self, event=None):
"""当登录方式改变时"""
if self.login_method_var.get() == "LittleSkin":
self.littleskin_frame.grid()
# 如果之前保存过LittleSkin信息,则填充
self.load_littleskin_credentials()
self.offline_frame.grid_remove()
else:
self.littleskin_frame.grid_remove()
self.offline_frame.grid()
def load_littleskin_credentials(self):
"""从配置文件加载LittleSkin凭证"""
try:
settings_file = f"./pmcl_settings.json"
if os.path.exists(settings_file):
with open(settings_file, "r") as f:
settings = json.load(f)
self.littleskin_email_var.set(settings.get("littleskin_email", ""))
except Exception as e:
self.log(f"加载LittleSkin凭证失败: {str(e)}", "ERROR")
def save_littleskin_credentials(self):
"""保存LittleSkin凭证到配置文件"""
try:
settings_file = f"./pmcl_settings.json"
# 先加载现有设置
settings = {}
if os.path.exists(settings_file):
with open(settings_file, "r") as f:
settings = json.load(f)
# 更新LittleSkin设置
settings["littleskin_email"] = self.littleskin_email_var.get()
# 保存设置
with open(settings_file, "w") as f:
json.dump(settings, f, indent=2)
except Exception as e:
self.log(f"保存LittleSkin凭证失败: {str(e)}", "ERROR")
def authenticate_with_littleskin(self, email, password):
"""使用LittleSkin进行认证"""
try:
self.log("正在连接到LittleSkin服务器...", "INFO")
# LittleSkin Yggdrasil API端点
auth_url = "https://littleskin.cn/api/yggdrasil/authserver/authenticate"
# 构造认证请求数据
client_token_str = uuid.uuid4().hex
auth_data = {
"agent": {
"name": "Minecraft",
"version": 1
},
"username": email,
"password": password,
"clientToken": client_token_str,
"requestUser": True
}
# 将数据转换为JSON并编码
data = json.dumps(auth_data).encode('utf-8')
# 创建请求
req = urllib.request.Request(
auth_url,
data=data,
headers={
'Content-Type': 'application/json',
'User-Agent': 'PMCL/1.2 (Python Minecraft Launcher)'
}
)
# 发送请求并获取响应
response = urllib.request.urlopen(req)
response_data = json.loads(response.read().decode('utf-8'))
# 检查是否有错误
if "error" in response_data:
error_message = response_data.get("errorMessage", "未知错误")
self.log(f"LittleSkin认证失败: {error_message}", "ERROR")
messagebox.showerror("认证失败", f"LittleSkin认证失败: {error_message}")
return None
# 提取认证信息
access_token = response_data.get("accessToken", "")
client_token = response_data.get("clientToken", "")
# 获取可用的角色列表
available_profiles = response_data.get("availableProfiles", [])
# 如果有多个角色,让用户选择
if len(available_profiles) > 1:
selected_profile = self.select_littleskin_profile(available_profiles)
if not selected_profile:
self.log("用户取消了角色选择", "WARN")
return None
else:
# 如果只有一个角色或没有角色,使用默认选择
selected_profile = response_data.get("selectedProfile", {})
if not selected_profile and available_profiles:
selected_profile = available_profiles[0]
# 获取用户信息
username = selected_profile.get("name", email.split("@")[0])
user_uuid = selected_profile.get("id", "")
self.log(f"LittleSkin认证成功: {username}", "INFO")
return {
"username": username,
"uuid": user_uuid,
"access_token": access_token,
"client_token": client_token
}
except urllib.error.HTTPError as e:
if e.code == 403:
self.log(f"LittleSkin认证HTTP错误: 用户名或密码错误", "WARN")
messagebox.showwarning("警告", f"LittleSkin认证失败: 用户名或密码错误")
else:
self.log(f"LittleSkin认证HTTP错误: {e.code} - {e.reason}", "ERROR")
messagebox.showerror("认证失败", f"LittleSkin认证失败: HTTP {e.code} - {e.reason}")
return None
except Exception as e:
self.log(f"LittleSkin认证失败: {str(e)}", "ERROR")
messagebox.showerror("认证失败", f"LittleSkin认证失败: {str(e)}")
return None
def select_littleskin_profile(self, profiles):
"""选择LittleSkin角色"""
if not profiles:
return None
# 创建选择对话框
dialog = tk.Toplevel(self.root)
dialog.title("选择角色")
dialog.transient(self.root)
dialog.grab_set()
# 居中显示
dialog.update_idletasks()
dialog.geometry(f"500x330+{(dialog.winfo_screenwidth() // 2) - (500 // 2)}+{(dialog.winfo_screenheight() // 2) - (330 // 2)}")
# 标签
tk.Label(dialog, text="请选择一个角色:").pack(pady=10)
# 列表框
listbox = tk.Listbox(dialog, selectmode=tk.SINGLE)
for profile in profiles:
listbox.insert(tk.END, profile.get("name", "未知角色"))
listbox.pack(fill=tk.BOTH, expand=True, padx=20, pady=10)
# 选择的值
selected_profile = [None]
def on_select():
selection = listbox.curselection()
if selection:
index = selection[0]
selected_profile[0] = profiles[index]
dialog.destroy()
def on_cancel():
selected_profile[0] = None
dialog.destroy()
# 按钮
button_frame = tk.Frame(dialog)
button_frame.pack(pady=10)
tk.Button(button_frame, text="确定", command=on_select).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="取消", command=on_cancel).pack(side=tk.LEFT, padx=5)
# 等待对话框关闭
dialog.wait_window()
return selected_profile[0]
def browse_java_path(self):
"""浏览Java路径"""
java_path = filedialog.askopenfilename(
title="选择Java可执行文件",
filetypes=[("Java Executable", ("java.exe", "javaw.exe")), ("Executable", "*.exe"), ("All Files", "*.*")]
)
if java_path:
self.java_path_var.set(java_path)
self.java_path = java_path
def browse_skin_path(self):
"""浏览皮肤路径"""
skin_path = filedialog.askopenfilename(
title="选择皮肤文件",
filetypes=[("Skin Files", "*.png"), ("All Files", "*.*")]
)
if skin_path:
self.skin_path_var.set(skin_path)
self.skin_path = skin_path
def load_installed_versions(self):
"""加载已安装的版本列表"""
try:
# 获取已安装的版本
if os.path.exists(f"{self.minecraft_directory}/versions"):
self.installed_versions = os.listdir(f"{self.minecraft_directory}/versions")
# 更新启动版本下拉列表
self.launch_version_combobox['values'] = self.installed_versions
# 设置默认选中版本
if self.installed_versions:
self.launch_version_var.set(self.installed_versions[0])
except Exception as e:
self.log(f"加载已安装版本列表失败: {str(e)}", "ERROR")
def create_version_settings_widgets(self):
"""创建版本设置窗口的界面"""
version = self.launch_version_var.get()
if not version:
messagebox.showwarning("警告", "请先选择一个版本")
return
self.version_settings_window = tk.Toplevel(self.root)
self.version_settings_window.title(f"{version} 设置")
self.version_settings_window.geometry(f"300x430+{int((self.root.winfo_screenwidth()-300)/2)}+{int((self.root.winfo_screenheight()-430)/2)}")
self.version_settings_window.grab_set()
self.version_settings_window.resizable(False, False)
vmain_frame = ttk.Frame(self.version_settings_window, padding="10")
vmain_frame.pack(fill=tk.BOTH, expand=True)
# 标题
title_label = ttk.Label(vmain_frame, text=f"{version} 设置", font=("微软雅黑", 18))
title_label.pack(pady=(0, 10))
# 按钮框架
button_frame = ttk.Frame(vmain_frame)
button_frame.pack(fill=tk.BOTH, expand=True)
# 跳转到版本文件夹按钮
version_folder_button = ttk.Button(button_frame, text="打开版本文件夹", command=lambda: self.open_folder(f"{self.minecraft_directory}/versions/{version}"))
version_folder_button.pack(fill=tk.X, pady=5)
# 跳转到存档文件夹按钮
saves_folder_button = ttk.Button(button_frame, text="打开存档文件夹", command=lambda: self.open_folder(f"{self.isolation_dir}/saves"))
saves_folder_button.pack(fill=tk.X, pady=5)
# 跳转到资源包文件夹按钮
resourcepack_folder_button = ttk.Button(button_frame, text="打开资源包文件夹", command=lambda: self.open_folder(f"{self.isolation_dir}/resourcepacks"))
resourcepack_folder_button.pack(fill=tk.X, pady=5)
# 跳转到模组文件夹按钮
mods_folder_button = ttk.Button(button_frame, text="打开模组文件夹", command=lambda: self.open_folder(f"{self.isolation_dir}/mods"))
mods_folder_button.pack(fill=tk.X, pady=5)
# 跳转到光影文件夹按钮
shaderpack_folder_button = ttk.Button(button_frame, text="打开光影包文件夹", command=lambda: self.open_folder(f"{self.isolation_dir}/shaderpacks"))
shaderpack_folder_button.pack(fill=tk.X, pady=5)
# 模组管理按钮
mod_manager_button = ttk.Button(button_frame, text="模组管理", command=lambda: self.open_mod_manager(version))
mod_manager_button.pack(fill=tk.X, pady=5)
# 版本隔离选项
isolation_checkbox = ttk.Checkbutton(button_frame, text="启用版本隔离", variable=self.isolation_var,
command=lambda: self.toggle_version_isolation(version))
isolation_checkbox.pack(fill=tk.X, pady=5)
# 重命名版本按钮
rename_version_button = ttk.Button(button_frame, text="重命名版本", command=lambda: self.rename_version(version, None))
rename_version_button.pack(fill=tk.X, pady=5)
# 删除版本按钮
delete_version_button = ttk.Button(button_frame, text="删除此版本", command=lambda: self.delete_version(version))
delete_version_button.pack(fill=tk.X, pady=5)
# 初始化版本隔离复选框状态
self.init_isolation_state(version)
def open_folder(self, folder_path):
"""打开指定文件夹"""
try:
def _open():
if platform.system().lower() == 'windows':
os.startfile(folder_path)
else:
os.system(f'open {folder_path}')
if os.path.exists(folder_path):
_open()
else:
# 如果文件夹不存在,创建它
os.makedirs(folder_path)
_open()
except Exception as e:
self.log(f"无法打开或创建文件夹: {str(e)}", "ERROR")
messagebox.showerror("错误", f"无法打开或创建文件夹: {str(e)}")
def open_mod_manager(self, version):
"""打开模组管理窗口"""
self.init_isolation_state(version)
mods_dir = f"{self.isolation_dir}/mods"
# 如果模组文件夹不存在,创建它
if not os.path.exists(mods_dir):
os.makedirs(mods_dir)
# 创建模组管理窗口
self.mod_manager_window = tk.Toplevel(self.root)
self.mod_manager_window.title(f"{version} 模组管理")
self.mod_manager_window.geometry(f"800x550+{int((self.root.winfo_screenwidth()-800)/2)}+{int((self.root.winfo_screenheight()-550)/2)}")
self.mod_manager_window.grab_set()
self.mod_manager_window.resizable(False, False)
# 绑定关闭事件
def on_exit():
self.version_settings_window.destroy()
self.mod_manager_window.destroy()
self.mod_manager_window.protocol("WM_DELETE_WINDOW", on_exit)
# 模组管理窗口主框架
mod_manager_main_frame = ttk.Frame(self.mod_manager_window, padding="10")
mod_manager_main_frame.pack(fill=tk.BOTH, expand=True)
# 标题
title_label = ttk.Label(mod_manager_main_frame, text=f"{version} 模组管理", font=("微软雅黑", 18))
title_label.pack(pady=(0, 10))
# 搜索框架
search_frame = ttk.Frame(mod_manager_main_frame)
search_frame.pack(fill=tk.X, pady=(0, 10))
# 搜索输入框
ttk.Label(search_frame, text="搜索模组:").pack(side=tk.LEFT)
self.mod_search_var = tk.StringVar()
self.mod_search_entry = ttk.Entry(search_frame, textvariable=self.mod_search_var, width=30)
self.mod_search_entry.pack(side=tk.LEFT, padx=(5, 0))
# 搜索按钮
search_button = ttk.Button(search_frame, text="搜索", command=self.search_mods_in_manager)
search_button.pack(side=tk.LEFT, padx=(5, 0))
# 清除按钮
clear_button = ttk.Button(search_frame, text="清除", command=self.clear_mod_search)
clear_button.pack(side=tk.LEFT, padx=(5, 0))
# 模组列表框架
mod_list_frame = ttk.LabelFrame(mod_manager_main_frame, text="模组列表", padding="10")
mod_list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 创建Treeview来显示模组列表
columns = ('name', 'status', 'size')
self.mods_tree = ttk.Treeview(mod_list_frame, columns=columns, show='headings', height=15, selectmode='extended')
# 定义列标题
self.mods_tree.heading('name', text='模组名称')
self.mods_tree.heading('status', text='状态')
self.mods_tree.heading('size', text='大小')
# 设置列宽
self.mods_tree.column('name', width=400)
self.mods_tree.column('status', width=100)
self.mods_tree.column('size', width=100)
# 添加滚动条
mods_scrollbar_y = ttk.Scrollbar(mod_list_frame, orient=tk.VERTICAL, command=self.mods_tree.yview)
mods_scrollbar_x = ttk.Scrollbar(mod_list_frame, orient=tk.HORIZONTAL, command=self.mods_tree.xview)
self.mods_tree.configure(yscrollcommand=mods_scrollbar_y.set, xscrollcommand=mods_scrollbar_x.set)
# 布局
self.mods_tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
mods_scrollbar_y.grid(row=0, column=1, sticky=(tk.N, tk.S))
mods_scrollbar_x.grid(row=1, column=0, sticky=(tk.W, tk.E))
# 按钮框架
button_frame = ttk.Frame(mod_manager_main_frame)
button_frame.pack(fill=tk.X, pady=(0, 10))
# 全选/取消全选按钮
self.select_all_button = ttk.Button(button_frame, text="全选", command=self.toggle_select_all)
self.select_all_button.pack(side=tk.LEFT, padx=(0, 5))
# 删除模组按钮
delete_mod_button = ttk.Button(button_frame, text="删除模组", command=self.delete_selected_mod, state=tk.DISABLED)
delete_mod_button.pack(side=tk.LEFT, padx=(0, 5))
# 禁用/启用模组按钮
self.toggle_mod_button = ttk.Button(button_frame, text="禁用模组", command=self.toggle_selected_mod, state=tk.DISABLED)
self.toggle_mod_button.pack(side=tk.LEFT, padx=(0, 5))
# 提示
tips = tk.Label(button_frame, text="提示:按住Ctrl或Shift可多选模组!")
tips.pack(side=tk.LEFT, padx=(0, 5))
# 刷新按钮
refresh_button = ttk.Button(button_frame, text="刷新", command=lambda: self.load_mods_list(mods_dir))
refresh_button.pack(side=tk.RIGHT)
# 配置网格权重
mod_manager_main_frame.columnconfigure(0, weight=1)
mod_manager_main_frame.rowconfigure(1, weight=1)
mod_list_frame.columnconfigure(0, weight=1)
mod_list_frame.rowconfigure(0, weight=1)
# 绑定Treeview选择事件
self.mods_tree.bind('<<TreeviewSelect>>', self.on_mod_in_manager_select)
# 绑定搜索框回车事件
self.mod_search_entry.bind('<Return>', lambda event: self.search_mods_in_manager())
# 加载模组列表
self.load_mods_list(mods_dir)
def load_mods_list(self, mods_dir):
"""加载模组列表"""
# 清空现有数据
for item in self.mods_tree.get_children():
self.mods_tree.delete(item)
# 获取模组文件
if os.path.exists(mods_dir):
for file in os.listdir(mods_dir):
file_path = os.path.join(mods_dir, file)
if os.path.isfile(file_path) and (file.endswith('.jar') or file.endswith('.disabled')):
# 获取文件大小
size = os.path.getsize(file_path)
size_str = self.format_file_size(size)
# 获取模组状态
status = "已禁用" if file.endswith('.disabled') else "已启用"
# 添加到Treeview
self.mods_tree.insert('', tk.END, values=(file, status, size_str), tags=(file_path,))
def search_mods_in_manager(self):
"""在模组管理器中搜索模组"""
search_term = self.mod_search_var.get().lower()
if not search_term:
return
# 获取当前版本的模组目录
version = self.launch_version_var.get()
self.init_isolation_state(version)
mods_dir = f"{self.isolation_dir}/mods"
# 清空现有数据
for item in self.mods_tree.get_children():
self.mods_tree.delete(item)
# 获取模组文件并过滤
if os.path.exists(mods_dir):
for file in os.listdir(mods_dir):
file_path = os.path.join(mods_dir, file)
if os.path.isfile(file_path) and (file.endswith('.jar') or file.endswith('.disabled')):
# 检查是否匹配搜索词
if search_term in file.lower():
# 获取文件大小
size = os.path.getsize(file_path)
size_str = self.format_file_size(size)
# 获取模组状态
status = "已禁用" if file.endswith('.disabled') else "已启用"
# 添加到Treeview
self.mods_tree.insert('', tk.END, values=(file, status, size_str), tags=(file_path,))
def toggle_select_all(self):
"""全选/取消全选模组"""
current_text = self.select_all_button.cget('text')
if current_text == "全选":
# 选择所有项
children = self.mods_tree.get_children()
self.mods_tree.selection_set(children)
self.select_all_button.config(text="取消全选")
# 启用批量操作按钮
for child in self.mod_manager_window.winfo_children():
if isinstance(child, ttk.Frame):
for subchild in child.winfo_children():
if isinstance(subchild, ttk.Frame):
for button in subchild.winfo_children():
if isinstance(button, ttk.Button) and button.cget('text') in ['批量删除', '批量禁用']:
button.config(state=tk.NORMAL)
else:
# 取消选择所有项
self.mods_tree.selection_remove(self.mods_tree.selection())
self.select_all_button.config(text="全选")
# 禁用批量操作按钮
for child in self.mod_manager_window.winfo_children():
if isinstance(child, ttk.Frame):
for subchild in child.winfo_children():
if isinstance(subchild, ttk.Frame):
for button in subchild.winfo_children():
if isinstance(button, ttk.Button) and button.cget('text') in ['批量删除', '批量禁用', '批量启用']:
button.config(state=tk.DISABLED)
# 禁用单个操作按钮
for child in self.mod_manager_window.winfo_children():
if isinstance(child, ttk.Frame):
for subchild in child.winfo_children():
if isinstance(subchild, ttk.Frame):
for button in subchild.winfo_children():
if isinstance(button, ttk.Button) and button.cget('text') in ['删除模组', '禁用模组', '启用模组']:
button.config(state=tk.DISABLED)
def batch_delete_mods(self):
"""批量删除模组"""
selection = self.mods_tree.selection()
if not selection:
messagebox.showwarning("警告", "请先选择要删除的模组")
return
# 确认删除
if messagebox.askyesno("确认删除", f"确定要删除选中的 {len(selection)} 个模组吗?此操作不可撤销。"):
deleted_count = 0
error_count = 0
# 删除每个选中的模组
for item_id in selection:
item = self.mods_tree.item(item_id)
values = item['values']
mod_name = values[0] if len(values) > 0 else '未知模组'
# 获取文件路径
tags = item['tags']
file_path = tags[0] if len(tags) > 0 else None
if file_path and os.path.exists(file_path):
try:
os.remove(file_path)
deleted_count += 1
except Exception as e:
error_count += 1
self.mod_manager_window.after(0, lambda: messagebox.showerror("错误", f"删除模组 {mod_name} 失败: {str(e)}"))
else:
error_count += 1
self.mod_manager_window.after(0, lambda: messagebox.showerror("错误", f"无法找到模组文件: {mod_name}"))
# 显示结果
if error_count == 0:
self.log(f"成功删除 {deleted_count} 个模组", "INFO")
messagebox.showinfo("成功", f"成功删除 {deleted_count} 个模组")
else:
self.log(f"删除完成: {deleted_count} 个成功, {error_count} 个失败", "WARN")
messagebox.showinfo("完成", f"删除完成: {deleted_count} 个成功, {error_count} 个失败")
# 重新加载模组列表
version = self.launch_version_var.get()
self.init_isolation_state(version)
mods_dir = f"{self.isolation_dir}/mods"
self.load_mods_list(mods_dir)
# 重置全选按钮
self.select_all_button.config(text="全选")
def batch_toggle_mods(self):
"""批量启用/禁用模组"""
selection = self.mods_tree.selection()
if not selection:
messagebox.showwarning("警告", "请先选择要操作的模组")
return
# 检查选中的模组状态,确定是启用还是禁用
enable_count = 0
disable_count = 0
for item_id in selection:
item = self.mods_tree.item(item_id)
values = item['values']
status = values[1] if len(values) > 1 else '未知状态'
if status == "已启用":
disable_count += 1
else:
enable_count += 1
# 确定操作类型(以多数为准)
operation = "禁用" if disable_count >= enable_count else "启用"
# 确认操作
if messagebox.askyesno("确认操作", f"确定要{operation}选中的 {len(selection)} 个模组吗?"):
success_count = 0
error_count = 0
# 执行操作
for item_id in selection:
item = self.mods_tree.item(item_id)
values = item['values']
mod_name = values[0] if len(values) > 0 else '未知模组'
status = values[1] if len(values) > 1 else '未知状态'
# 获取文件路径
tags = item['tags']
file_path = tags[0] if len(tags) > 0 else None
if file_path and os.path.exists(file_path):
try:
# 获取文件目录和文件名
dir_name = os.path.dirname(file_path)
file_name = os.path.basename(file_path)
if operation == "禁用" and status == "已启用":
# 禁用模组(添加.disabled后缀)
new_file_path = file_path + ".disabled"
os.rename(file_path, new_file_path)
success_count += 1
elif operation == "启用" and status == "已禁用":
# 启用模组(移除.disabled后缀)
if file_name.endswith('.disabled'):
new_file_path = os.path.join(dir_name, file_name[:-9]) # 移除.disabled后缀
os.rename(file_path, new_file_path)
success_count += 1
else:
# 状态已经正确,不需要操作
success_count += 1
except Exception as e:
error_count += 1
self.mod_manager_window.after(0, lambda: messagebox.showerror("错误", f"{operation}模组 {mod_name} 失败: {str(e)}"))
else:
error_count += 1
self.mod_manager_window.after(0, lambda: messagebox.showerror("错误", f"无法找到模组文件: {mod_name}"))
# 显示结果
if error_count == 0:
self.log(f"成功: {operation} {success_count} 个模组", "INFO")
messagebox.showinfo("成功", f"成功{operation} {success_count} 个模组")
else:
self.log(f"{operation} 完成: {success_count} 个成功, {error_count} 个失败", "WARN")
messagebox.showinfo("完成", f"{operation}完成: {success_count} 个成功, {error_count} 个失败")
# 重新加载模组列表
version = self.launch_version_var.get()
self.init_isolation_state(version)
mods_dir = f"{self.isolation_dir}/mods"
self.load_mods_list(mods_dir)
# 重置全选按钮
self.select_all_button.config(text="全选")
def clear_mod_search(self):
"""清除模组搜索"""
self.mod_search_var.set("")
# 重新加载所有模组
version = self.launch_version_var.get()
self.init_isolation_state(version)
mods_dir = f"{self.isolation_dir}/mods"
self.load_mods_list(mods_dir)
def format_file_size(self, size):