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

Compare commits

...

2 Commits

Author SHA1 Message Date
KenanZhu 57f1cfb3f2 fix(theme): 修复死锁、冗余读取、空作者字符串等交叉审查问题
- ThemeManager 拆分 _removeThemeFile 无锁版本, 消除 importTheme 持锁
  时调用 removeTheme 导致的死锁
- validateTheme 增加 check_qss 参数, listThemes 跳过 QSS 读取
- validateTheme 拒绝空/空白作者字符串, 避免 info.json 与文件名不一致
- 统一默认作者为 "未知作者"
- ALSettingsWidget.ui 增加删除按钮 [-], 浏览按钮改为 [+]
- ALSettingsWidget 实现 onRemoveThemeButtonClicked 删除逻辑
2026-06-16 19:37:09 +08:00
KenanZhu 007b4dc2ef fix(theme): 修复同名主题无法区分作者及导入链路边界问题
- 新增 ThemeUtils.validateTheme 和 readThemeQss 集中校验与读取逻辑
- ThemeManager.importTheme 通过 _resolveDestPath 处理同名主题:
  不同作者自动命名为 {主题名}_{作者名}.altheme, 首次导入保持原名
- ThemeManager.listThemes 返回 file 字段以便 UI 层定位文件
- ALSettingsWidget 全线改用 file 标识符, 组合框按作者消歧义显示
- 移除 applyTheme 中的临时目录解压, 改用 readThemeQss 直接读取
2026-06-16 18:37:47 +08:00
4 changed files with 239 additions and 70 deletions
+58 -15
View File
@@ -139,6 +139,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
): ):
self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked)
self.RemoveThemeButton.clicked.connect(self.onRemoveThemeButtonClicked)
self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged) self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged)
self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked) self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked)
@@ -196,7 +197,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 +207,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,13 +219,14 @@ 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:
author = t.get("author", "未知") name = t.get("name", "未知")
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", "没有相关简介")
self.ThemeInfoLabel.setText( self.ThemeInfoLabel.setText(
@@ -257,8 +261,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,16 +310,55 @@ 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) self.ThemeComboBox.addItem(name, file)
self.ThemeComboBox.blockSignals(False) self.ThemeComboBox.blockSignals(False)
@Slot()
def onRemoveThemeButtonClicked(
self
):
file = self.ThemeComboBox.currentData()
if not file:
QMessageBox.information(
self,
"提示 - AutoLibrary",
"请先选择一个主题。"
)
return
t = self.__theme_cache.get(file)
name = t.get("name", file) if t else file
reply = QMessageBox.question(
self,
"删除主题 - AutoLibrary",
f"确定要删除主题 \"{name}\" 吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply != QMessageBox.Yes:
return
try:
themeInstance().removeTheme(file)
self.populateThemeList()
self.ThemeComboBox.setCurrentIndex(0)
self.updateThemeStatus()
self.updateThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"删除失败 - AutoLibrary",
f"无法删除主题:{e}"
)
@Slot() @Slot()
def onImportThemeButtonClicked( def onImportThemeButtonClicked(
self self
@@ -330,9 +373,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 +402,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:
+20 -1
View File
@@ -328,7 +328,26 @@
</size> </size>
</property> </property>
<property name="text"> <property name="text">
<string>...</string> <string>+</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="RemoveThemeButton">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>-</string>
</property> </property>
</widget> </widget>
</item> </item>
+81 -51
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._removeThemeFile(theme_name) # caller holds the lock
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, check_qss=False) # skip QSS read for list scan
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(
@@ -196,6 +225,26 @@ class ThemeManager:
) )
return themes return themes
def _removeThemeFile(
self,
name: str
):
"""
Remove a theme file without locking.
The caller must hold self.__lock before invoking this method.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if os.path.isfile(filepath):
os.remove(filepath)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def removeTheme( def removeTheme(
self, self,
name: str name: str
@@ -210,16 +259,8 @@ class ThemeManager:
name (str): The theme name to remove. name (str): The theme name to remove.
""" """
filepath = os.path.join(self.__themes_dir, name + ".altheme")
with self.__lock: with self.__lock:
if os.path.isfile(filepath): self._removeThemeFile(name)
os.remove(filepath)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def applyTheme( def applyTheme(
self, self,
@@ -243,22 +284,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)
+80 -3
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,85 @@ 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,
check_qss: bool = True
) -> 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.
check_qss (bool): If False, skip theme.qss existence and
content checks (for list-only operations).
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") if check_qss else None # skip QSS read when only listing
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' 字段")
# reject blank author so info.json does not drift from the "未知作者" filename fallback
if ("author" not in info or not isinstance(info.get("author"), str)
or not info["author"].strip()):
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 check_qss and not qss_bytes.strip():
raise ValueError("无效的 .altheme: theme.qss 为空")
return info
def wrapQssToAtheme( def wrapQssToAtheme(
qss_path: str, qss_path: str,
@@ -119,7 +196,7 @@ def wrapQssToAtheme(
filename = os.path.splitext(os.path.basename(qss_path))[0] filename = os.path.splitext(os.path.basename(qss_path))[0]
info = { info = {
"name": filename, "name": filename,
"author": "未知", "author": "未知作者",
"need_theme": current_theme, "need_theme": current_theme,
"brief": "没有相关简介" "brief": "没有相关简介"
} }