diff --git a/.github/entitlements.plist b/.github/entitlements.plist new file mode 100644 index 0000000..b307dc5 --- /dev/null +++ b/.github/entitlements.plist @@ -0,0 +1,25 @@ + + + + + + com.apple.security.network.client + + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.files.downloads.read-write + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/.github/scripts/sign-and-notarize.sh b/.github/scripts/sign-and-notarize.sh new file mode 100755 index 0000000..311983d --- /dev/null +++ b/.github/scripts/sign-and-notarize.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# Check required environment variables +if [ -z "$APPLE_CERTIFICATE_P12_BASE64" ]; then + log_error "APPLE_CERTIFICATE_P12_BASE64 is not set" + exit 1 +fi + +if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then + log_error "APPLE_CERTIFICATE_PASSWORD is not set" + exit 1 +fi + +if [ -z "$APPLE_ID" ]; then + log_error "APPLE_ID is not set" + exit 1 +fi + +if [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ]; then + log_error "APPLE_APP_SPECIFIC_PASSWORD is not set" + exit 1 +fi + +if [ -z "$APPLE_TEAM_ID" ]; then + log_error "APPLE_TEAM_ID is not set" + exit 1 +fi + +APP_PATH="dist/TextWave.app" +ENTITLEMENTS_PATH=".github/entitlements.plist" + +# Verify app exists +if [ ! -d "$APP_PATH" ]; then + log_error "App not found at $APP_PATH" + exit 1 +fi + +log_info "Starting code signing and notarization process..." + +# Create temporary keychain +KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" +KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + +log_info "Creating temporary keychain..." +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" +security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + +# Decode and import certificate +log_info "Importing certificate..." +CERT_PATH="$RUNNER_TEMP/certificate.p12" +echo "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" +security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + +# Set keychain as default +security list-keychain -d user -s "$KEYCHAIN_PATH" +security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + +# Find signing identity +log_info "Finding Developer ID Application certificate..." +SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"') + +if [ -z "$SIGNING_IDENTITY" ]; then + log_error "No Developer ID Application certificate found" + security delete-keychain "$KEYCHAIN_PATH" || true + exit 1 +fi + +log_info "Using signing identity: $SIGNING_IDENTITY" + +# Sign all binaries - must be done in correct order: deepest first +log_info "Signing all binaries (this may take a few minutes)..." + +# Step 1: Sign all .dylib and .so files +log_info "Step 1/5: Signing .dylib and .so files..." +find "$APP_PATH/Contents/Resources" -type f \( -name "*.dylib" -o -name "*.so" \) -print0 | while IFS= read -r -d '' file; do + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$file" 2>/dev/null || true +done + +# Step 2: Sign all executables in MacOS directory (including Python) +log_info "Step 2/5: Signing executables in MacOS directory..." +find "$APP_PATH/Contents/MacOS" -type f -perm +111 -print0 | while IFS= read -r -d '' file; do + log_info " Signing: $(basename "$file")" + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$file" 2>/dev/null || true +done + +# Step 3: Sign Qt/PyQt6 frameworks (must sign nested binaries first, then framework) +log_info "Step 3/5: Signing Qt frameworks..." +if [ -d "$APP_PATH/Contents/Resources/lib/python3.11/PyQt6/Qt6/lib" ]; then + find "$APP_PATH/Contents/Resources/lib/python3.11/PyQt6/Qt6/lib" -name "*.framework" -print0 | while IFS= read -r -d '' framework; do + # First sign any binaries inside the framework + find "$framework" -type f \( -name "*.dylib" -o -perm +111 \) -print0 | while IFS= read -r -d '' binary; do + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$binary" 2>/dev/null || true + done + # Then sign the framework itself + log_info " Signing framework: $(basename "$framework")" + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$framework" 2>/dev/null || true + done +fi + +# Step 4: Sign Contents/Frameworks (both .dylib files and .framework bundles) +log_info "Step 4/5: Signing Contents/Frameworks..." +if [ -d "$APP_PATH/Contents/Frameworks" ]; then + # First sign all .dylib files + find "$APP_PATH/Contents/Frameworks" -type f -name "*.dylib" -print0 | while IFS= read -r -d '' dylib; do + log_info " Signing dylib: $(basename "$dylib")" + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$dylib" 2>/dev/null || true + done + # Then sign .framework bundles + find "$APP_PATH/Contents/Frameworks" -name "*.framework" -print0 | while IFS= read -r -d '' framework; do + log_info " Signing framework: $(basename "$framework")" + codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$framework" 2>/dev/null || true + done +fi + +# Step 5: Sign the main executable +log_info "Step 5/5: Signing main executable..." +codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS_PATH" \ + "$APP_PATH/Contents/MacOS/TextWave" + +# Finally: Sign the app bundle itself +log_info "Signing app bundle..." +codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \ + --entitlements "$ENTITLEMENTS_PATH" \ + "$APP_PATH" + +# Verify signature +log_info "Verifying signature..." +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" || log_warning "Gatekeeper assessment may fail before notarization" + +# Create zip for notarization (notarization requires zip, not just app bundle) +log_info "Creating archive for notarization..." +NOTARIZATION_ZIP="$RUNNER_TEMP/TextWave-notarization.zip" +ditto -c -k --keepParent "$APP_PATH" "$NOTARIZATION_ZIP" + +# Submit for notarization +log_info "Submitting to Apple notary service..." +NOTARIZATION_OUTPUT=$(xcrun notarytool submit "$NOTARIZATION_ZIP" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --timeout 30m 2>&1) + +NOTARIZATION_EXIT_CODE=$? +echo "$NOTARIZATION_OUTPUT" + +# Extract submission ID from output +SUBMISSION_ID=$(echo "$NOTARIZATION_OUTPUT" | grep -o 'id: [a-f0-9-]*' | head -1 | cut -d' ' -f2) + +# Check if notarization was accepted +if echo "$NOTARIZATION_OUTPUT" | grep -q "status: Accepted"; then + log_info "Notarization accepted!" +elif echo "$NOTARIZATION_OUTPUT" | grep -q "status: Invalid"; then + log_error "Notarization was rejected by Apple!" + if [ -n "$SUBMISSION_ID" ]; then + log_error "Fetching detailed rejection log..." + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" || true + fi + security delete-keychain "$KEYCHAIN_PATH" || true + exit 1 +elif [ $NOTARIZATION_EXIT_CODE -ne 0 ]; then + log_error "Notarization failed with exit code $NOTARIZATION_EXIT_CODE" + security delete-keychain "$KEYCHAIN_PATH" || true + exit 1 +else + log_error "Notarization status unclear - check output above" + security delete-keychain "$KEYCHAIN_PATH" || true + exit 1 +fi + +# Staple the notarization ticket +log_info "Stapling notarization ticket..." +xcrun stapler staple "$APP_PATH" + +# Verify stapling +log_info "Verifying stapled ticket..." +xcrun stapler validate "$APP_PATH" + +# Final verification +log_info "Final signature verification..." +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" + +log_info "Code signing and notarization complete!" + +# Clean up +log_info "Cleaning up temporary keychain..." +security delete-keychain "$KEYCHAIN_PATH" || true +rm -f "$CERT_PATH" +rm -f "$NOTARIZATION_ZIP" + +log_info "All done! The app is signed and notarized." diff --git a/.github/scripts/sign-dmg.sh b/.github/scripts/sign-dmg.sh new file mode 100755 index 0000000..ec9aa4f --- /dev/null +++ b/.github/scripts/sign-dmg.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +VERSION=$1 +DMG_PATH="TextWave-${VERSION}.dmg" + +if [ -z "$VERSION" ]; then + echo "Error: Version not specified" + exit 1 +fi + +if [ ! -f "$DMG_PATH" ]; then + echo "Error: DMG not found at $DMG_PATH" + exit 1 +fi + +echo "Signing DMG: $DMG_PATH" + +# Find signing identity (keychain already set up by calling workflow) +SIGNING_IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"') + +if [ -z "$SIGNING_IDENTITY" ]; then + echo "Error: No Developer ID Application certificate found" + exit 1 +fi + +echo "Using signing identity: $SIGNING_IDENTITY" + +# Sign the DMG +codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$DMG_PATH" + +# Verify DMG signature +echo "Verifying DMG signature..." +codesign --verify --verbose=2 "$DMG_PATH" + +echo "DMG signed successfully!" diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index dcfab5f..088eb45 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -6,6 +6,10 @@ on: push: branches: [main] +permissions: + contents: read + pull-requests: write + jobs: test: runs-on: macos-latest @@ -30,8 +34,24 @@ jobs: flake8 pdf2mp3_gui.py --max-line-length=120 --ignore=E203,W503 || true - name: Run unit tests + id: pytest + continue-on-error: true run: | - pytest tests/ -v --cov=pdf2mp3_gui --cov-report=xml --cov-report=term + set +e + pytest tests/ -v --cov=pdf2mp3_gui --cov-report=xml --cov-report=term | tee pytest-output.txt + echo "exit_code=$?" >> $GITHUB_OUTPUT + exit 0 + + - name: Extract coverage percentage + id: coverage + run: | + # Extract just the percentage number from coverage output + COVERAGE=$(grep "TOTAL" pytest-output.txt 2>/dev/null | awk '{print $NF}' | tr -d '%' || echo "0") + # Validate it's a number + if ! [[ "$COVERAGE" =~ ^[0-9]+$ ]]; then + COVERAGE="0" + fi + echo "percentage=$COVERAGE" >> $GITHUB_OUTPUT - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 @@ -42,7 +62,80 @@ jobs: continue-on-error: true - name: Test app builds + id: build + continue-on-error: true run: | + set +e python -m pip install py2app python setup.py py2app test -d dist/TextWave.app + echo "exit_code=$?" >> $GITHUB_OUTPUT + exit 0 + + - name: Post test results comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const pytest_exit = '${{ steps.pytest.outputs.exit_code }}' || '0'; + const build_exit = '${{ steps.build.outputs.exit_code }}' || '0'; + const coverage = '${{ steps.coverage.outputs.percentage }}' || '0'; + + // Determine overall status + const all_passed = pytest_exit === '0' && build_exit === '0'; + const status_emoji = all_passed ? '✅' : '❌'; + const status_text = all_passed ? 'Passed' : 'Failed'; + + // Create comment body + const comment_body = `## ${status_emoji} Test Results + + **Status:** ${status_text} + **Coverage:** ${coverage}% + + ### Test Summary + - **Linting:** ✅ flake8 checks completed + - **Unit Tests:** ${pytest_exit === '0' ? '✅ Passed' : '❌ Failed'} + - **Build Verification:** ${build_exit === '0' ? '✅ Passed' : '❌ Failed'} + - **Signing & Notarization:** ⏭️ Only on release (not tested in PRs) + + [View full workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId}) + + --- + *Last updated: ${new Date().toUTCString()}* + `; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const bot_comment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('') + ); + + // Update or create comment + if (bot_comment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: bot_comment.id, + body: comment_body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment_body + }); + } + + - name: Fail workflow if tests failed + if: steps.pytest.outputs.exit_code != '0' || steps.build.outputs.exit_code != '0' + run: | + echo "Tests failed - failing workflow" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6880437..1a3c686 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,12 +54,49 @@ jobs: chmod +x .github/scripts/build-app.sh .github/scripts/build-app.sh + - name: Code sign and notarize app + if: steps.check_tag.outputs.exists == 'false' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + chmod +x .github/scripts/sign-and-notarize.sh + .github/scripts/sign-and-notarize.sh + - name: Create DMG if: steps.check_tag.outputs.exists == 'false' run: | chmod +x .github/scripts/create-dmg.sh .github/scripts/create-dmg.sh ${{ steps.get_version.outputs.version }} + - name: Sign DMG + if: steps.check_tag.outputs.exists == 'false' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + # Reimport certificate for DMG signing + KEYCHAIN_PATH="$RUNNER_TEMP/dmg-signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + chmod +x .github/scripts/sign-dmg.sh + .github/scripts/sign-dmg.sh ${{ steps.get_version.outputs.version }} + + security delete-keychain "$KEYCHAIN_PATH" || true + rm -f "$CERT_PATH" + - name: Create Release if: steps.check_tag.outputs.exists == 'false' uses: softprops/action-gh-release@v1 @@ -77,6 +114,8 @@ jobs: ### Installation Download `TextWave-${{ steps.get_version.outputs.version }}.dmg`, open it, and drag TextWave to your Applications folder. + ✅ **This release is code-signed and notarized by Apple** - no security warnings! + ### Requirements - macOS 10.15 or later - Internet connection for text-to-speech conversion diff --git a/pdf2mp3_gui.py b/pdf2mp3_gui.py index 8d39cfd..2000442 100644 --- a/pdf2mp3_gui.py +++ b/pdf2mp3_gui.py @@ -7,7 +7,7 @@ import webbrowser from pathlib import Path -__version__ = "0.5.2" +__version__ = "0.6.0" def get_resource_path(filename):