a13rollcall/arcx_packager.py
2026-09-26 20:17:31 +08:00

601 行
26 KiB
Python

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import sys
import os
import json
import shutil
import base64
import zipfile
import tempfile
from datetime import datetime
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QTextEdit, QFileDialog,
QMessageBox, QTabWidget, QCheckBox, QSpinBox, QComboBox,
QListWidget, QListWidgetItem, QGroupBox, QFormLayout,
QScrollArea, QFrame, QSplitter)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QIcon, QColor, QPalette
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
class ArcxPackager(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("A13插件打包工具 - .arcx 生成器")
self.setMinimumSize(900, 700)
self.resize(1000, 750)
self._code_content = ""
self._pages = []
self._libs_dir = os.path.join(tempfile.gettempdir(), "a13_plugin_libs")
self._ensure_libs_dir()
self._setup_ui()
self._load_defaults()
def _ensure_libs_dir(self):
if not os.path.exists(self._libs_dir):
os.makedirs(self._libs_dir)
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setContentsMargins(20, 16, 20, 16)
main_layout.setSpacing(12)
title = QLabel("🔧 A13插件打包工具")
title.setStyleSheet("font-size: 24px; font-weight: 800; color: #0067c0;")
main_layout.addWidget(title)
subtitle = QLabel("将 Python 插件代码打包成 .arcx 格式,支持多页面配置、默认设置、元数据编辑")
subtitle.setStyleSheet("font-size: 13px; color: #666666;")
main_layout.addWidget(subtitle)
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabWidget::pane { border: 1px solid #e0e0e0; border-radius: 8px; }
QTabBar::tab { padding: 10px 20px; margin-right: 4px; border-radius: 6px 6px 0 0; font-size: 13px; }
QTabBar::tab:selected { background: #0067c0; color: white; }
QTabBar::tab:!selected { background: #f0f0f0; color: #333; }
""")
main_layout.addWidget(self.tabs, 1)
self._build_meta_tab()
self._build_code_tab()
self._build_pages_tab()
self._build_config_tab()
self._build_libs_tab()
self._build_preview_tab()
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.load_template_btn = QPushButton("📂 加载示例模板")
self.load_template_btn.setStyleSheet("QPushButton { background: #666; color: white; border: none; border-radius: 6px; padding: 12px 24px; font-weight: 600; font-size: 14px; }")
self.load_template_btn.clicked.connect(self._load_template)
btn_layout.addWidget(self.load_template_btn)
self.package_btn = QPushButton("📦 打包成 .arcx")
self.package_btn.setStyleSheet("QPushButton { background: #107c10; color: white; border: none; border-radius: 6px; padding: 12px 32px; font-weight: 700; font-size: 15px; } QPushButton:hover { background: #0e6c0e; }")
self.package_btn.clicked.connect(self._package)
btn_layout.addWidget(self.package_btn)
self.installer_btn = QPushButton("🚀 生成安装程序")
self.installer_btn.setStyleSheet("QPushButton { background: #0067c0; color: white; border: none; border-radius: 6px; padding: 12px 32px; font-weight: 700; font-size: 15px; } QPushButton:hover { background: #005a9e; }")
self.installer_btn.clicked.connect(self._generate_installer)
btn_layout.addWidget(self.installer_btn)
self.arcxpkg_btn = QPushButton("📚 打包含依赖(.arcxpkg)")
self.arcxpkg_btn.setStyleSheet("QPushButton { background: #ca5010; color: white; border: none; border-radius: 6px; padding: 12px 32px; font-weight: 700; font-size: 15px; } QPushButton:hover { background: #b04810; }")
self.arcxpkg_btn.clicked.connect(self._generate_arcxpkg)
btn_layout.addWidget(self.arcxpkg_btn)
main_layout.addLayout(btn_layout)
def _build_meta_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(12)
group = QGroupBox("插件元数据")
group.setStyleSheet("QGroupBox { font-weight: 700; font-size: 14px; border: 1px solid #e0e0e0; border-radius: 8px; margin-top: 12px; padding-top: 16px; }")
form = QFormLayout(group)
form.setSpacing(10)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("例如:示例插件")
form.addRow("插件名称*:", self.name_input)
self.version_input = QLineEdit()
self.version_input.setText("1.0.0")
form.addRow("版本号:", self.version_input)
self.author_input = QLineEdit()
self.author_input.setPlaceholderText("例如:张三")
form.addRow("作者:", self.author_input)
self.desc_input = QTextEdit()
self.desc_input.setMaximumHeight(80)
self.desc_input.setPlaceholderText("简要描述插件的功能和用途")
form.addRow("描述:", self.desc_input)
self.type_combo = QComboBox()
self.type_combo.addItems(["general", "tool", "theme", "data", "ui"])
form.addRow("插件类型:", self.type_combo)
self.api_spin = QSpinBox()
self.api_spin.setRange(1, 10)
self.api_spin.setValue(2)
form.addRow("API版本:", self.api_spin)
layout.addWidget(group)
layout.addStretch()
self.tabs.addTab(tab, "📝 元数据")
def _build_code_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
btn_row = QHBoxLayout()
self.load_code_btn = QPushButton("📂 加载 Python 文件")
self.load_code_btn.clicked.connect(self._load_code_file)
btn_row.addWidget(self.load_code_btn)
self.paste_code_btn = QPushButton("📋 粘贴代码")
self.paste_code_btn.clicked.connect(self._paste_code)
btn_row.addWidget(self.paste_code_btn)
self.clear_code_btn = QPushButton("🗑️ 清空")
self.clear_code_btn.clicked.connect(lambda: self.code_editor.clear())
btn_row.addWidget(self.clear_code_btn)
btn_row.addStretch()
self.code_path_label = QLabel("未加载文件")
self.code_path_label.setStyleSheet("color: #888; font-size: 12px;")
btn_row.addWidget(self.code_path_label)
layout.addLayout(btn_row)
self.code_editor = QTextEdit()
self.code_editor.setStyleSheet("""
QTextEdit { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3d3d3d; border-radius: 6px;
padding: 12px; font-family: Consolas, monospace; font-size: 12px; }
""")
self.code_editor.setPlaceholderText("在此粘贴或加载插件 Python 代码...\n\n必需函数:\ndef main(context):\n return {'status': 'success'}\n\n可选函数:\ndef build_page_settings(context, parent=None):\n # 构建设置页面\n return QWidget()")
layout.addWidget(self.code_editor, 1)
self.tabs.addTab(tab, "💻 代码编辑")
def _build_pages_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
hint = QLabel("配置插件的设置页面,每个页面对应代码中的 build_page_<key> 函数")
hint.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(hint)
self.pages_list = QListWidget()
self.pages_list.setStyleSheet("QListWidget { background: #fafafa; border: 1px solid #e5e5e5; border-radius: 6px; padding: 4px; } QListWidget::item { padding: 10px; border-radius: 4px; }")
layout.addWidget(self.pages_list, 1)
btn_row = QHBoxLayout()
self.add_page_btn = QPushButton("➕ 添加页面")
self.add_page_btn.clicked.connect(self._add_page)
btn_row.addWidget(self.add_page_btn)
self.remove_page_btn = QPushButton("➖ 删除选中")
self.remove_page_btn.clicked.connect(self._remove_page)
btn_row.addWidget(self.remove_page_btn)
self.edit_page_btn = QPushButton("✏️ 编辑选中")
self.edit_page_btn.clicked.connect(self._edit_page)
btn_row.addWidget(self.edit_page_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.tabs.addTab(tab, "📄 页面配置")
def _build_config_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
hint = QLabel("配置插件的默认设置项(JSON 格式),插件运行时可通过 context.get_config/set_config 访问")
hint.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(hint)
self.config_editor = QTextEdit()
self.config_editor.setStyleSheet("""
QTextEdit { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3d3d3d; border-radius: 6px;
padding: 12px; font-family: Consolas, monospace; font-size: 12px; }
""")
self.config_editor.setPlaceholderText('{\n "key1": "value1",\n "key2": true,\n "key3": 123\n}')
layout.addWidget(self.config_editor, 1)
btn_row = QHBoxLayout()
self.format_json_btn = QPushButton("✨ 格式化 JSON")
self.format_json_btn.clicked.connect(self._format_json)
btn_row.addWidget(self.format_json_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.tabs.addTab(tab, "⚙️ 默认配置")
def _build_preview_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
hint = QLabel("预览打包后的 .arcx 文件结构(JSON 格式)")
hint.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(hint)
self.preview_editor = QTextEdit()
self.preview_editor.setReadOnly(True)
self.preview_editor.setStyleSheet("""
QTextEdit { background: #f5f5f5; color: #333; border: 1px solid #e0e0e0; border-radius: 6px;
padding: 12px; font-family: Consolas, monospace; font-size: 11px; }
""")
layout.addWidget(self.preview_editor, 1)
btn_row = QHBoxLayout()
self.refresh_preview_btn = QPushButton("🔄 刷新预览")
self.refresh_preview_btn.clicked.connect(self._refresh_preview)
btn_row.addWidget(self.refresh_preview_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self.tabs.addTab(tab, "👁️ 预览")
def _load_defaults(self):
self._pages = [
{"key": "settings", "title": "插件设置", "icon": "settings"},
{"key": "about", "title": "关于插件", "icon": "info"},
]
self._refresh_pages_list()
self.config_editor.setPlainText('{\n "example_key": "example_value"\n}')
def _load_template(self):
template_path = os.path.join(BASE_DIR, "plugins", "示例插件.arcx")
if not os.path.exists(template_path):
QMessageBox.warning(self, "未找到模板", "示例插件模板不存在,请先运行主程序生成。")
return
try:
with open(template_path, "r", encoding="utf-8") as f:
data = json.load(f)
meta = data.get("meta", {})
self.name_input.setText(meta.get("name", ""))
self.version_input.setText(meta.get("version", "1.0.0"))
self.author_input.setText(meta.get("author", ""))
self.desc_input.setPlainText(meta.get("description", ""))
self.type_combo.setCurrentText(meta.get("type", "general"))
self.api_spin.setValue(meta.get("api_version", 2))
self.code_editor.setPlainText(data.get("code", ""))
self._pages = meta.get("pages", [])
self._refresh_pages_list()
self.config_editor.setPlainText(json.dumps(data.get("config", {}), indent=2, ensure_ascii=False))
self.code_path_label.setText("已加载:示例插件.arcx")
QMessageBox.information(self, "加载成功", "示例插件模板已加载,可以在此基础上修改。")
except Exception as e:
QMessageBox.warning(self, "加载失败", f"加载模板失败:{str(e)}")
def _load_code_file(self):
path, _ = QFileDialog.getOpenFileName(self, "选择 Python 文件", "", "Python Files (*.py);;All Files (*.*)")
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.code_editor.setPlainText(content)
self.code_path_label.setText(f"已加载:{os.path.basename(path)}")
if not self.name_input.text():
self.name_input.setText(os.path.splitext(os.path.basename(path))[0])
except Exception as e:
QMessageBox.warning(self, "加载失败", f"加载文件失败:{str(e)}")
def _paste_code(self):
clipboard = QApplication.clipboard()
text = clipboard.text()
if text:
self.code_editor.setPlainText(text)
self.code_path_label.setText("已从剪贴板粘贴")
else:
QMessageBox.information(self, "剪贴板为空", "剪贴板中没有文本内容。")
def _add_page(self):
from PyQt5.QtWidgets import QDialog, QDialogButtonBox
dlg = QDialog(self)
dlg.setWindowTitle("添加页面")
dlg.setMinimumWidth(400)
layout = QVBoxLayout(dlg)
form = QFormLayout()
key_input = QLineEdit()
key_input.setPlaceholderText("例如:settings")
form.addRow("页面Key*:", key_input)
title_input = QLineEdit()
title_input.setPlaceholderText("例如:插件设置")
form.addRow("页面标题*:", title_input)
icon_input = QComboBox()
icon_input.addItems(["settings", "info", "zap", "puzzle", "chart", "book", "users", "palette", "database", "keyboard"])
form.addRow("图标:", icon_input)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec_() == QDialog.Accepted:
key = key_input.text().strip()
title = title_input.text().strip()
if not key or not title:
QMessageBox.warning(self, "输入不完整", "请填写页面Key和标题。")
return
self._pages.append({"key": key, "title": title, "icon": icon_input.currentText()})
self._refresh_pages_list()
def _remove_page(self):
current = self.pages_list.currentRow()
if current < 0:
QMessageBox.warning(self, "未选中", "请先选择要删除的页面。")
return
self._pages.pop(current)
self._refresh_pages_list()
def _edit_page(self):
current = self.pages_list.currentRow()
if current < 0:
QMessageBox.warning(self, "未选中", "请先选择要编辑的页面。")
return
from PyQt5.QtWidgets import QDialog, QDialogButtonBox
page = self._pages[current]
dlg = QDialog(self)
dlg.setWindowTitle("编辑页面")
dlg.setMinimumWidth(400)
layout = QVBoxLayout(dlg)
form = QFormLayout()
key_input = QLineEdit(page["key"])
form.addRow("页面Key*:", key_input)
title_input = QLineEdit(page["title"])
form.addRow("页面标题*:", title_input)
icon_input = QComboBox()
icon_input.addItems(["settings", "info", "zap", "puzzle", "chart", "book", "users", "palette", "database", "keyboard"])
icon_input.setCurrentText(page.get("icon", "settings"))
form.addRow("图标:", icon_input)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec_() == QDialog.Accepted:
page["key"] = key_input.text().strip()
page["title"] = title_input.text().strip()
page["icon"] = icon_input.currentText()
self._refresh_pages_list()
def _refresh_pages_list(self):
self.pages_list.clear()
for page in self._pages:
item = QListWidgetItem(f"📄 {page['title']} (key: {page['key']}, icon: {page.get('icon', 'settings')})")
self.pages_list.addItem(item)
def _format_json(self):
try:
content = self.config_editor.toPlainText()
if not content.strip():
return
data = json.loads(content)
self.config_editor.setPlainText(json.dumps(data, indent=2, ensure_ascii=False))
except json.JSONDecodeError as e:
QMessageBox.warning(self, "JSON 格式错误", f"JSON 格式错误:{str(e)}")
def _build_arcx_data(self):
name = self.name_input.text().strip()
if not name:
return None, "请填写插件名称"
code = self.code_editor.toPlainText()
if not code.strip():
return None, "请填写或加载插件代码"
try:
config = json.loads(self.config_editor.toPlainText()) if self.config_editor.toPlainText().strip() else {}
except json.JSONDecodeError as e:
return None, f"默认配置 JSON 格式错误:{str(e)}"
data = {
"meta": {
"name": name,
"version": self.version_input.text().strip() or "1.0.0",
"author": self.author_input.text().strip() or "匿名",
"description": self.desc_input.toPlainText().strip(),
"type": self.type_combo.currentText(),
"api_version": self.api_spin.value(),
"has_settings_page": len(self._pages) > 0,
"pages": self._pages,
"config": config
},
"code": code,
"config": config,
"enabled": True,
"installed_at": datetime.now().isoformat()
}
return data, None
def _refresh_preview(self):
data, error = self._build_arcx_data()
if error:
self.preview_editor.setPlainText(f"❌ {error}")
return
preview = json.dumps(data, indent=2, ensure_ascii=False)
if len(preview) > 5000:
preview = preview[:5000] + "\n\n... (代码过长,已截断)"
self.preview_editor.setPlainText(preview)
def _package(self):
data, error = self._build_arcx_data()
if error:
QMessageBox.warning(self, "打包失败", error)
return
default_name = f"{data['meta']['name']}.arcx"
default_path = os.path.join(BASE_DIR, "plugins", default_name)
path, _ = QFileDialog.getSaveFileName(self, "保存 .arcx 文件", default_path, "ARCX 插件文件 (*.arcx)")
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
size = os.path.getsize(path)
QMessageBox.information(self, "打包成功",
f"✅ 插件打包成功!\n\n"
f"文件:{path}\n"
f"大小:{size / 1024:.1f} KB\n\n"
f"将此文件放到 plugins 目录,重启软件即可加载。")
except Exception as e:
QMessageBox.warning(self, "打包失败", f"保存文件失败:{str(e)}")
def _generate_installer(self):
data, error = self._build_arcx_data()
if error:
QMessageBox.warning(self, "生成失败", error)
return
meta = data['meta']
plugin_name = meta['name']
plugin_version = meta['version']
plugin_author = meta.get('author', '匿名')
plugin_desc = meta.get('description', '')
template_path = os.path.join(BASE_DIR, "plugin_installer_template.py")
if not os.path.exists(template_path):
QMessageBox.warning(self, "模板缺失", f"安装程序模板不存在:{template_path}")
return
try:
with open(template_path, "r", encoding="utf-8") as f:
template = f.read()
plugin_json = json.dumps(data, ensure_ascii=False)
plugin_data_b64 = base64.b64encode(plugin_json.encode('utf-8')).decode('utf-8')
installer_code = template
installer_code = installer_code.replace("__PLUGIN_NAME__", plugin_name)
installer_code = installer_code.replace("__PLUGIN_VERSION__", plugin_version)
installer_code = installer_code.replace("__PLUGIN_AUTHOR__", plugin_author)
installer_code = installer_code.replace("__PLUGIN_DESC__", plugin_desc)
installer_code = installer_code.replace('"__PLUGIN_DATA__"', f'"{plugin_data_b64}"')
default_name = f"{plugin_name}_installer.py"
default_path = os.path.join(BASE_DIR, default_name)
path, _ = QFileDialog.getSaveFileName(self, "保存安装程序", default_path, "Python 文件 (*.py)")
if not path:
return
with open(path, "w", encoding="utf-8") as f:
f.write(installer_code)
size = os.path.getsize(path)
QMessageBox.information(self, "生成成功",
f"✅ 安装程序生成成功!\n\n"
f"文件:{path}\n"
f"大小:{size / 1024:.1f} KB\n\n"
f"运行方式:\n"
f"python {os.path.basename(path)}\n\n"
f"如需打包成 exe,请使用 PyInstaller:\n"
f"pyinstaller --onefile --windowed --name {plugin_name}安装程序 {os.path.basename(path)}")
except Exception as e:
import traceback
QMessageBox.warning(self, "生成失败", f"生成安装程序失败:{str(e)}\n\n{traceback.format_exc()}")
def _generate_arcxpkg(self):
data, error = self._build_arcx_data()
if error:
QMessageBox.warning(self, "打包失败", error)
return
meta = data['meta']
plugin_name = meta['name']
plugin_version = meta['version']
libs_dir = QFileDialog.getExistingDirectory(
self,
"选择第三方依赖库目录(libs文件夹)\n\n该目录下应包含需要随插件一起分发的Python库,例如:\n- requests/\n- urllib3/\n- numpy/\n\n如果没有依赖库,请取消此步骤",
BASE_DIR
)
import tempfile
temp_dir = tempfile.mkdtemp(prefix="a13_arcxpkg_")
try:
plugin_arcx_path = os.path.join(temp_dir, "plugin.arcx")
with open(plugin_arcx_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
lib_count = 0
if libs_dir and os.path.exists(libs_dir):
target_libs = os.path.join(temp_dir, "libs")
shutil.copytree(libs_dir, target_libs)
lib_count = len(os.listdir(target_libs))
install_config = {
"plugin_name": plugin_name,
"plugin_version": plugin_version,
"has_libs": lib_count > 0,
"lib_count": lib_count,
"pack_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"format_version": "1.0"
}
with open(os.path.join(temp_dir, "install.json"), "w", encoding="utf-8") as f:
json.dump(install_config, f, ensure_ascii=False, indent=2)
default_name = f"{plugin_name}_v{plugin_version}.arcxpkg"
default_path = os.path.join(BASE_DIR, default_name)
save_path, _ = QFileDialog.getSaveFileName(
self, "保存插件包", default_path, "插件包 (*.arcxpkg)"
)
if not save_path:
return
with zipfile.ZipFile(save_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(temp_dir):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, temp_dir)
zf.write(file_path, arcname)
size = os.path.getsize(save_path)
lib_info = f"\n包含 {lib_count} 个第三方依赖库" if lib_count > 0 else "\n不包含第三方依赖库"
QMessageBox.information(
self, "打包成功",
f"✅ 插件包打包成功!\n\n"
f"文件:{save_path}\n"
f"大小:{size / 1024:.1f} KB"
f"{lib_info}\n\n"
f"安装方式:\n"
f"1. 运行 plugin_installer.py\n"
f"2. 选择此 .arcxpkg 文件\n"
f"3. 点击开始安装\n"
f"4. 重启主程序"
)
except Exception as e:
import traceback
QMessageBox.warning(self, "打包失败", f"生成插件包失败:{str(e)}\n\n{traceback.format_exc()}")
finally:
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
pass
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = ArcxPackager()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()