1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-17 23:13:03 +08:00

fix(theme): 修复同名主题无法区分作者及导入链路边界问题

- 新增 ThemeUtils.validateTheme 和 readThemeQss 集中校验与读取逻辑
- ThemeManager.importTheme 通过 _resolveDestPath 处理同名主题:
  不同作者自动命名为 {主题名}_{作者名}.altheme, 首次导入保持原名
- ThemeManager.listThemes 返回 file 字段以便 UI 层定位文件
- ALSettingsWidget 全线改用 file 标识符, 组合框按作者消歧义显示
- 移除 applyTheme 中的临时目录解压, 改用 readThemeQss 直接读取
This commit is contained in:
2026-06-16 18:37:47 +08:00
parent 67f297b434
commit 007b4dc2ef
3 changed files with 157 additions and 58 deletions
+23 -14
View File
@@ -196,7 +196,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
self.StyleComboBox.setCurrentIndex(index) self.StyleComboBox.setCurrentIndex(index)
self.populateThemeList() self.populateThemeList()
if custom_theme: if custom_theme:
idx = self.ThemeComboBox.findText(custom_theme) idx = self.ThemeComboBox.findData(custom_theme)
if idx >= 0: if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx) self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus() self.updateThemeStatus()
@@ -206,8 +206,10 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
self self
): ):
name = self.ThemeComboBox.currentText() file = self.ThemeComboBox.currentData()
if name and name != "默认": t = self.__theme_cache.get(file) if file else None
name = t.get("name", "") if t else ""
if name:
self.QssStatusLabel.setText(f"当前使用 {name} 主题。") self.QssStatusLabel.setText(f"当前使用 {name} 主题。")
else: else:
self.QssStatusLabel.setText("当前使用 默认 主题。") self.QssStatusLabel.setText("当前使用 默认 主题。")
@@ -216,12 +218,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
self self
): ):
name = self.ThemeComboBox.currentText() file = self.ThemeComboBox.currentData()
if not name or name == "默认": if not file:
self.ThemeInfoLabel.setText("") self.ThemeInfoLabel.setText("")
return return
t = self.__theme_cache.get(name) t = self.__theme_cache.get(file)
if t: if t:
name = t.get("name", "未知")
author = t.get("author", "未知") author = t.get("author", "未知")
need_theme = t.get("need_theme", "both") need_theme = t.get("need_theme", "both")
brief = t.get("brief", "没有相关简介") brief = t.get("brief", "没有相关简介")
@@ -257,8 +260,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
else: else:
theme = "system" theme = "system"
style = self.StyleComboBox.currentText() style = self.StyleComboBox.currentText()
custom_theme = self.ThemeComboBox.currentText() custom_theme = self.ThemeComboBox.currentData() or ""
if custom_theme == "默认": if not custom_theme:
custom_theme = "" custom_theme = ""
return theme, style, custom_theme return theme, style, custom_theme
@@ -306,14 +309,20 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
self.ThemeComboBox.blockSignals(True) self.ThemeComboBox.blockSignals(True)
self.ThemeComboBox.clear() self.ThemeComboBox.clear()
self.ThemeComboBox.addItem("默认") self.ThemeComboBox.addItem("默认", "")
self.__theme_cache = {} self.__theme_cache = {}
themes = themeInstance().listThemes() themes = themeInstance().listThemes()
for t in themes: for t in themes:
name = t.get("name", "") name = t.get("name", "")
file = t.get("file", name)
author = t.get("author", "")
if name: if name:
self.__theme_cache[name] = t self.__theme_cache[file] = t
self.ThemeComboBox.addItem(name) if author and author != "未知":
display = f"{name} ({author})"
else:
display = name
self.ThemeComboBox.addItem(display, file)
self.ThemeComboBox.blockSignals(False) self.ThemeComboBox.blockSignals(False)
@Slot() @Slot()
@@ -330,9 +339,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
if not file_path: if not file_path:
return return
try: try:
name = themeInstance().importTheme(file_path) file_id = themeInstance().importTheme(file_path)
self.populateThemeList() self.populateThemeList()
idx = self.ThemeComboBox.findText(name) idx = self.ThemeComboBox.findData(file_id)
if idx >= 0: if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx) self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus() self.updateThemeStatus()
@@ -359,7 +368,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
self.ThemeComboBox.blockSignals(True) self.ThemeComboBox.blockSignals(True)
if self.__original_custom_theme: if self.__original_custom_theme:
idx = self.ThemeComboBox.findText(self.__original_custom_theme) idx = self.ThemeComboBox.findData(self.__original_custom_theme)
if idx >= 0: if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx) self.ThemeComboBox.setCurrentIndex(idx)
else: else:
+60 -42
View File
@@ -9,9 +9,7 @@ See the LICENSE file for details.
""" """
import os import os
import shutil import shutil
import tempfile
import threading import threading
import zipfile
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -23,9 +21,8 @@ from interfaces.ConfigProvider import CfgKey
from managers.config.ConfigManager import instance as configInstance from managers.config.ConfigManager import instance as configInstance
from managers.log.LogManager import instance as logInstance from managers.log.LogManager import instance as logInstance
from utils.ThemeUtils import ( from utils.ThemeUtils import (
packTheme, readThemeQss,
readThemeInfo, validateTheme,
unpackTheme,
wrapQssToAtheme wrapQssToAtheme
) )
@@ -94,6 +91,54 @@ class ThemeManager:
return self.__themes_dir return self.__themes_dir
def _resolveDestPath(
self,
theme_name: str,
author: str
) -> str:
"""
Resolve the destination path for an imported theme.
If the default {name}.altheme path does not exist, use it directly.
If it exists and has a different author, use {name}_{author}.altheme.
If it exists and has the same author, raise ValueError.
Args:
theme_name (str): Sanitised theme name.
author (str): Theme author string.
Returns:
str: The resolved destination file path.
Raises:
ValueError: If a theme with the same name and author already exists.
"""
default_path = os.path.join(self.__themes_dir, theme_name + ".altheme")
if not os.path.exists(default_path):
return default_path
try:
existing_info = validateTheme(default_path)
existing_author = existing_info.get("author", "")
except Exception:
self.removeTheme(theme_name)
raise ValueError(
f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
)
if existing_author == author:
raise ValueError(
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
)
safe_author = os.path.basename(author) if author else "未知"
alt_path = os.path.join(
self.__themes_dir, f"{theme_name}_{safe_author}.altheme"
)
if os.path.exists(alt_path):
raise ValueError(
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
)
return alt_path
def importTheme( def importTheme(
self, self,
source_path: str source_path: str
@@ -123,31 +168,17 @@ class ThemeManager:
with self.__lock: with self.__lock:
if ext == ".qss": if ext == ".qss":
name = os.path.splitext(os.path.basename(source_path))[0] name = os.path.splitext(os.path.basename(source_path))[0]
dest_path = os.path.join(self.__themes_dir, name + ".altheme") dest_path = self._resolveDestPath(name, "未知")
if os.path.exists(dest_path):
raise ValueError(f"主题 '{name}' 已存在")
wrapQssToAtheme(source_path, dest_path, "both") wrapQssToAtheme(source_path, dest_path, "both")
return name return os.path.splitext(os.path.basename(dest_path))[0]
elif ext == ".altheme": elif ext == ".altheme":
with zipfile.ZipFile(source_path, "r") as zf: info = validateTheme(source_path)
if "theme.qss" not in zf.namelist():
raise ValueError("无效的 .altheme: 缺少 theme.qss")
info = readThemeInfo(source_path)
name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) name = info.get("name", os.path.splitext(os.path.basename(source_path))[0])
safe_name = os.path.basename(name) safe_name = os.path.basename(name)
dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme")
if os.path.exists(dest_path):
raise ValueError(f"主题 '{safe_name}' 已存在")
# Check for name collision with existing themes by the same author
new_author = info.get("author", "") new_author = info.get("author", "")
for existing in self.listThemes(): dest_path = self._resolveDestPath(safe_name, new_author)
if (existing.get("name", "") == safe_name
and existing.get("author", "") == new_author):
raise ValueError(
f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在"
)
shutil.copy2(source_path, dest_path) shutil.copy2(source_path, dest_path)
return safe_name return os.path.splitext(os.path.basename(dest_path))[0]
else: else:
raise ValueError(f"不支持的文件类型: {ext}") raise ValueError(f"不支持的文件类型: {ext}")
@@ -172,10 +203,7 @@ class ThemeManager:
if filename.endswith(".altheme"): if filename.endswith(".altheme"):
filepath = os.path.join(self.__themes_dir, filename) filepath = os.path.join(self.__themes_dir, filename)
try: try:
info = readThemeInfo(filepath) info = validateTheme(filepath)
with zipfile.ZipFile(filepath, "r") as zf:
if "theme.qss" not in zf.namelist():
raise ValueError("缺少 theme.qss")
name = info.get("name", "") name = info.get("name", "")
author = info.get("author", "") author = info.get("author", "")
key = (name, author) key = (name, author)
@@ -185,6 +213,7 @@ class ThemeManager:
) )
continue continue
seen_keys.add(key) seen_keys.add(key)
info["file"] = os.path.splitext(filename)[0]
themes.append(info) themes.append(info)
except Exception as e: except Exception as e:
logInstance().getLogger("ThemeManager").warning( logInstance().getLogger("ThemeManager").warning(
@@ -243,22 +272,11 @@ class ThemeManager:
if not os.path.isfile(filepath): if not os.path.isfile(filepath):
raise FileNotFoundError(filepath) raise FileNotFoundError(filepath)
with self.__lock: with self.__lock:
info = readThemeInfo(filepath) info = validateTheme(filepath)
with tempfile.TemporaryDirectory() as tmpdir: qss = readThemeQss(filepath)
unpackTheme(filepath, tmpdir)
qss_path = os.path.join(tmpdir, "theme.qss")
if os.path.isfile(qss_path):
with open(qss_path, "r", encoding="utf-8") as fh:
qss = fh.read()
app = QApplication.instance()
if app:
app.setStyleSheet(qss)
else:
raise ValueError(
f"主题 '{name}' 的 .altheme 文件中缺少 theme.qss"
)
app = QApplication.instance() app = QApplication.instance()
if app: if app:
app.setStyleSheet(qss)
need_theme = info.get("need_theme", "both") need_theme = info.get("need_theme", "both")
app.styleHints().setColorScheme( app.styleHints().setColorScheme(
ThemeManager._colorSchemeFor(need_theme) ThemeManager._colorSchemeFor(need_theme)
+74 -2
View File
@@ -37,7 +37,6 @@ def packTheme(
zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4)) zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
zf.write(qss_path, "theme.qss") zf.write(qss_path, "theme.qss")
def unpackTheme( def unpackTheme(
altheme_path: str, altheme_path: str,
output_dir: str output_dir: str
@@ -65,7 +64,6 @@ def unpackTheme(
raise ValueError(f"不安全的 .altheme 入口: {name}") raise ValueError(f"不安全的 .altheme 入口: {name}")
zf.extractall(output_dir) zf.extractall(output_dir)
def readThemeInfo( def readThemeInfo(
altheme_path: str altheme_path: str
) -> dict: ) -> dict:
@@ -94,6 +92,80 @@ def readThemeInfo(
raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段") raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段")
return info return info
def readThemeQss(
altheme_path: str
) -> str:
"""
Read the theme.qss content directly from a .altheme archive.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
str: The QSS stylesheet content.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If theme.qss is missing from the archive.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
if "theme.qss" not in zf.namelist():
raise ValueError("无效的 .altheme: 缺少 theme.qss")
return zf.read("theme.qss").decode("utf-8")
def validateTheme(
altheme_path: str
) -> dict:
"""
Validate a .altheme file and return its metadata.
Checks that info.json and theme.qss both exist, info.json
contains all required fields with valid values, and theme.qss
is a non-empty entry in the archive.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
dict: The validated theme metadata dictionary.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If validation fails for any reason.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
names = zf.namelist()
if "info.json" not in names:
raise ValueError("无效的 .altheme: 缺少 info.json")
if "theme.qss" not in names:
raise ValueError("无效的 .altheme: 缺少 theme.qss")
info_bytes = zf.read("info.json")
qss_bytes = zf.read("theme.qss")
try:
info = json.loads(info_bytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}")
if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip():
raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段")
if "author" not in info or not isinstance(info.get("author"), str):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段")
need_theme = info.get("need_theme", "both")
if need_theme not in ("light", "dark", "both"):
raise ValueError(
f"无效的 .altheme: need_theme 值 '{need_theme}' 无效, "
f"应为 'light''dark''both'"
)
if "brief" not in info or not isinstance(info.get("brief"), str):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段")
if not qss_bytes.strip():
raise ValueError("无效的 .altheme: theme.qss 为空")
return info
def wrapQssToAtheme( def wrapQssToAtheme(
qss_path: str, qss_path: str,