538 lines
22 KiB
Python
538 lines
22 KiB
Python
import base64
|
|
import datetime as dt
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
import urllib.parse
|
|
import urllib.request
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from tkinter import BOTH, END, LEFT, RIGHT, X, Y, BooleanVar, StringVar, Tk, filedialog, messagebox
|
|
from tkinter import ttk
|
|
|
|
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
TOOLS_DIR = APP_DIR.parent
|
|
if str(TOOLS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(TOOLS_DIR))
|
|
|
|
from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
|
|
|
|
DATA_DIR = APP_DIR / "Data"
|
|
CONFIG_PATH = APP_DIR / "config.local.json"
|
|
OSS_PREFIX = "bugreport/"
|
|
|
|
|
|
def default_save_config_dir() -> str:
|
|
user_profile = os.environ.get("USERPROFILE") or str(Path.home())
|
|
return str(Path(user_profile) / "AppData" / "LocalLow" / "Remilia Command" / "Config")
|
|
|
|
|
|
def default_config() -> dict:
|
|
return {
|
|
"access_key_id": "",
|
|
"access_key_secret": "",
|
|
"endpoint": "oss-cn-shanghai.aliyuncs.com",
|
|
"bucket": "th1-oss",
|
|
"save_config_dir": default_save_config_dir(),
|
|
"skip_existing_downloads": True,
|
|
}
|
|
|
|
|
|
def load_config() -> dict:
|
|
cfg = merge_default_oss_config(default_config())
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
cfg = merge_local_oss_config(cfg, json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
pass
|
|
return cfg
|
|
|
|
|
|
def save_config(cfg: dict) -> None:
|
|
CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def human_bytes(value: int) -> str:
|
|
if value >= 1024 * 1024:
|
|
return f"{value / 1024 / 1024:.2f} MB"
|
|
if value >= 1024:
|
|
return f"{value / 1024:.1f} KB"
|
|
return f"{value} B"
|
|
|
|
|
|
def parse_oss_time(value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
try:
|
|
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
except Exception:
|
|
return value[:16]
|
|
|
|
|
|
class OssClient:
|
|
def __init__(self, access_key_id: str, access_key_secret: str, endpoint: str, bucket: str):
|
|
self.access_key_id = access_key_id.strip()
|
|
self.access_key_secret = access_key_secret.strip()
|
|
self.endpoint = endpoint.strip()
|
|
self.bucket = bucket.strip()
|
|
|
|
def _sign(self, verb: str, date: str, canonicalized_resource: str) -> str:
|
|
string_to_sign = f"{verb}\n\n\n{date}\n{canonicalized_resource}"
|
|
digest = hmac.new(
|
|
self.access_key_secret.encode("utf-8"),
|
|
string_to_sign.encode("utf-8"),
|
|
hashlib.sha1,
|
|
).digest()
|
|
return base64.b64encode(digest).decode("ascii")
|
|
|
|
def _request(self, verb: str, url: str, canonicalized_resource: str) -> bytes:
|
|
now = dt.datetime.now(dt.timezone.utc)
|
|
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
|
signature = self._sign(verb, date, canonicalized_resource)
|
|
request = urllib.request.Request(url, method=verb)
|
|
request.add_header("Date", date)
|
|
request.add_header("Authorization", f"OSS {self.access_key_id}:{signature}")
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
return response.read()
|
|
|
|
def list_objects(self, prefix: str) -> list[str]:
|
|
keys: list[str] = []
|
|
marker = ""
|
|
|
|
while True:
|
|
query = f"prefix={urllib.parse.quote(prefix)}&max-keys=1000"
|
|
canonicalized_resource = f"/{self.bucket}/"
|
|
if marker:
|
|
query += f"&marker={urllib.parse.quote(marker)}"
|
|
|
|
url = f"https://{self.bucket}.{self.endpoint}/?{query}"
|
|
xml_bytes = self._request("GET", url, canonicalized_resource)
|
|
root = ET.fromstring(xml_bytes)
|
|
ns = ""
|
|
if root.tag.startswith("{"):
|
|
ns = root.tag.split("}", 1)[0] + "}"
|
|
|
|
for content in root.findall(f"{ns}Contents"):
|
|
key = content.findtext(f"{ns}Key") or ""
|
|
if key and not key.endswith("/"):
|
|
keys.append(key)
|
|
|
|
is_truncated = (root.findtext(f"{ns}IsTruncated") or "").lower() == "true"
|
|
marker = root.findtext(f"{ns}NextMarker") or (keys[-1] if keys else "")
|
|
if not is_truncated or not marker:
|
|
break
|
|
|
|
return keys
|
|
|
|
def download_object(self, object_key: str, local_path: Path) -> None:
|
|
encoded_key = "/".join(urllib.parse.quote(part) for part in object_key.split("/"))
|
|
url = f"https://{self.bucket}.{self.endpoint}/{encoded_key}"
|
|
canonicalized_resource = f"/{self.bucket}/{object_key}"
|
|
data = self._request("GET", url, canonicalized_resource)
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_bytes(data)
|
|
|
|
|
|
@dataclass
|
|
class ReportEntry:
|
|
local_path: Path
|
|
object_key: str
|
|
report_id: str
|
|
version: str
|
|
steam_id: str
|
|
created_at_utc: str
|
|
description: str
|
|
archive_count: int
|
|
size: int
|
|
manifest: dict
|
|
|
|
|
|
def local_path_for_key(object_key: str) -> Path:
|
|
relative = object_key[len(OSS_PREFIX):] if object_key.startswith(OSS_PREFIX) else object_key
|
|
return DATA_DIR / Path(*relative.split("/"))
|
|
|
|
|
|
def read_report(local_path: Path) -> ReportEntry | None:
|
|
try:
|
|
with zipfile.ZipFile(local_path, "r") as zf:
|
|
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
|
description = manifest.get("description", "")
|
|
if not description and "description.txt" in zf.namelist():
|
|
description = zf.read("description.txt").decode("utf-8", errors="replace")
|
|
|
|
parts = local_path.relative_to(DATA_DIR).parts
|
|
version = manifest.get("version") or (parts[0] if len(parts) > 0 else "")
|
|
steam_id = manifest.get("steamId") or (parts[1] if len(parts) > 1 else "")
|
|
object_key = OSS_PREFIX + "/".join(parts).replace("\\", "/")
|
|
return ReportEntry(
|
|
local_path=local_path,
|
|
object_key=object_key,
|
|
report_id=manifest.get("reportId", local_path.stem),
|
|
version=version,
|
|
steam_id=steam_id,
|
|
created_at_utc=manifest.get("createdAtUtc", ""),
|
|
description=description,
|
|
archive_count=int(manifest.get("archiveCount", 0) or 0),
|
|
size=local_path.stat().st_size,
|
|
manifest=manifest,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_reports() -> list[ReportEntry]:
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
reports = [entry for entry in (read_report(path) for path in DATA_DIR.rglob("*.zip")) if entry]
|
|
reports.sort(key=lambda item: item.created_at_utc or str(item.local_path.stat().st_mtime), reverse=True)
|
|
return reports
|
|
|
|
|
|
def download_reports(cfg: dict) -> tuple[int, int, int]:
|
|
client = OssClient(
|
|
cfg["access_key_id"],
|
|
cfg["access_key_secret"],
|
|
cfg["endpoint"],
|
|
cfg["bucket"],
|
|
)
|
|
keys = [key for key in client.list_objects(OSS_PREFIX) if key.endswith(".zip")]
|
|
downloaded = 0
|
|
skipped = 0
|
|
failed = 0
|
|
|
|
for key in keys:
|
|
local_path = local_path_for_key(key)
|
|
if cfg.get("skip_existing_downloads", True) and local_path.exists():
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
client.download_object(key, local_path)
|
|
downloaded += 1
|
|
except Exception:
|
|
failed += 1
|
|
|
|
return downloaded, skipped, failed
|
|
|
|
|
|
def replace_local_saves(report: ReportEntry, save_config_dir: str) -> tuple[int, int]:
|
|
target_dir = Path(os.path.expandvars(save_config_dir)).expanduser().resolve()
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
deleted = 0
|
|
for pattern in ("map_archive_*.dat", "map_archive_*.dat.bak"):
|
|
for path in target_dir.glob(pattern):
|
|
if path.is_file():
|
|
path.unlink()
|
|
deleted += 1
|
|
|
|
copied = 0
|
|
archives = report.manifest.get("archives") or []
|
|
with zipfile.ZipFile(report.local_path, "r") as zf:
|
|
for archive in archives:
|
|
entry_name = archive.get("zipEntry")
|
|
source_file_name = Path(archive.get("sourceFileName") or "").name
|
|
if not entry_name or not source_file_name:
|
|
continue
|
|
destination = target_dir / source_file_name
|
|
with zf.open(entry_name, "r") as source, destination.open("wb") as target:
|
|
shutil.copyfileobj(source, target)
|
|
copied += 1
|
|
|
|
last_write = archive.get("lastWriteTimeUtc")
|
|
if last_write:
|
|
try:
|
|
timestamp = dt.datetime.fromisoformat(last_write.replace("Z", "+00:00")).timestamp()
|
|
os.utime(destination, (timestamp, timestamp))
|
|
except Exception:
|
|
pass
|
|
|
|
return deleted, copied
|
|
|
|
|
|
class PlayerBugViewer(Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("TH1 玩家 Bug 查看器")
|
|
self.geometry("1120x720")
|
|
self.minsize(900, 560)
|
|
|
|
self.cfg = load_config()
|
|
self.reports: list[ReportEntry] = []
|
|
self.filtered_reports: list[ReportEntry] = []
|
|
self.selected_report: ReportEntry | None = None
|
|
|
|
self.access_key_id = StringVar(value=self.cfg.get("access_key_id", ""))
|
|
self.access_key_secret = StringVar(value=self.cfg.get("access_key_secret", ""))
|
|
self.endpoint = StringVar(value=self.cfg.get("endpoint", "oss-cn-shanghai.aliyuncs.com"))
|
|
self.bucket = StringVar(value=self.cfg.get("bucket", "th1-oss"))
|
|
self.save_config_dir = StringVar(value=self.cfg.get("save_config_dir", default_save_config_dir()))
|
|
self.skip_existing = BooleanVar(value=bool(self.cfg.get("skip_existing_downloads", True)))
|
|
self.version_filter = StringVar(value="全部版本")
|
|
self.steam_filter = StringVar(value="")
|
|
self.status_text = StringVar(value="就绪")
|
|
|
|
self._build_ui()
|
|
self.refresh_local_reports()
|
|
|
|
def _build_ui(self) -> None:
|
|
config_frame = ttk.LabelFrame(self, text="OSS 与本地路径")
|
|
config_frame.pack(fill=X, padx=10, pady=(10, 6))
|
|
|
|
ttk.Label(config_frame, text="AccessKey ID").grid(row=0, column=0, sticky="w", padx=8, pady=4)
|
|
ttk.Entry(config_frame, textvariable=self.access_key_id, width=32).grid(row=0, column=1, sticky="ew", padx=4)
|
|
ttk.Label(config_frame, text="AccessKey Secret").grid(row=0, column=2, sticky="w", padx=8)
|
|
ttk.Entry(config_frame, textvariable=self.access_key_secret, show="*", width=32).grid(row=0, column=3, sticky="ew", padx=4)
|
|
|
|
ttk.Label(config_frame, text="Endpoint").grid(row=1, column=0, sticky="w", padx=8, pady=4)
|
|
ttk.Entry(config_frame, textvariable=self.endpoint, width=32).grid(row=1, column=1, sticky="ew", padx=4)
|
|
ttk.Label(config_frame, text="Bucket").grid(row=1, column=2, sticky="w", padx=8)
|
|
ttk.Entry(config_frame, textvariable=self.bucket, width=32).grid(row=1, column=3, sticky="ew", padx=4)
|
|
|
|
ttk.Label(config_frame, text="本地存档 Config").grid(row=2, column=0, sticky="w", padx=8, pady=4)
|
|
ttk.Entry(config_frame, textvariable=self.save_config_dir).grid(row=2, column=1, columnspan=2, sticky="ew", padx=4)
|
|
ttk.Button(config_frame, text="浏览", command=self.choose_save_dir).grid(row=2, column=3, sticky="w", padx=4)
|
|
ttk.Checkbutton(config_frame, text="跳过已下载 zip", variable=self.skip_existing).grid(row=3, column=1, sticky="w", padx=4)
|
|
ttk.Button(config_frame, text="保存配置", command=self.save_current_config).grid(row=3, column=2, sticky="e", padx=4, pady=4)
|
|
ttk.Button(config_frame, text="更新内容", command=self.update_from_oss).grid(row=3, column=3, sticky="w", padx=4, pady=4)
|
|
config_frame.columnconfigure(1, weight=1)
|
|
config_frame.columnconfigure(3, weight=1)
|
|
|
|
filter_frame = ttk.Frame(self)
|
|
filter_frame.pack(fill=X, padx=10, pady=(0, 6))
|
|
ttk.Label(filter_frame, text="版本").pack(side=LEFT)
|
|
self.version_combo = ttk.Combobox(filter_frame, textvariable=self.version_filter, state="readonly", width=18)
|
|
self.version_combo.pack(side=LEFT, padx=(4, 12))
|
|
self.version_combo.bind("<<ComboboxSelected>>", lambda _event: self.apply_filters())
|
|
ttk.Label(filter_frame, text="SteamID").pack(side=LEFT)
|
|
steam_entry = ttk.Entry(filter_frame, textvariable=self.steam_filter, width=26)
|
|
steam_entry.pack(side=LEFT, padx=4)
|
|
steam_entry.bind("<KeyRelease>", lambda _event: self.apply_filters())
|
|
ttk.Button(filter_frame, text="刷新本地", command=self.refresh_local_reports).pack(side=LEFT, padx=8)
|
|
ttk.Button(filter_frame, text="打开下载目录", command=self.open_data_dir).pack(side=LEFT)
|
|
|
|
pane = ttk.PanedWindow(self, orient="horizontal")
|
|
pane.pack(fill=BOTH, expand=True, padx=10, pady=(0, 6))
|
|
|
|
list_frame = ttk.Frame(pane)
|
|
self.tree = ttk.Treeview(
|
|
list_frame,
|
|
columns=("created", "version", "steam", "archives", "size"),
|
|
show="headings",
|
|
selectmode="browse",
|
|
)
|
|
for col, text, width in (
|
|
("created", "时间", 140),
|
|
("version", "版本", 90),
|
|
("steam", "SteamID", 150),
|
|
("archives", "存档", 60),
|
|
("size", "大小", 80),
|
|
):
|
|
self.tree.heading(col, text=text)
|
|
self.tree.column(col, width=width, anchor="w")
|
|
self.tree.pack(side=LEFT, fill=BOTH, expand=True)
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
|
|
scrollbar.pack(side=RIGHT, fill=Y)
|
|
self.tree.configure(yscrollcommand=scrollbar.set)
|
|
self.tree.bind("<<TreeviewSelect>>", self.on_select_report)
|
|
pane.add(list_frame, weight=3)
|
|
|
|
preview_frame = ttk.Frame(pane)
|
|
button_frame = ttk.Frame(preview_frame)
|
|
button_frame.pack(fill=X, pady=(0, 6))
|
|
ttk.Button(button_frame, text="一键替换到本地存档", command=self.restore_selected_report).pack(side=LEFT)
|
|
self.preview = ttk.Treeview(preview_frame, columns=("field", "value"), show="headings", height=7)
|
|
self.preview.heading("field", text="字段")
|
|
self.preview.heading("value", text="内容")
|
|
self.preview.column("field", width=100, anchor="w")
|
|
self.preview.column("value", width=440, anchor="w")
|
|
self.preview.pack(fill=X)
|
|
self.description_box = None
|
|
self._build_description_box(preview_frame)
|
|
pane.add(preview_frame, weight=4)
|
|
|
|
status = ttk.Label(self, textvariable=self.status_text, anchor="w")
|
|
status.pack(fill=X, padx=10, pady=(0, 8))
|
|
|
|
def _build_description_box(self, parent) -> None:
|
|
import tkinter as tk
|
|
|
|
desc_frame = ttk.LabelFrame(parent, text="玩家自述")
|
|
desc_frame.pack(fill=BOTH, expand=True, pady=(8, 0))
|
|
self.description_box = tk.Text(desc_frame, wrap="word", height=12)
|
|
self.description_box.pack(fill=BOTH, expand=True)
|
|
self.description_box.configure(state="disabled")
|
|
|
|
def current_config(self) -> dict:
|
|
return {
|
|
"access_key_id": self.access_key_id.get(),
|
|
"access_key_secret": self.access_key_secret.get(),
|
|
"endpoint": self.endpoint.get(),
|
|
"bucket": self.bucket.get(),
|
|
"save_config_dir": self.save_config_dir.get(),
|
|
"skip_existing_downloads": self.skip_existing.get(),
|
|
}
|
|
|
|
def choose_save_dir(self) -> None:
|
|
selected = filedialog.askdirectory(initialdir=self.save_config_dir.get() or str(Path.home()))
|
|
if selected:
|
|
self.save_config_dir.set(selected)
|
|
|
|
def save_current_config(self) -> None:
|
|
self.cfg = self.current_config()
|
|
save_config(self.cfg)
|
|
self.status_text.set(f"配置已保存到 {CONFIG_PATH}")
|
|
|
|
def update_from_oss(self) -> None:
|
|
cfg = self.current_config()
|
|
if not cfg["access_key_id"] or not cfg["access_key_secret"]:
|
|
messagebox.showerror("缺少配置", "请先填写 AccessKey ID 和 AccessKey Secret。")
|
|
return
|
|
|
|
self.save_current_config()
|
|
self.status_text.set("正在从 OSS 更新 bug 汇报...")
|
|
self._run_worker(lambda: download_reports(cfg), self._after_download)
|
|
|
|
def _after_download(self, result) -> None:
|
|
if isinstance(result, Exception):
|
|
messagebox.showerror("更新失败", str(result))
|
|
self.status_text.set(f"更新失败: {result}")
|
|
return
|
|
downloaded, skipped, failed = result
|
|
self.refresh_local_reports()
|
|
self.status_text.set(f"更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed};本地缓存 {len(self.reports)} 条")
|
|
|
|
def refresh_local_reports(self) -> None:
|
|
self.reports = load_reports()
|
|
versions = ["全部版本"] + sorted({entry.version for entry in self.reports if entry.version}, reverse=True)
|
|
self.version_combo.configure(values=versions)
|
|
if self.version_filter.get() not in versions:
|
|
self.version_filter.set("全部版本")
|
|
self.apply_filters()
|
|
|
|
def apply_filters(self) -> None:
|
|
version = self.version_filter.get()
|
|
steam = self.steam_filter.get().strip()
|
|
self.filtered_reports = []
|
|
for report in self.reports:
|
|
if version and version != "全部版本" and report.version != version:
|
|
continue
|
|
if steam and steam not in report.steam_id:
|
|
continue
|
|
self.filtered_reports.append(report)
|
|
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
for index, report in enumerate(self.filtered_reports):
|
|
self.tree.insert(
|
|
"",
|
|
END,
|
|
iid=str(index),
|
|
values=(
|
|
parse_oss_time(report.created_at_utc),
|
|
report.version,
|
|
report.steam_id,
|
|
report.archive_count,
|
|
human_bytes(report.size),
|
|
),
|
|
)
|
|
self.status_text.set(f"本地缓存 {len(self.reports)} 条,当前筛选 {len(self.filtered_reports)} 条")
|
|
|
|
def on_select_report(self, _event=None) -> None:
|
|
selection = self.tree.selection()
|
|
if not selection:
|
|
self.selected_report = None
|
|
return
|
|
index = int(selection[0])
|
|
if index < 0 or index >= len(self.filtered_reports):
|
|
return
|
|
self.selected_report = self.filtered_reports[index]
|
|
self.render_preview(self.selected_report)
|
|
|
|
def render_preview(self, report: ReportEntry) -> None:
|
|
for item in self.preview.get_children():
|
|
self.preview.delete(item)
|
|
|
|
rows = [
|
|
("ReportID", report.report_id),
|
|
("版本", report.version),
|
|
("SteamID", report.steam_id),
|
|
("时间(UTC)", parse_oss_time(report.created_at_utc)),
|
|
("时间(本地)", report.manifest.get("createdAtLocal", "")),
|
|
("时区", report.manifest.get("timezone", "")),
|
|
("CrashSight设备ID", report.manifest.get("crashSightDeviceId", "")),
|
|
("设备名", report.manifest.get("deviceName", "")),
|
|
("设备型号", report.manifest.get("deviceModel", "")),
|
|
("系统", report.manifest.get("operatingSystem", "")),
|
|
("CPU", report.manifest.get("processorType", "")),
|
|
("内存", f"{report.manifest.get('systemMemorySizeMb', '')} MB"),
|
|
("显卡", report.manifest.get("graphicsDeviceName", "")),
|
|
("显存", f"{report.manifest.get('graphicsMemorySizeMb', '')} MB"),
|
|
("OSS Key", report.object_key),
|
|
("本地文件", str(report.local_path)),
|
|
("存档数", str(report.archive_count)),
|
|
]
|
|
for archive in report.manifest.get("archives") or []:
|
|
rows.append((
|
|
"存档",
|
|
f"{archive.get('mode')} map={archive.get('mapId')} {archive.get('kind')} {archive.get('sourceFileName')} {human_bytes(int(archive.get('fileSize') or 0))}",
|
|
))
|
|
for field, value in rows:
|
|
self.preview.insert("", END, values=(field, value))
|
|
|
|
self.description_box.configure(state="normal")
|
|
self.description_box.delete("1.0", END)
|
|
self.description_box.insert("1.0", report.description)
|
|
self.description_box.configure(state="disabled")
|
|
|
|
def restore_selected_report(self) -> None:
|
|
if not self.selected_report:
|
|
messagebox.showinfo("未选择", "请先选择一条 bug 汇报。")
|
|
return
|
|
if self.selected_report.archive_count <= 0:
|
|
messagebox.showwarning("没有存档", "这条汇报没有附带存档。")
|
|
return
|
|
|
|
target_dir = self.save_config_dir.get().strip()
|
|
if not target_dir:
|
|
messagebox.showerror("缺少路径", "请先设置本地存档 Config 路径。")
|
|
return
|
|
|
|
confirmed = messagebox.askyesno(
|
|
"确认替换",
|
|
f"将清理本地所有 map_archive 存档,并替换为当前汇报内的存档。\n\n目标目录:\n{target_dir}",
|
|
)
|
|
if not confirmed:
|
|
return
|
|
|
|
try:
|
|
deleted, copied = replace_local_saves(self.selected_report, target_dir)
|
|
self.status_text.set(f"已替换本地存档: 删除 {deleted}, 写入 {copied}")
|
|
messagebox.showinfo("替换完成", f"删除 {deleted} 个本地存档,写入 {copied} 个汇报存档。")
|
|
except Exception as exc:
|
|
messagebox.showerror("替换失败", str(exc))
|
|
self.status_text.set(f"替换失败: {exc}")
|
|
|
|
def open_data_dir(self) -> None:
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
os.startfile(DATA_DIR)
|
|
|
|
def _run_worker(self, func, callback) -> None:
|
|
def runner():
|
|
try:
|
|
result = func()
|
|
except Exception as exc:
|
|
result = exc
|
|
self.after(0, lambda: callback(result))
|
|
|
|
threading.Thread(target=runner, daemon=True).start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
PlayerBugViewer().mainloop()
|