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

Compare commits

...

9 Commits

Author SHA1 Message Date
KenanZhu 6d05d4c7cb ci: main 分支 push 事件触发 BuildTest 工作流 2026-06-17 10:29:54 +08:00
KenanZhu 01f4ccaa0e fix(driver): macOS/Linux 下载的 Chrome/Edge 驱动缺少执行权限导致运行失败
zipfile 解压不保留 Unix 执行位,extract 后 chmod 755;同时在启动检查
时对已下载但不可执行的旧驱动自动修复权限。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:22:33 +08:00
Kenan Zhu 0bad34d7a8 refactor(*): LoginPage 消息追踪统一与 Flow 长方法拆分 (#12)
refactor: LoginPage 消息追踪统一与 Flow 长方法拆分
2026-06-13 09:30:02 +08:00
KenanZhu 61d1b44402 refactor: 提取 Flow 类 execute 长方法为私有子方法,降低嵌套深度 2026-06-13 08:59:45 +08:00
KenanZhu 72301c63fd refactor: LoginPage 继承 MsgBase,统一页面消息追踪机制 2026-06-13 08:59:34 +08:00
Kenan Zhu 609850ab60 ci(workflows): 新增 macOS .app 与 .dmg 打包流程 (#11)
新增 macOS .app 与 .dmg 打包流程
2026-06-10 09:49:54 +08:00
KenanZhu 8e14d45b71 fix(ci): 修复 Windows summary 因 Write-Host 管道失效导致空白输出
pwsh 中 Write-Host 写入信息流(stream 6)不进入管道,Write-Host | Out-File
实际写入空文件。改为直接输出字符串(stream 1)进入管道,与 macOS 的
echo >> GITHUB_STEP_SUMMARY 行为一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:17:49 +08:00
KenanZhu f6ef9af39d fix(ci): 修复 macOS 构建流程 6 项缺陷
1. requirements.txt 从 UTF-16 LE 转码为 UTF-8,修复 macOS pip install 解析失败
2. 新增 AutoLibrary_Logo.icns 图标文件,替换 macOS BUNDLE 中不兼容的 .ico 格式
3. PyInstaller 调用改为 python -m PyInstaller,确保 PATH 无关健壮性
4. ddddocr ONNX 模型路径增加多级 fallback 搜索,提升版本兼容性
5. info_plist 移除过时的 CFBundlePackageType,添加 NSHumanReadableCopyright
6. 为 macOS 专属的 sed -i '' 语法添加注释说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:05:01 +08:00
KenanZhu 72dce83c55 ci(workflows): 新增 macOS .app 与 .dmg 打包流程 2026-06-09 16:24:37 +08:00
12 changed files with 802 additions and 98 deletions
+282 -9
View File
@@ -4,6 +4,9 @@ name: Build Test
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
on:
push:
branches:
- main
pull_request:
branches:
- main
@@ -209,13 +212,283 @@ jobs:
- name: Upload build summary
run: |
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Get version info
id: get_version
run: |
version="pr-test"
tag_name="pr-test"
echo "✓ Mode: Pull Request Test Build"
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" name='AutoLibrary'," \
' debug=False,' \
' bootloader_ignore_signals=False,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
' runtime_tmpdir=None,' \
' console=False,' \
' disable_windowed_traceback=False,' \
' argv_emulation=False,' \
' target_arch=None,' \
' codesign_identity=None,' \
' entitlements_file=None,' \
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: 7
- name: Upload build summary
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Test Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Pull request build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}"
echo "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}"
echo "- Event: ${{ github.event_name }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+311 -10
View File
@@ -1,7 +1,10 @@
name: Build
# This workflow compiles the application for Windows platform using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
# This workflow compiles the application for Windows and macOS platforms using
# PyInstaller, and archives the built artifacts.
#
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
#
# It is triggered when called by the release workflow.
@@ -251,12 +254,310 @@ jobs:
- name: Upload build summary
if: ${{ inputs.is_test == 'true' }}
run: |
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Download build version of ALVersionInfo.py
uses: actions/download-artifact@v6
with:
name: updated-version-info-for-build
path: src/gui/
- name: Get version info
id: get_version
run: |
is_test="${{ inputs.is_test }}"
if [ "$is_test" = "true" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Test Build"
else
version="${{ inputs.version }}"
tag_name="${{ inputs.tag_name }}"
if [ -z "$version" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Independent Build (No inputs provided)"
fi
fi
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Verify 'ALVersionInfo.py' was updated
run: |
version_info_file="src/gui/ALVersionInfo.py"
echo "Verifying $version_info_file content:"
echo "========================================"
cat "$version_info_file"
echo "========================================"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" name='AutoLibrary'," \
' debug=False,' \
' bootloader_ignore_signals=False,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
' runtime_tmpdir=None,' \
' console=False,' \
' disable_windowed_traceback=False,' \
' argv_emulation=False,' \
' target_arch=None,' \
' codesign_identity=None,' \
' entitlements_file=None,' \
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
- name: Upload build summary
if: ${{ inputs.is_test == 'true' }}
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Event: ${{ github.event_name }}"
echo "- Ref: ${{ github.ref }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+12 -3
View File
@@ -21,8 +21,10 @@ name: Release
# Commits version changes to release branch and creates the release tag.
# 4. Build:
# Compiles the application for Windows platform using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
# Compiles the application for Windows and macOS platforms using PyInstaller, and
# archives the built artifacts.
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
# 5. Release:
# Creates GitHub release with generated artifacts and release notes
@@ -181,12 +183,18 @@ jobs:
contents: write
steps:
- name: Download artifacts
- name: Download Windows artifacts
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
path: artifacts/
- name: Download macOS artifacts
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64
path: artifacts/
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
@@ -195,6 +203,7 @@ jobs:
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
files: |
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
draft: false
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
generate_release_notes: true
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -360,6 +360,10 @@ class WebDriverDownloader:
break
if not driver_file:
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
# Ensure executable permissions on Unix systems (zipfile
# extraction does not preserve the execute bit).
if os.name != 'nt':
os.chmod(driver_file, 0o755)
progress_callback(100, 100, 0.0, "解压完成")
self.download_path.unlink()
self._cleanup(driver_file)
+4
View File
@@ -111,6 +111,10 @@ class WebDriverManager:
for driver_info in self.__driver_infos:
driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file():
# Repair missing execute permission on Unix
# (zip-extracted drivers from older versions).
if os.name != 'nt' and not os.access(str(driver_path), os.X_OK):
os.chmod(str(driver_path), 0o755)
driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED
+15 -13
View File
@@ -10,6 +10,7 @@ See the LICENSE file for details.
import os
import queue
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
TimeoutException,
WebDriverException,
@@ -37,11 +38,11 @@ class AutoLib(MsgBase):
output_queue: queue.Queue,
run_config: dict,
) -> None:
super().__init__(input_queue, output_queue)
super().__init__(input_queue, output_queue)
self.__run_config: dict = run_config
self.__user_config: dict | None = None
self.__driver = None
self.__driver: WebDriver | None = None
self.__driver_type: str = ""
self.__driver_path: str = ""
self.__login_page: LoginPage = None
@@ -58,7 +59,7 @@ class AutoLib(MsgBase):
else:
if not self.__initDriverUrl():
self.close()
raise Exception("浏览器驱动URL初始化失败 !")
raise Exception("浏览器驱动 URL 初始化失败 !")
self.__initPagesServices()
self.__initPagesFlows()
@@ -67,9 +68,10 @@ class AutoLib(MsgBase):
) -> bool:
self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type", "none")
match self.__driver_type.lower():
driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = driver_config.get("driver_type", "none")
self.__driver_type = self.__driver_type.lower()
match self.__driver_type:
case "edge":
driver_options = webdriver.EdgeOptions()
case "chrome":
@@ -82,10 +84,10 @@ class AutoLib(MsgBase):
self.TraceLevel.WARNING,
)
return False
if not web_driver_config:
if not driver_config:
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False
if web_driver_config.get("headless", False):
if driver_config.get("headless", False):
driver_options.add_argument("--headless")
driver_options.add_argument("--disable-gpu")
driver_options.add_argument("--no-sandbox")
@@ -110,11 +112,11 @@ class AutoLib(MsgBase):
"AppleWebKit/537.36 (KHTML, like Gecko) "\
"Chrome/120.0.0.0 "\
"Safari/537.36"
if self.__driver_type.lower() == "edge":
if self.__driver_type == "edge":
user_agent += " Edg/120.0.0.0"
# set options for firefox
elif self.__driver_type.lower() == "firefox":
elif self.__driver_type == "firefox":
driver_options.set_preference("dom.webdriver.enabled", False)
driver_options.set_preference("useAutomationExtension", False)
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
@@ -122,14 +124,14 @@ class AutoLib(MsgBase):
driver_options.add_argument(f"user-agent={user_agent}")
# init browser driver
self.__driver_path = web_driver_config.get("driver_path", "")
self.__driver_path = driver_config.get("driver_path", "")
if not self.__driver_path:
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False
try:
self.__driver_path = os.path.abspath(self.__driver_path)
service = None
match self.__driver_type.lower():
match self.__driver_type:
case "edge":
service = EdgeService(executable_path=self.__driver_path)
self.__driver = webdriver.Edge(service=service, options=driver_options)
@@ -161,7 +163,7 @@ class AutoLib(MsgBase):
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False
url: str = lib_config.get("host_url") + lib_config.get("login_url")
self.__login_page = LoginPage(self.__driver, tracer=self._showTrace)
self.__login_page = LoginPage(self._input_queue, self._output_queue, self.__driver)
self.__driver.set_page_load_timeout(5)
try:
self.__driver.get(url)
+13 -19
View File
@@ -7,7 +7,8 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from typing import Callable, Optional
import queue
from typing import Callable
from selenium.common.exceptions import (
ElementNotInteractableException,
@@ -19,8 +20,10 @@ from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.MsgBase import MsgBase
class LoginPage:
class LoginPage(MsgBase):
USERNAME_INPUT = (By.NAME, "username")
PASSWORD_INPUT = (By.NAME, "password")
@@ -36,22 +39,13 @@ class LoginPage:
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver,
tracer: Optional[Callable[..., None]] = None,
) -> None:
super().__init__(input_queue, output_queue)
self._driver: WebDriver = driver
self._tracer: Optional[Callable[..., None]] = tracer
def _trace(
self,
msg: str,
level: int = 20,
no_log: bool = False,
) -> None:
if self._tracer:
self._tracer(msg, level, no_log)
def navigate(
self,
@@ -185,7 +179,7 @@ class LoginPage:
) -> bool:
for attempt in range(max_attempts):
self._trace(
self._showTrace(
f"用户 {username}{attempt + 1} 次尝试登录......",
no_log=True,
)
@@ -196,16 +190,16 @@ class LoginPage:
continue
if not self.fillCaptcha(captcha_text):
continue
self._trace("尝试登录...", no_log=True)
self._showTrace("尝试登录...", no_log=True)
if not self.clickLogin():
continue
if self.waitLoginSuccess():
self._trace(f"用户 {username}{attempt + 1} 次登录成功 !")
self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._trace(
self._showTrace(
"登录页面加载失败 ! : "
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
level=40,
level=self.TraceLevel.ERROR,
)
return False
+18 -2
View File
@@ -35,7 +35,7 @@ class CheckinFlow(MsgBase):
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
def _ensureCheckinButton(
self,
username: str,
) -> bool:
@@ -49,7 +49,13 @@ class CheckinFlow(MsgBase):
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False
self._showTrace("签到按钮已启用")
self._shell.clickCheckinButton()
return True
def _processCheckinDialog(
self,
username: str,
) -> bool:
try:
with CheckinResultDialog(self._driver) as dialog:
result_msg = dialog.getResultMessage()
@@ -87,3 +93,13 @@ class CheckinFlow(MsgBase):
except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
def execute(
self,
username: str,
) -> bool:
if not self._ensureCheckinButton(username):
return False
self._shell.clickCheckinButton()
return self._processCheckinDialog(username)
+40 -7
View File
@@ -61,19 +61,23 @@ class RenewFlow(MsgBase):
)
return True
def execute(
def _computeRenewTarget(
self,
username: str,
record: dict,
renew_info: dict,
) -> bool:
):
max_diff = renew_info.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
end_time = record["time"]["end"]
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
if not self._validateRenewTime(end_time, target_renew_mins):
return False
return None
return target_renew_mins
def _ensureExtendButton(
self,
username: str,
) -> bool:
if not self._shell.waitExtendButton():
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False
@@ -83,7 +87,17 @@ class RenewFlow(MsgBase):
f"请连接图书馆网络后重试"
)
return False
self._shell.clickExtendButton()
return True
def _processRenewDialog(
self,
username: str,
record: dict,
target_renew_mins: int,
max_diff: int,
prefer_earlier: bool,
) -> bool:
try:
with RenewDialog(self._driver) as dialog:
if not dialog.waitUntilReady():
@@ -132,3 +146,22 @@ class RenewFlow(MsgBase):
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh()
return False
def execute(
self,
username: str,
record: dict,
renew_info: dict,
) -> bool:
max_diff = renew_info.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
target_renew_mins = self._computeRenewTarget(record, renew_info)
if target_renew_mins is None:
return False
if not self._ensureExtendButton(username):
return False
self._shell.clickExtendButton()
return self._processRenewDialog(
username, record, target_renew_mins, max_diff, prefer_earlier,
)
+103 -35
View File
@@ -58,40 +58,105 @@ class ReserveFlow(MsgBase):
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
def _loadReserveView(
self,
) -> ReserveView | None:
try:
return self._shell.gotoReserveView()
except (TimeoutException, ElementNotInteractableException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return None
def _selectDate(
self,
view: ReserveView,
ctx: ReserveContext,
) -> bool:
submit_reserve = False
reserve_success = False
have_hover_on_page = False
try:
view = self._shell.gotoReserveView()
except (TimeoutException, ElementNotInteractableException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return False
if not view.selectDate(ctx.date):
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"日期 {ctx.date} 选择成功 !")
return True
def _selectPlace(
self,
view: ReserveView,
) -> bool:
if not view.selectPlace("1"):
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
return False
self._showTrace("预约场所 图书馆 选择成功 !")
return True
def _selectFloor(
self,
view: ReserveView,
ctx: ReserveContext,
) -> bool:
if not view.selectFloor(ctx.floor):
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
return True
def _selectRoom(
self,
view: ReserveView,
ctx: ReserveContext,
):
seat_map = view.selectRoom(ctx.room)
if seat_map is None:
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
return None
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
have_hover_on_page = True
return seat_map
def _processReserveResult(
self,
) -> bool:
with ReserveResultDialog(self._driver) as result:
if result.isFailure():
self._showTrace("预约失败", self.TraceLevel.ERROR)
elif result.isSuccess():
details = result.getDetailTexts()
if len(details) >= 6:
self._showTrace(
f"\n"
f" 预约成功 !\n"
f" {details[1]}\n"
f" {details[2]}\n"
f" {details[3]}\n"
f" 签到时间 {details[5]}"
)
else:
self._showTrace(
"\n"
" 预约成功 !\n"
" 未找获取到详细信息"
)
return True
else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def _selectSeatAndSubmit(
self,
view: ReserveView,
seat_map,
ctx: ReserveContext,
) -> tuple[bool, bool]:
submit_reserve = False
reserve_success = False
seat_status = seat_map.selectSeat(ctx.seat_id)
if seat_status is None:
self._showTrace(
@@ -111,31 +176,34 @@ class ReserveFlow(MsgBase):
try:
view.submitReserve()
submit_reserve = True
with ReserveResultDialog(self._driver) as result:
if result.isFailure():
self._showTrace("预约失败", self.TraceLevel.ERROR)
elif result.isSuccess():
details = result.getDetailTexts()
if len(details) >= 6:
self._showTrace(
f"\n"
f" 预约成功 !\n"
f" {details[1]}\n"
f" {details[2]}\n"
f" {details[3]}\n"
f" 签到时间 {details[5]}"
)
else:
self._showTrace(
"\n"
" 预约成功 !\n"
" 未找获取到详细信息"
)
reserve_success = True
else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
reserve_success = self._processReserveResult()
except (TimeoutException, ElementNotInteractableException):
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
return submit_reserve, reserve_success
def execute(
self,
ctx: ReserveContext,
) -> bool:
# reserve flow pipeline:
# date > place > floor > room > seat (begin/end time) > submit > result
view = self._loadReserveView()
if view is None:
return False
if not self._selectDate(view, ctx):
return False
if not self._selectPlace(view):
return False
if not self._selectFloor(view, ctx):
return False
seat_map = self._selectRoom(view, ctx)
if seat_map is None:
return False
have_hover_on_page = True
submit_reserve, reserve_success = self._selectSeatAndSubmit(
view, seat_map, ctx,
)
if not submit_reserve and have_hover_on_page:
view.refresh()
if reserve_success: