diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 6157eda..2c7f9e6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce2ad8f..ef5d65b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,10 @@ name: Build -# This workflow compiles the application for Windows platform using PyInstaller, and -# archives the built artifacts as 'AutoLibrary.-windows-x86_64.zip'. +# This workflow compiles the application for Windows and macOS platforms using +# PyInstaller, and archives the built artifacts. +# +# - Windows: AutoLibrary.-windows-x86_64.zip +# - macOS: AutoLibrary.-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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a49f86..e53aee5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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.-windows-x86_64.zip'. +# Compiles the application for Windows and macOS platforms using PyInstaller, and +# archives the built artifacts. +# - Windows: AutoLibrary.-windows-x86_64.zip +# - macOS: AutoLibrary.-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 diff --git a/src/gui/resources/icons/AutoLibrary_Logo.icns b/src/gui/resources/icons/AutoLibrary_Logo.icns new file mode 100644 index 0000000..19830f8 Binary files /dev/null and b/src/gui/resources/icons/AutoLibrary_Logo.icns differ diff --git a/src/managers/driver/WebDriverDownloader.py b/src/managers/driver/WebDriverDownloader.py index e8189ae..681d9c8 100644 --- a/src/managers/driver/WebDriverDownloader.py +++ b/src/managers/driver/WebDriverDownloader.py @@ -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) diff --git a/src/managers/driver/WebDriverManager.py b/src/managers/driver/WebDriverManager.py index 30b3645..7ab845d 100644 --- a/src/managers/driver/WebDriverManager.py +++ b/src/managers/driver/WebDriverManager.py @@ -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 diff --git a/src/pages/AutoLib.py b/src/pages/AutoLib.py index 18f5b00..470b814 100644 --- a/src/pages/AutoLib.py +++ b/src/pages/AutoLib.py @@ -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) diff --git a/src/pages/LoginPage.py b/src/pages/LoginPage.py index 3194276..8a7225a 100644 --- a/src/pages/LoginPage.py +++ b/src/pages/LoginPage.py @@ -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 diff --git a/src/pages/flows/CheckinFlow.py b/src/pages/flows/CheckinFlow.py index 73ee581..e238674 100644 --- a/src/pages/flows/CheckinFlow.py +++ b/src/pages/flows/CheckinFlow.py @@ -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) diff --git a/src/pages/flows/RenewFlow.py b/src/pages/flows/RenewFlow.py index e52dd52..16b55d8 100644 --- a/src/pages/flows/RenewFlow.py +++ b/src/pages/flows/RenewFlow.py @@ -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, + ) diff --git a/src/pages/flows/ReserveFlow.py b/src/pages/flows/ReserveFlow.py index 88a2864..9d0c04b 100644 --- a/src/pages/flows/ReserveFlow.py +++ b/src/pages/flows/ReserveFlow.py @@ -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: