diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b81221d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: "🐛 Bug Report" +description: Report a bug or unexpected behavior +labels: [ "bug" ] +body: +- type: textarea + id: description + attributes: + label: Description + description: What happened? What did you expect to happen? + validations: + required: true +- type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce the issue? + placeholder: | + 1. Open YouTube in Safari + 2. Play a video + 3. Switch to another tab + 4. ... + validations: + required: true +- type: input + id: macos-version + attributes: + label: macOS Version + placeholder: "e.g. 15.4" + validations: + required: true +- type: input + id: safari-version + attributes: + label: Safari Version + placeholder: "e.g. 18.3" + validations: + required: true +- type: input + id: app-version + attributes: + label: AutoPiP Version + description: Shown in the app menu under "About AutoPiP" + placeholder: "e.g. 2.0.0" + validations: + required: true +- type: textarea + id: additional + attributes: + label: Additional Context + description: Screenshots, screen recordings, or anything else that might help. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..fc7ef62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: "✨ Feature Request" +description: Suggest an idea or improvement +labels: [ "enhancement" ] +body: +- type: textarea + id: description + attributes: + label: Description + description: What would you like to see added or changed? + validations: + required: true +- type: textarea + id: use-case + attributes: + label: Use Case + description: Why is this feature useful? What problem does it solve? + validations: + required: true +- type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any workarounds or alternative approaches? diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..a2780eb --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +config-variables: null + +paths: + .github/workflows/build-release.yml: + ignore: + # actionlint 1.7.12 still models the old v3 schema. Current v3 uses + # client-id and marks app-id as deprecated. + - '(missing input "app-id"|input "client-id" is not defined).*actions/create-github-app-token@v3' \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..657d74c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Description + + + +## Type of Change + +- [ ] 🐛 Bug fix +- [ ] ✨ New feature +- [ ] 🔧 Refactoring +- [ ] 📝 Documentation +- [ ] 🏗️ CI/CD +- [ ] 🔒 Security + +## Checklist + +- [ ] I have tested my changes locally +- [ ] I have updated `semver.txt` (if this change affects the released version) +- [ ] My changes do not introduce new warnings or errors +- [ ] I have updated documentation where necessary + +## Related Issues + + \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..dc2308f --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,383 @@ +name: Build and Release + +on: + push: + branches: [main, 'feature/*'] + paths-ignore: + # Documentation and repository metadata do not change the shipped app. + - '**.md' + - 'LICENSE' + - 'renovate.json' + - '.gitignore' + - '.github/CODEOWNERS' + - '.github/dependabot.yml' + - '.github/FUNDING.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/workflows/tests.yml' + + # Test-only changes are verified by tests.yml but do not need a release. + - 'AutoPiPTests/**' + - 'AutoPiPUITests/**' + - 'tests/**' + + # Generated by this workflow; ignoring it prevents a release loop. + - 'appcast.xml' + workflow_dispatch: + +permissions: + contents: read + +env: + CREATE_DMG_VERSION: '1.3.0' + SPARKLE_VERSION: '2.9.3' + +jobs: + check: + name: Check release trigger + runs-on: ubuntu-latest + outputs: + should-build: ${{ steps.release-trigger.outputs.should-build }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Decide whether to build + id: release-trigger + env: + ACTOR: ${{ github.actor }} + BEFORE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} + run: | + case "$ACTOR" in + "github-actions[bot]"|"autopip-ci-bot"|"autopip-ci-bot[bot]") + echo "should-build=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping release triggered by $ACTOR" + exit 0 + ;; + esac + + if [ "$EVENT_NAME" = "workflow_dispatch" ] || [ "$REF_NAME" != "main" ]; then + echo "should-build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + echo "should-build=true" >> "$GITHUB_OUTPUT" + elif git diff --quiet "$BEFORE" "$HEAD_SHA" -- semver.txt; then + echo "should-build=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping main build because semver.txt did not change" + else + echo "should-build=true" >> "$GITHUB_OUTPUT" + fi + + release: + name: Build and publish + needs: check + if: needs.check.outputs.should-build == 'true' + runs-on: macos-15 + timeout-minutes: 30 + permissions: + contents: write + concurrency: + # Tags and the canonical appcast are shared across all release branches. + group: build-and-release + cancel-in-progress: false + + steps: + - name: Generate GitHub App token + id: app-token + if: vars.CLIENT_ID != '' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token || github.token }} + + - name: Load canonical appcast + if: github.ref_name != 'main' + run: | + git fetch origin main + git show origin/main:appcast.xml > appcast.xml + + - name: Prepare release metadata + id: release + env: + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + run: | + VERSION=$(head -1 semver.txt | tr -d '[:space:]') + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid semver in semver.txt: '$VERSION'" + exit 1 + fi + + awk ' + /^---$/ { found = 1; next } + /^## [0-9]+\.[0-9]+\.[0-9]+$/ { exit } + found { print } + ' semver.txt | sed '/^[[:space:]]*$/d' > "$RUNNER_TEMP/changelog.txt" + + if [ "$REF_NAME" = "main" ]; then + TAG="v${VERSION}" + CHANNEL="stable" + PRERELEASE=false + else + TAG=$(git tag --points-at "$GITHUB_SHA" \ + | grep -E "^v${VERSION}-beta[0-9]+$" \ + | sort -V \ + | tail -1 || true) + if [ -z "$TAG" ]; then + LATEST=$(git tag -l "v${VERSION}-beta*" \ + | sed "s/v${VERSION}-beta//" \ + | grep -E '^[0-9]+$' \ + | sort -n \ + | tail -1 || true) + BETA_NUMBER=$(( ${LATEST:-0} + 1 )) + TAG="v${VERSION}-beta${BETA_NUMBER}" + else + echo "Reusing $TAG for a retry of $GITHUB_SHA" + fi + CHANNEL="beta" + PRERELEASE=true + fi + + # Keep Sparkle's internal version increasing across beta and stable runs. + BUILD=$(( RUN_NUMBER + 10 )) + + if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then + TAG_SHA=$(git rev-list -n 1 "$TAG") + if [ "$TAG_SHA" != "$GITHUB_SHA" ]; then + echo "::error::Tag $TAG already points to $TAG_SHA; refusing to move it" + exit 1 + fi + fi + + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "channel=$CHANNEL" + echo "prerelease=$PRERELEASE" + echo "build=$BUILD" + } >> "$GITHUB_OUTPUT" + + echo "### ${CHANNEL} release - ${TAG} (${BUILD})" >> "$GITHUB_STEP_SUMMARY" + cat "$RUNNER_TEMP/changelog.txt" >> "$GITHUB_STEP_SUMMARY" + + - name: Set project version + env: + BUILD: ${{ steps.release.outputs.build }} + VERSION: ${{ steps.release.outputs.version }} + run: | + sed -i '' "s/MARKETING_VERSION = .*;/MARKETING_VERSION = ${VERSION};/" \ + AutoPiP.xcodeproj/project.pbxproj + sed -i '' "s/CURRENT_PROJECT_VERSION = .*;/CURRENT_PROJECT_VERSION = ${BUILD};/" \ + AutoPiP.xcodeproj/project.pbxproj + sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ + "AutoPiP Extension/Resources/manifest.json" + + - name: Resolve Swift packages + run: xcodebuild -resolvePackageDependencies -project AutoPiP.xcodeproj -scheme AutoPiP + + - name: Run tests + run: | + node --test tests/*.test.js + xcodebuild test \ + -project AutoPiP.xcodeproj \ + -scheme AutoPiP \ + -destination 'platform=macOS' \ + -only-testing:AutoPiPTests \ + CODE_SIGNING_ALLOWED=NO + + - name: Import signing certificate + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + run: | + if [ -z "$BUILD_CERTIFICATE_BASE64" ] || [ -z "$P12_PASSWORD" ]; then + echo "::error::Code-signing secrets are not configured" + exit 1 + fi + + CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Archive + run: | + xcodebuild archive \ + -project AutoPiP.xcodeproj \ + -scheme AutoPiP \ + -configuration Release \ + -archivePath "$RUNNER_TEMP/AutoPiP.xcarchive" + + - name: Create DMG + run: | + APP_PATH="$RUNNER_TEMP/AutoPiP.xcarchive/Products/Applications/AutoPiP.app" + if [ ! -d "$APP_PATH" ]; then + echo "::error::AutoPiP.app was not produced by the archive" + exit 1 + fi + + CREATE_DMG_ARCHIVE="$RUNNER_TEMP/create-dmg.tar.gz" + curl --fail --location --silent --show-error \ + "https://github.com/create-dmg/create-dmg/archive/refs/tags/v${CREATE_DMG_VERSION}.tar.gz" \ + --output "$CREATE_DMG_ARCHIVE" + tar -xzf "$CREATE_DMG_ARCHIVE" -C "$RUNNER_TEMP" + + "$RUNNER_TEMP/create-dmg-${CREATE_DMG_VERSION}/create-dmg" \ + --volname "AutoPiP" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "AutoPiP.app" 175 190 \ + --app-drop-link 425 190 \ + "$RUNNER_TEMP/AutoPiP.dmg" \ + "$APP_PATH" + + - name: Sign DMG and update appcast + env: + BUILD: ${{ steps.release.outputs.build }} + CHANNEL: ${{ steps.release.outputs.channel }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + if [ -z "$SPARKLE_PRIVATE_KEY" ]; then + echo "::error::SPARKLE_PRIVATE_KEY is not configured" + exit 1 + fi + + DMG_PATH="$RUNNER_TEMP/AutoPiP.dmg" + SPARKLE_ARCHIVE="$RUNNER_TEMP/sparkle.tar.xz" + curl --fail --location --silent --show-error \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" \ + --output "$SPARKLE_ARCHIVE" + mkdir "$RUNNER_TEMP/sparkle" + tar -xJf "$SPARKLE_ARCHIVE" -C "$RUNNER_TEMP/sparkle" + + KEY_FILE="$RUNNER_TEMP/sparkle_ed_key" + printf '%s\n' "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE" + SIGN_OUTPUT=$("$RUNNER_TEMP/sparkle/bin/sign_update" "$DMG_PATH" --ed-key-file "$KEY_FILE" 2>&1) || { + rm -f "$KEY_FILE" + echo "::error::Sparkle signing failed" + echo "$SIGN_OUTPUT" + exit 1 + } + rm -f "$KEY_FILE" + + SIGNATURE=$(printf '%s\n' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p') + if [ -z "$SIGNATURE" ]; then + echo "::error::Sparkle did not return an EdDSA signature" + exit 1 + fi + + python3 scripts/update_appcast.py \ + --appcast appcast.xml \ + --changelog "$RUNNER_TEMP/changelog.txt" \ + --version "$VERSION" \ + --tag "$TAG" \ + --build "$BUILD" \ + --channel "$CHANNEL" \ + --signature "$SIGNATURE" \ + --length "$(stat -f%z "$DMG_PATH")" + xmllint --noout appcast.xml + cp appcast.xml "$RUNNER_TEMP/appcast.xml" + + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: AutoPiP-${{ steps.release.outputs.tag }} + path: ${{ runner.temp }}/AutoPiP.dmg + if-no-files-found: error + + - name: Prepare release notes + env: + PRERELEASE: ${{ steps.release.outputs.prerelease }} + run: | + NOTES="$RUNNER_TEMP/release_notes.md" + if [ "$PRERELEASE" = "true" ]; then + { + echo "> [!WARNING]" + echo "> **This is a beta release.** It may be unstable or incomplete." + echo + } > "$NOTES" + else + : > "$NOTES" + fi + + if [ -s "$RUNNER_TEMP/changelog.txt" ]; then + { + echo "## Changelog" + echo + cat "$RUNNER_TEMP/changelog.txt" + echo + } >> "$NOTES" + fi + + cat >> "$NOTES" <<'INSTALLATION' + ## Installation + + This release is not notarized by Apple. After downloading `AutoPiP.dmg`, + macOS may require **System Settings > Privacy & Security > Open Anyway** + for both the disk image and the app. Then copy AutoPiP to Applications, + open it, and enable the extension in Safari Settings. + INSTALLATION + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.release.outputs.tag }} + target_commitish: ${{ github.sha }} + name: ${{ steps.release.outputs.tag }} + files: ${{ runner.temp }}/AutoPiP.dmg + prerelease: ${{ steps.release.outputs.prerelease == 'true' }} + body_path: ${{ runner.temp }}/release_notes.md + fail_on_unmatched_files: true + overwrite_files: true + token: ${{ steps.app-token.outputs.token || github.token }} + + - name: Publish canonical appcast + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + git fetch origin main + APPCAST_WORKTREE="$RUNNER_TEMP/appcast-main" + git worktree add --detach "$APPCAST_WORKTREE" origin/main + git -C "$APPCAST_WORKTREE" config user.name "github-actions[bot]" + git -C "$APPCAST_WORKTREE" config user.email "github-actions[bot]@users.noreply.github.com" + cp "$RUNNER_TEMP/appcast.xml" "$APPCAST_WORKTREE/appcast.xml" + git -C "$APPCAST_WORKTREE" add appcast.xml + + if git -C "$APPCAST_WORKTREE" diff --cached --quiet; then + echo "appcast.xml is already current" + exit 0 + fi + + git -C "$APPCAST_WORKTREE" commit -m "chore: update appcast for ${TAG}" + git -C "$APPCAST_WORKTREE" push origin HEAD:main + + - name: Cleanup keychain + if: always() + run: | + rm -f "$RUNNER_TEMP/build_certificate.p12" "$RUNNER_TEMP/sparkle_ed_key" + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3ecde48 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + pull_request: + push: + branches: [main, 'feature/*'] + paths: + - '.github/actionlint.yaml' + - '.github/workflows/build-release.yml' + - '.github/workflows/tests.yml' + - 'AutoPiP/**' + - 'AutoPiP Extension/**' + - 'AutoPiPTests/**' + - 'scripts/**' + - 'tests/**' + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Run JavaScript tests + run: node --test tests/*.test.js + + swift: + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + - name: Run Swift unit tests + run: | + xcodebuild test \ + -project AutoPiP.xcodeproj \ + -scheme AutoPiP \ + -destination 'platform=macOS' \ + -only-testing:AutoPiPTests \ + CODE_SIGNING_ALLOWED=NO \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5cf0669..05955bf 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ playground.xcworkspace .build/ +# Python release tooling +__pycache__/ +*.py[cod] + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..589083e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,324 @@ +# AGENTS.md + +Operational guidance for coding agents working on **AutoPiP for Safari**. This +file complements `README.md`, `BUILD.md`, and `CONTRIBUTING.md`. Read it before +changing code, release automation, permissions, or persistent settings. + +> **Language:** Write code, identifiers, comments, commit messages, issues, and +> pull requests in English. Maintainer-facing conversation may use the language +> chosen by the maintainer. + +--- + +## 1. Project Overview + +AutoPiP is a **Safari Web Extension** with a small **Cocoa host app** for macOS +13.5 and later. It automatically enters Picture-in-Picture for HTML5 video when +the user changes tabs or windows, or scrolls a YouTube video out of view. + +The Xcode project contains two product targets plus unit and UI test targets: + +```text +AutoPiP/ # Cocoa host app + AppDelegate.swift # App lifecycle and update controller + ViewController.swift # WKWebView/native message bridge + UpdateController.swift # Sparkle wrapper and beta channel + Resources/Base.lproj/Main.html # Host UI + Resources/{Script.js,Style.css} # Host UI behavior and styling + +AutoPiP Extension/ # Safari Web Extension (Manifest V2) + SafariWebExtensionHandler.swift # Native message handler + Resources/manifest.json # Permissions and extension wiring + Resources/content.js # Video discovery and PiP behavior + Resources/popup.{html,css,js} # Safari toolbar popup + Resources/_locales/en/messages.json # Maintained localization + Resources/images/ # Extension assets + +AutoPiPTests/ # Swift unit tests +AutoPiPUITests/ # macOS UI-test target +tests/ # Dependency-free Node.js tests +scripts/update_appcast.py # Structured Sparkle feed updater +.github/workflows/tests.yml # Pull-request and branch tests +.github/workflows/build-release.yml # Beta and stable publishing +``` + +Important constraints: + +- Automatic PiP without a user gesture relies on Safari/WebKit behavior. +- Extension settings stay in `browser.storage.local`. +- The extension must not make outbound network requests or collect telemetry. +- The host app accesses GitHub only through Sparkle for update checks/downloads. +- Sparkle is the only Swift package dependency and is resolved through SwiftPM. +- See `PRIVACY.md` before changing permissions or data handling. + +--- + +## 2. Local Setup and Commands + +| Purpose | Command | +| ------- | ------- | +| Clone and open | `git clone https://github.com/vordenken/AutoPiP.git && cd AutoPiP && open AutoPiP.xcodeproj` | +| Resolve Swift packages | `xcodebuild -resolvePackageDependencies -project AutoPiP.xcodeproj -scheme AutoPiP` | +| Run JavaScript tests | `node --test tests/*.test.js` | +| Run Swift unit tests | `xcodebuild test -project AutoPiP.xcodeproj -scheme AutoPiP -destination 'platform=macOS,arch=arm64' -only-testing:AutoPiPTests CODE_SIGNING_ALLOWED=NO` | +| Validate workflows | `actionlint .github/workflows/build-release.yml .github/workflows/tests.yml` | +| Validate the appcast | `xmllint --noout appcast.xml` | +| Build an unsigned archive | `xcodebuild archive -project AutoPiP.xcodeproj -scheme AutoPiP -configuration Release CODE_SIGNING_ALLOWED=NO` | +| Encode a signing certificate | `base64 -i certificate.p12 \| pbcopy` | + +Build and run the signed app from Xcode with the `AutoPiP` scheme. Node.js 24 is +used in CI. There is no npm package, bundler, transpiler, or formatter setup. +JavaScript is plain ES2020, and Swift follows normal Xcode formatting. + +--- + +## 3. Architecture and State + +- `content.js` owns automatic and manual PiP state transitions. +- `popup.js` persists extension settings and broadcasts changes to open tabs. +- `ViewController.swift` receives host-page messages and forwards update settings + to `UpdateController`. +- `UpdateController.swift` owns Sparkle preferences and allowed beta channels. +- `OnboardingCompleted` and `BetaUpdatesEnabled` are stored in `UserDefaults`. +- Extension preferences, lists, and shortcut configuration are stored in + `browser.storage.local`. + +Treat storage keys and native message strings as contracts. Changes may require +migration logic and coordinated edits on both sides of the bridge. + +--- + +## 4. Branches, Commits, and Releases + +### Branches + +- `main` is stable. A push releases only when `semver.txt` changed. +- A release-relevant push to `feature/*` creates the next immutable beta tag, + `vX.Y.Z-betaN`. +- Documentation-only, repository-metadata, test-only, and generated appcast + changes do not trigger a release. +- Other branch names do not trigger the release workflow. Use `feature/*` when a + beta build is required. +- Long-lived branches should fetch and merge `origin/main` regularly because the + release bot advances `main` with appcast commits. + +### Commits and Pull Requests + +- Use a short imperative subject, preferably no longer than 72 characters. +- Conventional Commits are welcome but not required. +- Keep each commit focused on one logical change. +- Open pull requests against `main`; do not commit directly to `main`. +- Explain what changed and why. Include screenshots or recordings for UI work. +- Obtain maintainer review before merging. + +### Version Source of Truth + +`semver.txt` contains the release version and changelog: + +```text +X.Y.Z +--- +### Section +- Current release entry + +## X.Y.(Z-1) +- Previous release history +``` + +- Line 1 must be strict `X.Y.Z` SemVer. +- `---` starts the current release notes. +- A heading matching `## X.Y.Z` starts historical notes. +- Preserve all historical sections. +- Do not edit release versions for individual CI runs; the workflow injects + them. Keep the checked-in `CURRENT_PROJECT_VERSION` aligned with the latest + published build so local development builds are not offered older updates. +- Do not edit `appcast.xml` manually. `scripts/update_appcast.py` updates the + canonical feed on `main`, keeps all stable entries, and retains five betas. +- Do not create, move, or force-push release tags unless explicitly requested. + +### Release Pipeline + +The release workflow: + +1. Selects the stable or beta channel from the branch name. +2. Reads version and release notes from `semver.txt`. +3. Runs JavaScript and Swift unit tests. +4. Archives and signs the app, then creates the DMG. +5. Signs the DMG with Sparkle's EdDSA tool. +6. Publishes the GitHub Release. +7. Updates the canonical appcast on `main`. + +Release jobs are globally serialized because all branches share tags and one +appcast. Running releases are not cancelled. GitHub Actions use floating major +tags, and the Sparkle tools use `SPARKLE_VERSION`, which tests require to match +`Package.resolved`. See `BUILD.md` for secrets and branch-protection setup. + +--- + +## 5. Code Conventions + +### Swift + +- Use four-space indentation and Xcode's standard style. +- Keep identifiers, comments, and user-facing strings in English. +- Prefer the narrowest practical access level (`private` or `fileprivate`). +- Use `os_log` for diagnostics; do not add `print()` or `NSLog()` calls. +- Dispatch UI updates to the main queue from SafariServices callbacks. +- Prefer `guard let`; force unwrap only compile-time-guaranteed bundle resources. +- Guard versioned macOS APIs with `if #available` and provide a fallback where + the deployment target requires one. +- Prefer recoverable errors and diagnostic logging over fatal termination. + +### JavaScript + +- Use vanilla ES2020 and the `browser.*` WebExtension API. +- Keep configuration constants near the beginning of the file. +- Use `const` by default and `let` for mutable state. +- Route diagnostic output through the debug-logging mechanism; do not add + unconditional `console.log` calls. +- Wrap storage reads and writes in the existing safe storage helpers. +- Never use `eval()` or assign untrusted data to `innerHTML`; use `textContent`. +- Check `event.isTrusted` for visibility and focus events. +- Preserve the short blur delay that distinguishes window changes from internal + focus changes such as clicking YouTube live chat. +- Treat new manifest permissions as privacy-sensitive changes. + +### HTML and CSS + +- Keep CSS in `popup.css` or `Style.css`, not inline HTML attributes. +- Do not load external fonts, scripts, or CDN assets. +- Keep extension resource paths relative to the `Resources` directory. +- Preserve the established compact Safari-style UI unless a redesign is agreed. + +### Manifest and Localization + +- Keep `manifest_version: 2` until Safari's Manifest V3 support is explicitly + adopted by the maintainer. +- Add extension-facing strings to `Resources/_locales/en/messages.json`. +- English is the only actively maintained locale; additional translations are + welcome but must not block English updates. + +--- + +## 6. Validation Before a Pull Request + +Run the automated checks relevant to the change: + +```bash +node --test tests/*.test.js +xcodebuild test \ + -project AutoPiP.xcodeproj \ + -scheme AutoPiP \ + -destination 'platform=macOS,arch=arm64' \ + -only-testing:AutoPiPTests \ + CODE_SIGNING_ALLOWED=NO +``` + +For workflow or release changes, also run: + +```bash +actionlint .github/workflows/build-release.yml .github/workflows/tests.yml +xmllint --noout appcast.xml +``` + +The automated suite exercises content-script events, popup state, onboarding +messages, native onboarding parsing, and appcast transformations. It does not +replace Safari integration testing. + +Perform the applicable Safari smoke tests before release: + +1. Build and run in Xcode with no build warnings. +2. Enable AutoPiP under Safari Settings > Extensions and grant website access. +3. Play a YouTube video and switch tabs; PiP should enter and then exit on return. +4. Switch windows; internal page focus changes must not trigger PiP. +5. Scroll a YouTube video out of view and back into view. +6. Disable AutoPiP in the popup; active automatic PiP should close. +7. Verify blacklist and whitelist behavior for the current site. +8. Verify the configured keyboard shortcut, including the standard API fallback. +9. Clear `browser.storage.local`, reload, and verify defaults are restored. +10. Exercise clean-install onboarding and stable/beta Sparkle update checks. +11. Confirm the content-script console has no unexpected errors. + +--- + +## 7. High-Value Code Anchors + +- Automatic PiP triggers: `AutoPiP Extension/Resources/content.js` +- Video selection and cache: `getVideo()` in `content.js` +- Extension storage and messages: safe storage helpers and `messageHandlers` +- Popup/content bridge: `popup.js` and `content.js` +- Host onboarding: `AutoPiP/Resources/Base.lproj/Main.html`, `Script.js`, and + `ViewController.swift` +- Sparkle preferences and channels: `AutoPiP/UpdateController.swift` +- Release source of truth: `semver.txt` +- Release orchestration: `.github/workflows/build-release.yml` +- Appcast transformation: `scripts/update_appcast.py` +- Automated checks: `.github/workflows/tests.yml`, `tests/`, and `AutoPiPTests/` + +--- + +## 8. Change Boundaries for Agents + +### Allowed Without Prior Approval + +- Focused bug fixes and behavior-preserving refactors with passing tests. +- Reliability and performance improvements to content-script DOM handling. +- New message handlers when the sending and receiving sides are updated together. +- Tests and documentation that reflect verified behavior. +- Compatibility documentation for streaming sites that were actually tested. + +### Ask the Maintainer First + +- New or broader manifest permissions. +- Storage-schema or native-message protocol changes that need migration. +- Substantial host or popup UI redesigns. +- Migration to Manifest V3. +- New Swift packages, npm dependencies, or external services. +- Release workflow, version parser, signing, or appcast behavior changes. +- Deleting releases, moving tags, or changing branch-protection behavior. + +### Never Do + +- Add tracking, telemetry, analytics, or unrelated network calls. +- Commit secrets, tokens, certificates, private keys, or Apple IDs. +- Add `print()` or `NSLog()` diagnostics to shipped Swift code. +- Remove `semver.txt` history or edit generated appcast entries by hand. +- Force-push release tags or commit directly to `main`. +- Introduce large unrelated formatting changes. +- Change or remove copyright or license headers without explicit instruction. + +--- + +## 9. Common Failure Modes + +- **No signing certificate:** Configure the local development team in Xcode or + use `CODE_SIGNING_ALLOWED=NO` for tests and unsigned validation builds. +- **Extension missing in Safari:** Restart Safari or macOS. The legacy fallback is + `defaults write com.apple.Safari WebKitExtensionsEnabled -bool true`. +- **PiP unavailable on a site:** Check whether the page sets + `disablePictureInPicture` and inspect debug logs before changing selectors. +- **Release skipped:** Test-only/documentation-only changes are intentionally + ignored. On `main`, `semver.txt` must change. Other branches must use the + `feature/*` naming convention or `workflow_dispatch`. +- **Appcast push rejected:** Verify the GitHub App credentials, Contents write + permission, installation, and branch-protection bypass described in `BUILD.md`. +- **Sparkle version mismatch:** Keep `SPARKLE_VERSION` in the release workflow in + sync with `Package.resolved`; the release tests enforce this. +- **Sparkle update not visible:** Check channel selection, appcast caching, build + numbers, and the enclosure URL before republishing. +- **Actionlint reports `app-id`/`client-id`:** `.github/actionlint.yaml` suppresses + only the stale schema warning for the current floating v3 action. + +--- + +## 10. Escalation Checklist + +Ask before proceeding when a change affects permissions, privacy, persisted +settings, native message compatibility, release/signing behavior, dependencies, +or the product's UI structure. For ordinary focused fixes, tests, and small +refactors, proceed on a feature branch and open a pull request. + +--- + +*Last updated: 2026-08-31. Keep this file synchronized with architecture, +testing, manifest, and release-workflow changes.* diff --git a/AutoPiP Extension/Resources/content.js b/AutoPiP Extension/Resources/content.js index aa520e9..1ea5c6d 100644 --- a/AutoPiP Extension/Resources/content.js +++ b/AutoPiP Extension/Resources/content.js @@ -574,12 +574,29 @@ function togglePiP() { disablePiP(); } else { debugLog('Keyboard shortcut: enabling PiP (manual override)'); - try { - if (!setWebkitPresentationMode(video, 'picture-in-picture')) { - debugLog('PiP not supported on this video element'); + // Prefer the W3C requestPictureInPicture API here: unlike webkitSetPresentationMode, + // it uses the video's natural (intrinsic) dimensions rather than its CSS-rendered size. + // This prevents the "video small in corner" bug when e.g. YouTube's mini-player is + // active and the video element is currently rendered at a small size. + if (typeof video.requestPictureInPicture === 'function') { + video.requestPictureInPicture().catch(error => { + debugLog('requestPictureInPicture failed, falling back to webkit API:', error.message); + try { + if (!setWebkitPresentationMode(video, 'picture-in-picture')) { + debugLog('PiP not supported on this video element'); + } + } catch (e) { + console.error('[AutoPiP] Keyboard PiP toggle exception:', e.message || e); + } + }); + } else { + try { + if (!setWebkitPresentationMode(video, 'picture-in-picture')) { + debugLog('PiP not supported on this video element'); + } + } catch (error) { + console.error('[AutoPiP] Keyboard PiP toggle exception:', error.message || error); } - } catch (error) { - console.error('[AutoPiP] Keyboard PiP toggle exception:', error.message || error); } } } diff --git a/AutoPiP Extension/Resources/manifest.json b/AutoPiP Extension/Resources/manifest.json index 3062195..867dd64 100644 --- a/AutoPiP Extension/Resources/manifest.json +++ b/AutoPiP Extension/Resources/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "AutoPiP", - "version": "2.0.0", + "version": "2.1.0", "default_locale": "en", "description": "Automatically enables Picture-in-Picture mode when switching tabs, windows or scrolling down YouTube videos", "icons": { diff --git a/AutoPiP.xcodeproj/project.pbxproj b/AutoPiP.xcodeproj/project.pbxproj index 177ff77..dd3e4b0 100644 --- a/AutoPiP.xcodeproj/project.pbxproj +++ b/AutoPiP.xcodeproj/project.pbxproj @@ -414,7 +414,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = "AutoPiP Extension/AutoPiP_Extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = NRKZZ9TFF7; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -428,7 +428,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -448,7 +448,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = "AutoPiP Extension/AutoPiP_Extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = NRKZZ9TFF7; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -462,7 +462,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -612,7 +612,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = NRKZZ9TFF7; ENABLE_HARDENED_RUNTIME = YES; EXCLUDED_SOURCE_FILE_NAMES = ( @@ -634,7 +634,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -659,7 +659,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = NRKZZ9TFF7; ENABLE_HARDENED_RUNTIME = YES; EXCLUDED_SOURCE_FILE_NAMES = ( @@ -681,7 +681,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -706,7 +706,7 @@ DEVELOPMENT_TEAM = NRKZZ9TFF7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.vd.AutoPiPTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -724,7 +724,7 @@ DEVELOPMENT_TEAM = NRKZZ9TFF7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.vd.AutoPiPTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -741,7 +741,7 @@ DEVELOPMENT_TEAM = NRKZZ9TFF7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.vd.AutoPiPUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -758,7 +758,7 @@ DEVELOPMENT_TEAM = NRKZZ9TFF7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.vd.AutoPiPUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/AutoPiP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AutoPiP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6e450d9..190626a 100644 --- a/AutoPiP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AutoPiP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", - "version" : "2.9.0" + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" } } ], diff --git a/AutoPiP/AppDelegate.swift b/AutoPiP/AppDelegate.swift index 3b8e600..261b139 100644 --- a/AutoPiP/AppDelegate.swift +++ b/AutoPiP/AppDelegate.swift @@ -10,7 +10,7 @@ import Cocoa @main class AppDelegate: NSObject, NSApplicationDelegate { - private let updateController = UpdateController() + let updateController = UpdateController() func applicationDidFinishLaunching(_ notification: Notification) { setupMenu() diff --git a/AutoPiP/Info.plist b/AutoPiP/Info.plist index ce061c6..7b099db 100644 --- a/AutoPiP/Info.plist +++ b/AutoPiP/Info.plist @@ -2,6 +2,8 @@ + SUEnableAutomaticChecks + SUEnableInstallerLauncherService SUFeedURL diff --git a/AutoPiP/Resources/Base.lproj/Main.html b/AutoPiP/Resources/Base.lproj/Main.html index 3410697..dd5a5cf 100644 --- a/AutoPiP/Resources/Base.lproj/Main.html +++ b/AutoPiP/Resources/Base.lproj/Main.html @@ -3,17 +3,119 @@ - - - AutoPiP Icon -

You can turn on AutoPiP’s extension in Safari Extensions preferences.

-

AutoPiP’s extension is currently on. You can turn it off in Safari Extensions preferences.

-

AutoPiP’s extension is currently off. You can turn it on in Safari Extensions preferences.

- + + + + + + + + + + + + + diff --git a/AutoPiP/Resources/Script.js b/AutoPiP/Resources/Script.js index eaee639..83a23ea 100644 --- a/AutoPiP/Resources/Script.js +++ b/AutoPiP/Resources/Script.js @@ -1,22 +1,135 @@ +/* ===== Onboarding Navigation ===== */ + +function showPage(id) { + document.querySelectorAll('.page').forEach(function(p) { p.classList.add('hidden'); }); + document.getElementById(id).classList.remove('hidden'); +} + +function startOnboarding() { + showPage('page-welcome'); +} + +/* ===== Version (called from Swift) ===== */ + +function setVersion(v) { + var text = 'v' + v; + var el = document.getElementById('version-label'); + if (el) el.textContent = text; + var el2 = document.getElementById('main-version-label'); + if (el2) el2.textContent = text; +} + +/* ===== Main view (called from Swift) ===== */ + function show(enabled, useSettingsInsteadOfPreferences) { + showPage('page-main'); + if (useSettingsInsteadOfPreferences) { - document.getElementsByClassName('state-on')[0].innerText = "AutoPiP’s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; - document.getElementsByClassName('state-off')[0].innerText = "AutoPiP’s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; - document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on AutoPiP’s extension in the Extensions section of Safari Settings."; - document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…"; + document.getElementsByClassName('state-on')[0].innerText = "AutoPiP\u2019s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-off')[0].innerText = "AutoPiP\u2019s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; + document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on AutoPiP\u2019s extension in the Extensions section of Safari Settings."; + document.getElementsByClassName('open-preferences')[0].innerText = "Open Safari Settings\u2026"; } if (typeof enabled === "boolean") { - document.body.classList.toggle(`state-on`, enabled); - document.body.classList.toggle(`state-off`, !enabled); + document.body.classList.toggle('state-on', enabled); + document.body.classList.toggle('state-off', !enabled); } else { - document.body.classList.remove(`state-on`); - document.body.classList.remove(`state-off`); + document.body.classList.remove('state-on'); + document.body.classList.remove('state-off'); } } -function openPreferences() { - webkit.messageHandlers.controller.postMessage("open-preferences"); +function setUpdateSettings(settings) { + document.getElementById('auto-check-toggle').checked = settings.autoCheck; + document.getElementById('auto-download-toggle').checked = settings.autoDownload; + document.getElementById('auto-download-toggle').disabled = !settings.autoCheck; + document.getElementById('beta-toggle').checked = settings.beta; } -document.querySelector("button.open-preferences").addEventListener("click", openPreferences); +/* ===== Event Listeners ===== */ + +document.addEventListener('DOMContentLoaded', function() { + + /* --- Onboarding Page 1 --- */ + document.getElementById('welcome-next').addEventListener('click', function() { + showPage('page-features'); + }); + + /* --- Onboarding Page 2 (Features) --- */ + document.getElementById('features-back').addEventListener('click', function() { + showPage('page-welcome'); + }); + document.getElementById('features-next').addEventListener('click', function() { + showPage('page-updates'); + }); + + /* --- Onboarding Page 3 (Updates) --- */ + document.getElementById('updates-back').addEventListener('click', function() { + showPage('page-features'); + }); + + document.getElementById('onb-auto-check').addEventListener('change', function() { + var dl = document.getElementById('onb-auto-download'); + dl.disabled = !this.checked; + if (!this.checked) dl.checked = false; + webkit.messageHandlers.controller.postMessage("set-auto-check:" + this.checked); + if (!this.checked) webkit.messageHandlers.controller.postMessage("set-auto-download:false"); + }); + + document.getElementById('onb-auto-download').addEventListener('change', function() { + webkit.messageHandlers.controller.postMessage("set-auto-download:" + this.checked); + }); + + document.getElementById('onb-beta').addEventListener('change', function() { + webkit.messageHandlers.controller.postMessage("set-beta:" + this.checked); + }); + + document.getElementById('onb-check-updates').addEventListener('click', function() { + webkit.messageHandlers.controller.postMessage("check-for-updates"); + }); + + document.getElementById('updates-done').addEventListener('click', function() { + var settings = { + autoCheck: document.getElementById('onb-auto-check').checked, + autoDownload: document.getElementById('onb-auto-download').checked, + beta: document.getElementById('onb-beta').checked + }; + webkit.messageHandlers.controller.postMessage("onboarding-done:" + JSON.stringify(settings)); + }); + + /* --- Support links (open in default browser) --- */ + document.querySelectorAll('.support-links a').forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + webkit.messageHandlers.controller.postMessage("open-url:" + this.href); + }); + }); + + /* --- Main view controls --- */ + document.querySelector("button.open-preferences").addEventListener("click", function() { + webkit.messageHandlers.controller.postMessage("open-preferences"); + }); + + document.getElementById('check-updates-btn').addEventListener('click', function() { + webkit.messageHandlers.controller.postMessage("check-for-updates"); + }); + + document.getElementById('auto-check-toggle').addEventListener('change', function() { + webkit.messageHandlers.controller.postMessage("set-auto-check:" + this.checked); + var dl = document.getElementById('auto-download-toggle'); + dl.disabled = !this.checked; + if (!this.checked) { + dl.checked = false; + webkit.messageHandlers.controller.postMessage("set-auto-download:false"); + } + }); + + document.getElementById('auto-download-toggle').addEventListener('change', function() { + webkit.messageHandlers.controller.postMessage("set-auto-download:" + this.checked); + }); + + document.getElementById('beta-toggle').addEventListener('change', function() { + webkit.messageHandlers.controller.postMessage("set-beta:" + this.checked); + }); +}); diff --git a/AutoPiP/Resources/Style.css b/AutoPiP/Resources/Style.css index cbde9e6..4a93697 100644 --- a/AutoPiP/Resources/Style.css +++ b/AutoPiP/Resources/Style.css @@ -6,40 +6,266 @@ :root { color-scheme: light dark; + --accent-color: rgb(0, 122, 255); + --success-color: #34c759; + --border-color: rgba(128, 128, 128, 0.25); +} - --spacing: 20px; +@media (prefers-color-scheme: dark) { + :root { + --accent-color: #0a84ff; + --success-color: #32d74b; + --border-color: rgba(128, 128, 128, 0.35); + } } -html { +html, body { height: 100%; + margin: 0; + overflow: hidden; } body { + font: -apple-system-short-body; + text-align: center; +} + +.hidden { display: none !important; } + +/* ===== Pages (shared) ===== */ + +.page { display: flex; + flex-direction: column; + height: 100%; +} + +.page-content { + flex: 1; + display: flex; + flex-direction: column; align-items: center; justify-content: center; - flex-direction: column; + padding: 20px 30px 0; + gap: 8px; +} - gap: var(--spacing); - margin: 0 calc(var(--spacing) * 2); - height: 100%; +.page-content.centered { + text-align: center; +} - font: -apple-system-short-body; +.page-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px 14px; +} + +/* ===== Typography ===== */ + +h1 { + font-size: 1.3em; + margin: 0; + font-weight: 600; +} + +h2 { + font-size: 1.1em; + margin: 0 0 2px; + font-weight: 600; +} + +.subtitle { + margin: 0; + font-size: 0.85em; + opacity: 0.7; + line-height: 1.4; +} + +.hint { + margin: 0; + font-size: 0.75em; + opacity: 0.55; +} + +.permission-hint { text-align: center; + line-height: 1.4; +} + +/* ===== Buttons ===== */ + +.btn { + font-size: 0.85em; + padding: 5px 14px; + border-radius: 5px; + border: none; + cursor: default; + background: rgba(128, 128, 128, 0.2); +} + +.btn:active { + opacity: 0.7; +} + +.btn.primary { + background-color: var(--accent-color); + color: white; + font-weight: 500; +} + + + +/* ===== Toggle Switch (matches popup) ===== */ + +.toggle { + position: relative; + width: 38px; + height: 22px; + -webkit-appearance: none; + appearance: none; + background-color: rgba(128, 128, 128, 0.3); + border-radius: 11px; + outline: none; + cursor: default; + transition: background-color 0.25s ease; + margin: 0; + flex-shrink: 0; +} + +.toggle:checked { + background-color: var(--success-color); +} + +.toggle::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: white; + top: 2px; + left: 2px; + transition: transform 0.25s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.toggle:checked::before { + transform: translateX(16px); } +.toggle:active::before { + width: 22px; +} + +/* ===== Setting rows ===== */ + +.settings-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; + font-size: 0.85em; +} + +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.setting-row.sub { + padding-left: 18px; +} + +.setting-row span { + flex: 1; +} + +/* ===== Feature list (page 2) ===== */ + +.feature-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + text-align: left; +} + +.feature-item { + display: flex; + flex-direction: column; + gap: 1px; + font-size: 0.85em; +} + +.feature-item strong { + font-weight: 600; +} + +.feature-item span { + font-size: 0.88em; + opacity: 0.55; + line-height: 1.3; +} + +/* ===== Support links ===== */ + +.support-links { + display: flex; + gap: 6px; + align-items: center; + font-size: 0.75em; + margin-top: 10px; +} + +.support-links a { + color: var(--accent-color); + text-decoration: none; +} + +.support-links a:hover { + text-decoration: underline; +} + +.dot { + opacity: 0.4; +} + +/* ===== Version label ===== */ + +.version-label { + font-size: 0.7em; + opacity: 0.35; +} + +/* ===== Separator ===== */ + +.separator { + width: 100%; + height: 1px; + background: var(--border-color); + margin: 4px 0; +} + +/* ===== State info text (main view) ===== */ + +.state-info { + margin: 0; + font-size: 0.85em; + opacity: 0.7; + line-height: 1.4; +} + +/* State visibility */ body:not(.state-on, .state-off) :is(.state-on, .state-off) { display: none; } - body.state-on :is(.state-off, .state-unknown) { display: none; } - body.state-off :is(.state-on, .state-unknown) { display: none; } - -button { - font-size: 1em; -} diff --git a/AutoPiP/UpdateController.swift b/AutoPiP/UpdateController.swift index f1c0792..f4800e0 100644 --- a/AutoPiP/UpdateController.swift +++ b/AutoPiP/UpdateController.swift @@ -3,14 +3,22 @@ import Sparkle +private class UpdaterDelegate: NSObject, SPUUpdaterDelegate { + func allowedChannels(for updater: SPUUpdater) -> Set { + UserDefaults.standard.bool(forKey: "BetaUpdatesEnabled") ? Set(["beta"]) : Set() + } +} + class UpdateController { + private let delegate = UpdaterDelegate() private let updaterController: SPUStandardUpdaterController + var updater: SPUUpdater { updaterController.updater } + init() { - // Initialisiere den Updater updaterController = SPUStandardUpdaterController( startingUpdater: true, - updaterDelegate: nil, + updaterDelegate: delegate, userDriverDelegate: nil ) } @@ -18,4 +26,28 @@ class UpdateController { func checkForUpdates() { updaterController.checkForUpdates(nil) } + + var automaticallyChecksForUpdates: Bool { + get { updater.automaticallyChecksForUpdates } + set { + updater.automaticallyChecksForUpdates = newValue + if !newValue { updater.automaticallyDownloadsUpdates = false } + } + } + + var automaticallyDownloadsUpdates: Bool { + get { updater.automaticallyDownloadsUpdates } + set { + if newValue { updater.automaticallyChecksForUpdates = true } + updater.automaticallyDownloadsUpdates = newValue + } + } + + var isBetaUpdatesEnabled: Bool { + get { UserDefaults.standard.bool(forKey: "BetaUpdatesEnabled") } + set { + UserDefaults.standard.set(newValue, forKey: "BetaUpdatesEnabled") + updater.resetUpdateCycleAfterShortDelay() + } + } } diff --git a/AutoPiP/ViewController.swift b/AutoPiP/ViewController.swift index ff83c74..d1d7870 100644 --- a/AutoPiP/ViewController.swift +++ b/AutoPiP/ViewController.swift @@ -11,6 +11,23 @@ import WebKit let extensionBundleIdentifier = "com.vd.AutoPiP.Extension" +struct OnboardingSettings { + let autoCheck: Bool? + let autoDownload: Bool? + let beta: Bool? + + init?(jsonString: String) { + guard let data = jsonString.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + autoCheck = values["autoCheck"] as? Bool + autoDownload = values["autoDownload"] as? Bool + beta = values["beta"] as? Bool + } +} + class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler { @IBOutlet var webView: WKWebView! @@ -26,30 +43,110 @@ class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHan } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in - guard let state = state, error == nil else { - // Insert code to inform the user that something went wrong. - return - } + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + webView.evaluateJavaScript("setVersion('\(version)')") + } + + let onboardingDone = UserDefaults.standard.bool(forKey: "OnboardingCompleted") + if !onboardingDone { + webView.evaluateJavaScript("startOnboarding()") + return + } + + showMainView() + } + + private func showMainView() { + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in DispatchQueue.main.async { + guard let state = state, error == nil else { + self.webView.evaluateJavaScript("show(null, true)") + return + } + if #available(macOS 13, *) { - webView.evaluateJavaScript("show(\(state.isEnabled), true)") + self.webView.evaluateJavaScript("show(\(state.isEnabled), true)") } else { - webView.evaluateJavaScript("show(\(state.isEnabled), false)") + self.webView.evaluateJavaScript("show(\(state.isEnabled), false)") } } } + + if let appDelegate = NSApp.delegate as? AppDelegate { + let uc = appDelegate.updateController + let json = """ + {autoCheck:\(uc.automaticallyChecksForUpdates),autoDownload:\(uc.automaticallyDownloadsUpdates),beta:\(uc.isBetaUpdatesEnabled)} + """ + webView.evaluateJavaScript("setUpdateSettings(\(json))") + } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if (message.body as! String != "open-preferences") { - return; + guard let body = message.body as? String else { return } + + if body == "open-preferences" { + openSafariAndQuit() + return + } + + if body.hasPrefix("open-url:") { + let urlString = String(body.dropFirst("open-url:".count)) + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + return + } + + if body.hasPrefix("onboarding-done:") { + let jsonString = String(body.dropFirst("onboarding-done:".count)) + handleOnboardingDone(jsonString) + return } + guard let uc = (NSApp.delegate as? AppDelegate)?.updateController else { return } + + switch body { + case "check-for-updates": + uc.checkForUpdates() + case let s where s.hasPrefix("set-auto-check:"): + uc.automaticallyChecksForUpdates = s.hasSuffix("true") + case let s where s.hasPrefix("set-auto-download:"): + uc.automaticallyDownloadsUpdates = s.hasSuffix("true") + case let s where s.hasPrefix("set-beta:"): + uc.isBetaUpdatesEnabled = s.hasSuffix("true") + default: + break + } + } + + private func handleOnboardingDone(_ jsonString: String) { + guard let settings = OnboardingSettings(jsonString: jsonString), + let uc = (NSApp.delegate as? AppDelegate)?.updateController else { return } + + if let autoCheck = settings.autoCheck { + uc.automaticallyChecksForUpdates = autoCheck + } + if let autoDownload = settings.autoDownload { + uc.automaticallyDownloadsUpdates = autoDownload + } + if let beta = settings.beta { + uc.isBetaUpdatesEnabled = beta + } + + UserDefaults.standard.set(true, forKey: "OnboardingCompleted") + openSafariAndQuit() + } + + private func openSafariAndQuit() { SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in DispatchQueue.main.async { - NSApplication.shared.terminate(nil) + if error != nil, let safariURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") { + NSWorkspace.shared.openApplication(at: safariURL, configuration: NSWorkspace.OpenConfiguration()) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSApplication.shared.terminate(nil) + } } } } diff --git a/AutoPiPTests/AutoPiPTests.swift b/AutoPiPTests/AutoPiPTests.swift index b2a656a..1d0eea3 100644 --- a/AutoPiPTests/AutoPiPTests.swift +++ b/AutoPiPTests/AutoPiPTests.swift @@ -10,8 +10,28 @@ import Testing struct AutoPiPTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @Test func parsesOnboardingSettings() throws { + let settings = try #require(OnboardingSettings( + jsonString: #"{"autoCheck":true,"autoDownload":false,"beta":true}"# + )) + + #expect(settings.autoCheck == true) + #expect(settings.autoDownload == false) + #expect(settings.beta == true) + } + + @Test func ignoresInvalidSettingTypesIndividually() throws { + let settings = try #require(OnboardingSettings( + jsonString: #"{"autoCheck":true,"autoDownload":"yes"}"# + )) + + #expect(settings.autoCheck == true) + #expect(settings.autoDownload == nil) + #expect(settings.beta == nil) + } + + @Test func rejectsMalformedOnboardingJSON() { + #expect(OnboardingSettings(jsonString: "not-json") == nil) } } diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..dc8ad43 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,91 @@ +# Building & Releasing AutoPiP + +## Build from Source + +```bash +git clone https://github.com/vordenken/AutoPiP.git +cd AutoPiP +open AutoPiP.xcodeproj +``` + +Then build and run the `AutoPiP` scheme in Xcode. + +## How to Release + +AutoPiP is built and released automatically via GitHub Actions. **`semver.txt`** is the single source of truth for version and changelog. + +### semver.txt Format + +```text +2.1.0 +--- +- First changelog entry +- Second changelog entry + +## 2.0.0 +- Previous release notes +``` + +- **Line 1:** Current version (valid [semver](https://semver.org/)) +- **`---`:** Separator +- **Lines after `---`:** Changelog for the current version (Markdown list items, optional headings prefixed by `###`) +- **`## x.y.z`:** Previous release changelogs (preserved history) + +### Workflow + +1. Create a `feature/*` branch from `main` +2. Set the version and changelog in `semver.txt` +3. Push — every release-relevant push to `feature/*` creates a new beta release: + - First push → `v2.1.0-beta1` + - Second push → `v2.1.0-beta2` (auto-incremented from existing tags) + - Beta releases are never overwritten +4. Merge to `main` when ready — creates stable release `v2.1.0` + - On `main`, the workflow only runs when `semver.txt` changes + +Documentation-only, repository metadata, test-only, and generated `appcast.xml` +changes do not trigger releases. +`workflow_dispatch` releases the selected branch using the version from +`semver.txt`: `main` produces a stable release, while `feature/*` produces the +next beta. + +The workflow automatically: + +- Sets the version in the Xcode project and `manifest.json` +- Runs the JavaScript and Swift unit tests before importing signing credentials +- Builds, archives, and creates a DMG +- Signs the DMG with the code signing certificate and Sparkle EdDSA +- Creates a Git tag and GitHub Release with changelog + installation instructions +- Updates the canonical `appcast.xml` on `main` after publishing the release +- Keeps all stable feed entries and the five newest beta entries + +Release jobs are serialized because all branches share one tag namespace and one +canonical appcast. Running jobs are never cancelled, and existing tags are never +moved. The workflow downloads the Sparkle tools at the version pinned by SwiftPM +before signing. + +### GitHub App for Branch Protection + +If `main` has branch protection, the workflow needs a GitHub App to push the appcast update. Create a GitHub App with **Contents: Read & Write** permission, install it on the repo, then: + +- Add variable `CLIENT_ID` with the App's client ID +- Add secret `APP_PRIVATE_KEY` with the App's private key +- Add the App to the branch protection bypass list + +## Repository Secrets + +| Secret / Variable | Required | Description | +| ----------------- | -------- | ----------- | +| `BUILD_CERTIFICATE_BASE64` | **Yes** | Base64-encoded `.p12` signing certificate | +| `P12_PASSWORD` | **Yes** | Password for the `.p12` file | +| `SPARKLE_PRIVATE_KEY` | **Yes** | EdDSA private key for Sparkle update signatures | +| `CLIENT_ID` (variable) | If branch-protected | GitHub App Client ID for pushing to protected branches | +| `APP_PRIVATE_KEY` | If branch-protected | GitHub App private key | + +**Export your signing certificate:** + +```bash +# Export from Keychain Access as .p12, then encode: +base64 -i certificate.p12 | pbcopy +``` + +**Export your Sparkle key:** The EdDSA private key generated by Sparkle's `generate_keys` tool. Store it as the `SPARKLE_PRIVATE_KEY` secret. diff --git a/PRIVACY.md b/PRIVACY.md index cdbe521..0542b76 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,26 +1,48 @@ # Privacy Policy -AutoPiP is designed with privacy in mind and does not collect, store, or transmit any personal data. +AutoPiP is designed with privacy in mind. It does not collect analytics, +telemetry, browsing history, video information, or other personal content. -## Permissions +## Data Collection -- The extension requires permission to "Access all websites" solely to detect video players and enable Picture-in-Picture functionality across different websites -- No data is collected, stored, or shared with third parties -- All functionality operates locally on your device +- **No personal data** is collected by AutoPiP. +- **No usage statistics** are gathered. +- **No cookies** are set or read by AutoPiP. +- **No analytics** are implemented. +- **No advertising** is included. +- **No user tracking** takes place. +- **No personal data** is sold or shared for marketing purposes. -## Data Collection +This does not include the standard technical connection data processed by GitHub +when Sparkle checks for or downloads updates, as described below. + +## Safari Extension + +- The extension requires permission to access websites solely to detect video elements and provide Picture-in-Picture functionality. +- Website access, video detection, preferences, and site lists are processed locally on the device. +- The extension does not send page content, visited URLs, video information, or extension settings to AutoPiP or any third party. + +## Local Storage + +- Extension preferences, blacklist and whitelist entries, and keyboard shortcut settings are stored locally using `browser.storage.local`. +- Onboarding status, beta-channel selection, and Sparkle update preferences are stored locally in macOS `UserDefaults`. +- AutoPiP does not use these settings for analytics, advertising, profiling, or tracking. + +## Software Updates + +- The macOS host app uses the open-source Sparkle framework to check for and download updates. +- Automatic update checks are enabled by default and can be disabled in the AutoPiP app. Automatic downloads and beta updates are also configurable. +- Sparkle retrieves the update feed from `raw.githubusercontent.com` and update packages from AutoPiP releases hosted by GitHub. +- These requests disclose standard connection information to GitHub and its infrastructure, such as the IP address, request time, requested URL, and HTTP metadata required to deliver the update. +- AutoPiP does not enable Sparkle system profiling and does not attach browsing activity, extension settings, or user-generated data to update requests. -- **No personal data** is collected -- **No usage statistics** are gathered -- **No cookies** are used -- **No analytics** are implemented -- **No user tracking** takes place +GitHub processes connection data under its own privacy policy. -## Third-Party Services +## External Links -- The extension does not communicate with any external servers -- No data is shared with third parties +Links to GitHub, Ko-fi, and Buy Me a Coffee are opened in the default browser +only when selected by the user. Those websites have their own privacy policies. For questions about privacy, please open an issue on GitHub. -Last updated: November 28, 2024 +Last updated: August 31, 2026 diff --git a/README.md b/README.md index 255b3ae..15c5af2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

Downloads License + Build PRs Welcome

@@ -25,11 +26,18 @@ A Safari extension that automatically enables Picture-in-Picture (PiP) mode for ## 🚀 Quick Start -1. Download the latest release [here](https://github.com/vordenken/AutoPiP/releases) -2. Install and enable the Safari extension -3. Start watching videos - PiP activates automatically! +> **⚠️ macOS Gatekeeper:** AutoPiP is not notarized by Apple. macOS will block both the DMG and the app with a security warning — this is expected. Follow the steps below to allow them. Once installed, **updates via Sparkle work without this workaround**. -> **Updating:** To receive updates, open the AutoPiP app from time to time. Sparkle checks for updates automatically (once per day). +1. Download the latest `AutoPiP.dmg` from [Releases](https://github.com/vordenken/AutoPiP/releases) +2. Try to open the DMG — macOS will block it with *"cannot be opened because it is from an unidentified developer"* + - Open **System Settings → Privacy & Security**, scroll down and click **"Open Anyway"** + - Open the DMG again and drag `AutoPiP.app` to your Applications folder +3. Try to open `AutoPiP.app` — macOS will block it again with the same warning + - Open **System Settings → Privacy & Security**, scroll down and click **"Open Anyway"** + - Open the app again — it will guide you to enable the Safari extension +4. Enable **AutoPiP** in Safari → Settings → Extensions + +> **Updating:** Open the AutoPiP app occasionally — Sparkle checks for updates automatically (once per day). ## 🎯 Compatibility @@ -41,6 +49,7 @@ A Safari extension that automatically enables Picture-in-Picture (PiP) mode for | Paramount+ | | | | Netflix | | | | Jellyfin | | | +| BiliBili (哔哩哔哩) | | | *AppleTV opens the native app instead of Safari @@ -53,6 +62,10 @@ A Safari extension that automatically enables Picture-in-Picture (PiP) mode for > I wanted to add Chrome/Firefox support but Safari is the only browser that allows calling PiP without user-interaction - So unless this changes, AutoPiP will be Safari only +## 🔨 Building & Releasing + +See [BUILD.md](BUILD.md) for instructions on building from source, releasing, and configuring repository secrets. + ## 🤝 Contributing As this is my first Swift/Xcode project, I welcome: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4a4427d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|:---------:| +| 2.x | ✅ | +| < 2.0 | ❌ | + +## Reporting a Vulnerability + +If you discover a security vulnerability in AutoPiP, please report it responsibly: + +1. **Do not** open a public issue +2. Use [GitHub private vulnerability reporting](https://github.com/vordenken/AutoPiP/security/advisories/new) +3. Include a description of the vulnerability and steps to reproduce + +You can expect an initial response within 48 hours. Confirmed vulnerabilities will be patched as soon as possible and credited in the release notes (unless you prefer to remain anonymous). diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9995802 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"] +} \ No newline at end of file diff --git a/scripts/update_appcast.py b/scripts/update_appcast.py new file mode 100644 index 0000000..42f56e5 --- /dev/null +++ b/scripts/update_appcast.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +import argparse +import html +import os +import re +import tempfile +from datetime import datetime, timezone +from email.utils import format_datetime +from pathlib import Path +from xml.dom import Node, minidom + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Add a release to the Sparkle appcast") + parser.add_argument("--appcast", type=Path, required=True) + parser.add_argument("--changelog", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--build", type=int, required=True) + parser.add_argument("--channel", choices=("stable", "beta"), required=True) + parser.add_argument("--signature", required=True) + parser.add_argument("--length", type=int, required=True) + parser.add_argument("--max-betas", type=int, default=5) + parser.add_argument("--date", help="RFC 2822 publication date (defaults to now)") + return parser.parse_args() + + +def direct_children(element, tag_name): + return [ + child + for child in element.childNodes + if child.nodeType == Node.ELEMENT_NODE and child.tagName == tag_name + ] + + +def child_text(element, tag_name): + children = direct_children(element, tag_name) + if not children or children[0].firstChild is None: + return "" + return "".join( + child.data for child in children[0].childNodes if child.nodeType == Node.TEXT_NODE + ).strip() + + +def short_version(item): + enclosures = direct_children(item, "enclosure") + if not enclosures: + return "" + return enclosures[0].getAttribute("sparkle:shortVersionString") + + +def is_beta(item): + return child_text(item, "sparkle:channel") == "beta" + + +def changelog_html(changelog, beta): + parts = [] + list_open = False + + if beta: + parts.append( + '

' + "⚠ Beta Release - This version may be unstable. " + "Only install it if you want to test new features early.

" + ) + + for raw_line in changelog.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("### "): + if list_open: + parts.append("") + list_open = False + parts.append(f"

{html.escape(line[4:])}

") + elif line.startswith("- "): + if not list_open: + parts.append("
    ") + list_open = True + parts.append(f"
  • {html.escape(line[2:])}
  • ") + else: + if list_open: + parts.append("
") + list_open = False + parts.append(f"

{html.escape(line)}

") + + if list_open: + parts.append("") + return "\n".join(parts) + + +def append_text_element(document, parent, tag_name, value): + element = document.createElement(tag_name) + element.appendChild(document.createTextNode(value)) + parent.appendChild(element) + return element + + +def create_item(document, args, changelog): + beta = args.channel == "beta" + display_version = args.tag.removeprefix("v") if beta else args.version + item = document.createElement("item") + + append_text_element( + document, + item, + "title", + f"Version {args.version} (Beta)" if beta else f"Version {args.version}", + ) + if beta: + append_text_element(document, item, "sparkle:channel", "beta") + + release_notes = changelog_html(changelog, beta) + if release_notes: + if "]]>" in release_notes: + raise ValueError("Changelog cannot contain the CDATA terminator ']]>'") + description = document.createElement("description") + description.appendChild(document.createCDATASection(f"\n{release_notes}\n")) + item.appendChild(description) + + append_text_element( + document, + item, + "pubDate", + args.date or format_datetime(datetime.now(timezone.utc)), + ) + + enclosure = document.createElement("enclosure") + enclosure.setAttribute( + "url", + f"https://github.com/vordenken/AutoPiP/releases/download/{args.tag}/AutoPiP.dmg", + ) + enclosure.setAttribute("sparkle:version", str(args.build)) + enclosure.setAttribute("sparkle:shortVersionString", display_version) + enclosure.setAttribute("sparkle:edSignature", args.signature) + enclosure.setAttribute("length", str(args.length)) + enclosure.setAttribute("type", "application/octet-stream") + item.appendChild(enclosure) + return item + + +def remove_whitespace_nodes(node): + for child in list(node.childNodes): + if child.nodeType == Node.TEXT_NODE and not child.data.strip(): + node.removeChild(child) + elif child.hasChildNodes(): + remove_whitespace_nodes(child) + + +def validate_arguments(args): + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.version): + raise ValueError(f"Invalid version: {args.version}") + expected_tag = rf"v{re.escape(args.version)}" + if args.channel == "beta": + expected_tag += r"-beta[0-9]+" + if not re.fullmatch(expected_tag, args.tag): + raise ValueError(f"Tag {args.tag} does not match {args.channel} version {args.version}") + if args.build < 1 or args.length < 1 or args.max_betas < 1: + raise ValueError("Build, length, and max-betas must be positive integers") + + +def update_appcast(args): + validate_arguments(args) + document = minidom.parse(str(args.appcast)) + channels = document.getElementsByTagName("channel") + if len(channels) != 1: + raise ValueError("Appcast must contain exactly one channel") + channel = channels[0] + changelog = args.changelog.read_text(encoding="utf-8") + new_item = create_item(document, args, changelog) + + for item in list(direct_children(channel, "item")): + item_version = short_version(item) + duplicate = item_version == short_version(new_item) + promoted_beta = args.channel == "stable" and item_version.startswith( + f"{args.version}-beta" + ) + if duplicate or promoted_beta: + channel.removeChild(item) + + existing_items = direct_children(channel, "item") + if existing_items: + channel.insertBefore(new_item, existing_items[0]) + else: + channel.appendChild(new_item) + + beta_items = [item for item in direct_children(channel, "item") if is_beta(item)] + for expired_beta in beta_items[args.max_betas :]: + channel.removeChild(expired_beta) + + remove_whitespace_nodes(document) + output = document.toprettyxml(indent=" ", encoding="utf-8") + with tempfile.NamedTemporaryFile( + mode="wb", dir=args.appcast.parent, delete=False, prefix="appcast-" + ) as temporary_file: + temporary_file.write(output) + temporary_path = Path(temporary_file.name) + os.replace(temporary_path, args.appcast) + + +def main(): + args = parse_arguments() + update_appcast(args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/semver.txt b/semver.txt new file mode 100644 index 0000000..c391225 --- /dev/null +++ b/semver.txt @@ -0,0 +1,28 @@ +2.1.0 +--- +### What's New +- 🎨 New onboarding flow — guides through extension setup and update settings on first launch +- ⚙️ Update preferences (auto-check, auto-download, beta channel) now configurable during setup + +### Under the Hood +- 🔧 Builds and releases are now fully automated via GitHub Actions +- 🧪 Beta release channel — opt in to test upcoming features before they reach the stable release + +## 2.0.0 +- Global on/off toggle to temporarily disable AutoPiP without removing the extension +- Configurable keyboard shortcut to manually trigger PiP (default: ⌥P) +- Blacklist / Whitelist mode to control which sites AutoPiP is active on +- Updated Sparkle to 2.9.0 + +## 1.0.1 +- Fixed issue where clicking into YouTube live chat would incorrectly activate PiP mode +- Improved focus detection to prevent PiP activation on internal page interactions + +## 1.0 +- Bugfix: Twitch.tv + +## 0.4 +- Added Disney+ support +- Added feature for PiP on YouTube scrolling +- Enhanced control over PiP behavior with new settings for tab switching, window switching, and scrolling on YouTube +- Updated popup interface with additional checkboxes for PiP settings diff --git a/tests/content.test.js b/tests/content.test.js new file mode 100644 index 0000000..9da517e --- /dev/null +++ b/tests/content.test.js @@ -0,0 +1,257 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const contentScript = fs.readFileSync( + path.join(__dirname, '..', 'AutoPiP Extension', 'Resources', 'content.js'), + 'utf8' +); + +class FakeEventTarget { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type, event = {}) { + for (const listener of this.listeners.get(type) || []) { + listener(event); + } + } +} + +function createVideo(overrides = {}) { + return { + paused: false, + currentTime: 10, + ended: false, + webkitSupportsPresentationMode: true, + webkitPresentationMode: 'inline', + presentationModes: [], + getBoundingClientRect() { + return { top: 0, left: 0, bottom: 720, right: 1280, height: 720 }; + }, + webkitSetPresentationMode(mode) { + this.webkitPresentationMode = mode; + this.presentationModes.push(mode); + }, + ...overrides + }; +} + +function loadContentScript({ hostname = 'www.youtube.com', storage = {}, video = createVideo() } = {}) { + let hasFocus = true; + let mutationCallback; + let queryCount = 0; + const document = new FakeEventTarget(); + Object.assign(document, { + hidden: false, + readyState: 'complete', + pictureInPictureElement: null, + documentElement: { clientHeight: 900, clientWidth: 1440 }, + hasFocus: () => hasFocus, + contains: (element) => element === video, + querySelector: () => { + queryCount += 1; + return video; + } + }); + + const window = new FakeEventTarget(); + Object.assign(window, { + document, + location: { hostname }, + innerHeight: 900, + innerWidth: 1440 + }); + + let messageListener; + let observerCallback; + const context = vm.createContext({ + browser: { + runtime: { + lastError: null, + onMessage: { addListener: (listener) => { messageListener = listener; } } + }, + storage: { local: { get: async () => storage } } + }, + console, + document, + window, + MutationObserver: class { + constructor(callback) { + mutationCallback = callback; + } + + observe() {} + }, + IntersectionObserver: class { + constructor(callback) { + observerCallback = callback; + } + + observe() {} + unobserve() {} + }, + setTimeout: (callback) => { + callback(); + return 1; + }, + clearTimeout() {} + }); + + vm.runInContext(contentScript, context, { filename: 'content.js' }); + + return { + context, + document, + window, + video, + evaluate(expression) { + return vm.runInContext(expression, context); + }, + dispatchMessage(message) { + let response; + messageListener(message, {}, (value) => { response = value; }); + return response; + }, + getQueryCount() { + return queryCount; + }, + intersect(entries) { + observerCallback(entries); + }, + mutate() { + mutationCallback(); + }, + setFocus(value) { + hasFocus = value; + } + }; +} + +test('trusted tab switch enters and leaves Picture-in-Picture', () => { + const harness = loadContentScript(); + + harness.document.hidden = true; + harness.document.dispatch('visibilitychange', { isTrusted: true }); + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture']); + + harness.document.hidden = false; + harness.document.dispatch('visibilitychange', { isTrusted: true }); + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture', 'inline']); +}); + +test('untrusted visibility events are ignored', () => { + const harness = loadContentScript(); + + harness.document.hidden = true; + harness.document.dispatch('visibilitychange', { isTrusted: false }); + + assert.deepEqual(harness.video.presentationModes, []); +}); + +test('global switch and site lists gate automatic Picture-in-Picture', () => { + const disabledHarness = loadContentScript(); + const response = disabledHarness.dispatchMessage({ command: 'toggleAutoPiP', enabled: false }); + assert.equal(response.enabled, false); + disabledHarness.document.hidden = true; + disabledHarness.document.dispatch('visibilitychange', { isTrusted: true }); + assert.deepEqual(disabledHarness.video.presentationModes, []); + + const blacklistHarness = loadContentScript(); + blacklistHarness.dispatchMessage({ command: 'updateBlacklist', sites: ['youtube.com'] }); + blacklistHarness.document.hidden = true; + blacklistHarness.document.dispatch('visibilitychange', { isTrusted: true }); + assert.deepEqual(blacklistHarness.video.presentationModes, []); + + const whitelistHarness = loadContentScript(); + whitelistHarness.dispatchMessage({ + command: 'updateListMode', + mode: 'whitelist', + whitelistedSites: ['youtube.com'] + }); + whitelistHarness.document.hidden = true; + whitelistHarness.document.dispatch('visibilitychange', { isTrusted: true }); + assert.deepEqual(whitelistHarness.video.presentationModes, ['picture-in-picture']); +}); + +test('window focus changes enter and leave Picture-in-Picture', () => { + const harness = loadContentScript(); + + harness.setFocus(false); + harness.window.dispatch('blur', { isTrusted: true }); + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture']); + + harness.setFocus(true); + harness.window.dispatch('focus', { isTrusted: true }); + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture', 'inline']); +}); + +test('internal focus changes do not enter Picture-in-Picture', () => { + const harness = loadContentScript(); + + harness.window.dispatch('blur', { isTrusted: true }); + + assert.deepEqual(harness.video.presentationModes, []); +}); + +test('YouTube scroll visibility drives Picture-in-Picture', () => { + const harness = loadContentScript(); + + harness.intersect([{ target: harness.video, isIntersecting: false }]); + harness.intersect([{ target: harness.video, isIntersecting: true }]); + + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture', 'inline']); +}); + +test('configured keyboard shortcut toggles manual Picture-in-Picture', () => { + const harness = loadContentScript(); + let prevented = false; + harness.dispatchMessage({ command: 'toggleKeyboardShortcut', enabled: true }); + harness.dispatchMessage({ command: 'updateShortcutKey', key: 'KeyP', modifier: 'alt' }); + + harness.document.dispatch('keydown', { + code: 'KeyP', + altKey: true, + ctrlKey: false, + metaKey: false, + shiftKey: false, + preventDefault() { prevented = true; } + }); + + assert.equal(prevented, true); + assert.deepEqual(harness.video.presentationModes, ['picture-in-picture']); +}); + +test('manual toggle falls back to the WebKit API when the standard API rejects', async () => { + const video = createVideo({ + requestPictureInPicture: () => Promise.reject(new Error('not available')) + }); + const harness = loadContentScript({ video }); + + harness.evaluate('togglePiP()'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(video.presentationModes, ['picture-in-picture']); +}); + +test('video lookup is cached until a DOM mutation invalidates it', () => { + const harness = loadContentScript(); + const initialQueries = harness.getQueryCount(); + + harness.evaluate('getVideo()'); + harness.evaluate('getVideo()'); + assert.equal(harness.getQueryCount(), initialQueries); + + harness.mutate(); + harness.evaluate('getVideo()'); + assert.equal(harness.getQueryCount(), initialQueries + 1); +}); \ No newline at end of file diff --git a/tests/host-script.test.js b/tests/host-script.test.js new file mode 100644 index 0000000..3078538 --- /dev/null +++ b/tests/host-script.test.js @@ -0,0 +1,122 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const hostScript = fs.readFileSync( + path.join(__dirname, '..', 'AutoPiP', 'Resources', 'Script.js'), + 'utf8' +); + +class FakeElement { + constructor() { + this.checked = false; + this.classList = { + values: new Set(), + add: (...names) => names.forEach((name) => this.classList.values.add(name)), + remove: (...names) => names.forEach((name) => this.classList.values.delete(name)), + toggle: (name, force) => force ? this.classList.values.add(name) : this.classList.values.delete(name) + }; + this.disabled = false; + this.href = ''; + this.listeners = new Map(); + this.textContent = ''; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type, event = {}) { + for (const listener of this.listeners.get(type) || []) listener.call(this, event); + } +} + +function loadHostScript() { + const ids = [ + 'page-welcome', 'page-features', 'page-updates', 'page-main', + 'version-label', 'main-version-label', 'welcome-next', 'features-back', + 'features-next', 'updates-back', 'onb-auto-check', 'onb-auto-download', + 'onb-beta', 'onb-check-updates', 'updates-done', 'check-updates-btn', + 'auto-check-toggle', 'auto-download-toggle', 'beta-toggle' + ]; + const elements = new Map(ids.map((id) => [id, new FakeElement()])); + const preferencesButton = new FakeElement(); + const links = ['https://github.com/vordenken/AutoPiP', 'https://ko-fi.com/vordenken'] + .map((href) => Object.assign(new FakeElement(), { href })); + const stateElements = new Map([ + ['state-on', [new FakeElement()]], + ['state-off', [new FakeElement()]], + ['state-unknown', [new FakeElement()]], + ['open-preferences', [preferencesButton]] + ]); + const document = new FakeElement(); + document.body = new FakeElement(); + document.getElementById = (id) => elements.get(id); + document.getElementsByClassName = (name) => stateElements.get(name) || []; + document.querySelector = () => preferencesButton; + document.querySelectorAll = (selector) => selector === '.page' + ? ['page-welcome', 'page-features', 'page-updates', 'page-main'].map((id) => elements.get(id)) + : links; + + const messages = []; + const context = vm.createContext({ + document, + webkit: { + messageHandlers: { + controller: { postMessage: (message) => messages.push(message) } + } + } + }); + vm.runInContext(hostScript, context, { filename: 'Script.js' }); + document.dispatch('DOMContentLoaded'); + + return { context, document, elements, messages }; +} + +test('onboarding navigation advances and returns between pages', () => { + const harness = loadHostScript(); + + vm.runInContext('startOnboarding()', harness.context); + assert.equal(harness.elements.get('page-welcome').classList.values.has('hidden'), false); + + harness.elements.get('welcome-next').dispatch('click'); + assert.equal(harness.elements.get('page-features').classList.values.has('hidden'), false); + + harness.elements.get('features-next').dispatch('click'); + assert.equal(harness.elements.get('page-updates').classList.values.has('hidden'), false); + + harness.elements.get('updates-back').dispatch('click'); + assert.equal(harness.elements.get('page-features').classList.values.has('hidden'), false); +}); + +test('disabling automatic checks also disables automatic downloads', () => { + const harness = loadHostScript(); + const autoCheck = harness.elements.get('onb-auto-check'); + const autoDownload = harness.elements.get('onb-auto-download'); + autoCheck.checked = false; + autoDownload.checked = true; + + autoCheck.dispatch('change'); + + assert.equal(autoDownload.checked, false); + assert.equal(autoDownload.disabled, true); + assert.deepEqual(harness.messages, ['set-auto-check:false', 'set-auto-download:false']); +}); + +test('finishing onboarding sends all selected update settings', () => { + const harness = loadHostScript(); + harness.elements.get('onb-auto-check').checked = true; + harness.elements.get('onb-auto-download').checked = true; + harness.elements.get('onb-beta').checked = false; + + harness.elements.get('updates-done').dispatch('click'); + + assert.equal( + harness.messages.at(-1), + 'onboarding-done:{"autoCheck":true,"autoDownload":true,"beta":false}' + ); +}); \ No newline at end of file diff --git a/tests/popup.test.js b/tests/popup.test.js new file mode 100644 index 0000000..5a0a0df --- /dev/null +++ b/tests/popup.test.js @@ -0,0 +1,165 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const popupScript = fs.readFileSync( + path.join(__dirname, '..', 'AutoPiP Extension', 'Resources', 'popup.js'), + 'utf8' +); + +class FakeClassList { + constructor() { + this.values = new Set(); + } + + add(...names) { + names.forEach((name) => this.values.add(name)); + } + + remove(...names) { + names.forEach((name) => this.values.delete(name)); + } + + contains(name) { + return this.values.has(name); + } + + toggle(name, force) { + const enabled = force === undefined ? !this.contains(name) : force; + enabled ? this.add(name) : this.remove(name); + return enabled; + } +} + +class FakeElement { + constructor() { + this.checked = false; + this.children = []; + this.classList = new FakeClassList(); + this.dataset = {}; + this.disabled = false; + this.listeners = new Map(); + this.style = {}; + this.textContent = ''; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + this.listeners.set(type, listeners.filter((candidate) => candidate !== listener)); + } + + appendChild(child) { + this.children.push(child); + } + + focus() {} + + setAttribute(name, value) { + this[name] = value; + } +} + +function loadPopup({ storage = {}, activeUrl = 'https://media.example.com/watch' } = {}) { + const elements = new Map(); + const document = new FakeElement(); + document.getElementById = (id) => { + if (!elements.has(id)) elements.set(id, new FakeElement()); + return elements.get(id); + }; + document.createElement = () => new FakeElement(); + + const storageWrites = []; + const messages = []; + const tabs = [{ id: 1, url: activeUrl }, { id: 2, url: 'https://example.org' }]; + const browser = { + runtime: { + lastError: null, + getManifest: () => ({ version: '2.1.0' }) + }, + storage: { + local: { + get: async () => storage, + set: async (items) => { storageWrites.push(JSON.parse(JSON.stringify(items))); } + } + }, + tabs: { + query(options, callback) { + const result = options.active ? [tabs[0]] : tabs; + if (callback) { + callback(result); + return undefined; + } + return Promise.resolve(result); + }, + sendMessage: async (tabId, message) => { + messages.push({ tabId, message: JSON.parse(JSON.stringify(message)) }); + } + } + }; + + const quietConsole = { error() {}, log() {}, warn() {} }; + const context = vm.createContext({ browser, console: quietConsole, document, Promise, URL }); + vm.runInContext(popupScript, context, { filename: 'popup.js' }); + + return { + context, + elements, + messages, + storageWrites, + evaluate(expression) { + return vm.runInContext(expression, context); + } + }; +} + +test('hostname extraction supports full and root-domain modes', () => { + const harness = loadPopup(); + + assert.equal( + harness.evaluate("extractHostname('https://media.example.com/watch', true)"), + 'media.example.com' + ); + assert.equal( + harness.evaluate("extractHostname('https://media.example.com/watch', false)"), + 'example.com' + ); + assert.equal(harness.evaluate("extractHostname('not a URL', true)"), ''); +}); + +test('shortcut labels use physical key codes and native modifier symbols', () => { + const harness = loadPopup(); + + assert.equal(harness.evaluate("formatShortcutLabel('alt', 'KeyP')"), '\u2325P'); +}); + +test('mode rendering switches the visible list and active button', () => { + const harness = loadPopup(); + + harness.evaluate("renderMode('whitelist')"); + + assert.equal(harness.elements.get('blacklistSection').classList.contains('hidden'), true); + assert.equal(harness.elements.get('whitelistSection').classList.contains('hidden'), false); + assert.equal(harness.elements.get('whitelistModeBtn').classList.contains('active'), true); +}); + +test('moving a site to the blacklist removes it from the whitelist', async () => { + const harness = loadPopup(); + harness.evaluate("blacklistedSites = []; whitelistedSites = ['example.com']; currentTabUrl = null"); + + await harness.evaluate("addToBlacklist('example.com')"); + + assert.equal(harness.evaluate("blacklistedSites.includes('example.com')"), true); + assert.equal(harness.evaluate("whitelistedSites.includes('example.com')"), false); + assert.equal( + harness.storageWrites.some((write) => Array.isArray(write.whitelistedSites) && write.whitelistedSites.length === 0), + true + ); +}); \ No newline at end of file diff --git a/tests/release-pipeline.test.js b/tests/release-pipeline.test.js new file mode 100644 index 0000000..46ae7e4 --- /dev/null +++ b/tests/release-pipeline.test.js @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const repositoryRoot = path.join(__dirname, '..'); +const updateAppcast = path.join(repositoryRoot, 'scripts', 'update_appcast.py'); +const workflowPath = path.join(repositoryRoot, '.github', 'workflows', 'build-release.yml'); +const packageResolutionPath = path.join( + repositoryRoot, + 'AutoPiP.xcodeproj', + 'project.xcworkspace', + 'xcshareddata', + 'swiftpm', + 'Package.resolved' +); + +function appcastItem(version, { beta = false, build = 1 } = {}) { + return ` + + Version ${version} + ${beta ? 'beta' : ''} + + `; +} + +function createFixture(items) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'autopip-appcast-')); + const appcast = path.join(directory, 'appcast.xml'); + const changelog = path.join(directory, 'changelog.txt'); + fs.writeFileSync(appcast, ` + + + AutoPiP Updates${items.join('')} + + +`); + fs.writeFileSync(changelog, '### Changes\n- Safe & tested\n'); + return { directory, appcast, changelog }; +} + +function runUpdate(fixture, { channel, tag, version = '2.1.0', build = 50 }) { + return spawnSync('python3', [ + updateAppcast, + '--appcast', fixture.appcast, + '--changelog', fixture.changelog, + '--version', version, + '--tag', tag, + '--build', String(build), + '--channel', channel, + '--signature', 'new-signature', + '--length', '200', + '--max-betas', '5', + '--date', 'Mon, 31 Aug 2026 12:00:00 +0000' + ], { encoding: 'utf8' }); +} + +function assertValidXml(appcast) { + const result = spawnSync( + 'python3', + ['-c', 'from xml.dom import minidom; import sys; minidom.parse(sys.argv[1])', appcast], + { encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr); +} + +test('beta update keeps five newest betas and every stable release', (context) => { + const fixture = createFixture([ + appcastItem('2.1.0-beta6', { beta: true, build: 46 }), + appcastItem('2.1.0-beta5', { beta: true, build: 45 }), + appcastItem('2.1.0-beta4', { beta: true, build: 44 }), + appcastItem('2.1.0-beta3', { beta: true, build: 43 }), + appcastItem('2.1.0-beta2', { beta: true, build: 42 }), + appcastItem('2.1.0-beta1', { beta: true, build: 41 }), + appcastItem('2.0.0', { build: 10 }), + appcastItem('1.0.1', { build: 5 }) + ]); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + + const result = runUpdate(fixture, { channel: 'beta', tag: 'v2.1.0-beta7' }); + assert.equal(result.status, 0, result.stderr); + assertValidXml(fixture.appcast); + + const output = fs.readFileSync(fixture.appcast, 'utf8'); + assert.equal((output.match(/beta<\/sparkle:channel>/g) || []).length, 5); + assert.match(output, /sparkle:shortVersionString="2\.1\.0-beta7"/); + assert.doesNotMatch(output, /sparkle:shortVersionString="2\.1\.0-beta2"/); + assert.match(output, /sparkle:shortVersionString="2\.0\.0"/); + assert.match(output, /sparkle:shortVersionString="1\.0\.1"/); + assert.match(output, /
  • Safe & tested<\/li>/); +}); + +test('stable promotion removes betas for that version but preserves other channels', (context) => { + const fixture = createFixture([ + appcastItem('2.2.0-beta1', { beta: true, build: 60 }), + appcastItem('2.1.0-beta2', { beta: true, build: 49 }), + appcastItem('2.1.0-beta1', { beta: true, build: 48 }), + appcastItem('2.0.0', { build: 10 }) + ]); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + + const result = runUpdate(fixture, { channel: 'stable', tag: 'v2.1.0' }); + assert.equal(result.status, 0, result.stderr); + assertValidXml(fixture.appcast); + + const output = fs.readFileSync(fixture.appcast, 'utf8'); + assert.match(output, /sparkle:shortVersionString="2\.1\.0"/); + assert.doesNotMatch(output, /sparkle:shortVersionString="2\.1\.0-beta/); + assert.match(output, /sparkle:shortVersionString="2\.2\.0-beta1"/); + assert.match(output, /sparkle:shortVersionString="2\.0\.0"/); +}); + +test('appcast updater rejects a tag that does not match the release channel', (context) => { + const fixture = createFixture([]); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + + const result = runUpdate(fixture, { channel: 'stable', tag: 'v2.1.0-beta1' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /does not match stable version/); +}); + +test('release workflow preserves immutable and serialized publishing', () => { + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + assert.match(workflow, /- 'AutoPiPTests\/\*\*'/); + assert.match(workflow, /- 'AutoPiPUITests\/\*\*'/); + assert.match(workflow, /- 'tests\/\*\*'/); + assert.match(workflow, /- '\.github\/workflows\/tests\.yml'/); + assert.match(workflow, /cancel-in-progress: false/); + assert.match(workflow, /EVENT_NAME.*workflow_dispatch|workflow_dispatch.*EVENT_NAME/s); + assert.match(workflow, /REF_NAME.*!=.*main/); + assert.match(workflow, /git diff --quiet.*semver\.txt/); + assert.match(workflow, /refusing to move it/); + assert.match(workflow, /git show origin\/main:appcast\.xml/); + assert.match(workflow, /git worktree add --detach.*origin\/main/); + assert.match(workflow, /git -C .* push origin HEAD:main/); + assert.match(workflow, /CREATE_DMG_VERSION: '1\.3\.0'/); + assert.match(workflow, /create-dmg\/archive\/refs\/tags\/v\$\{CREATE_DMG_VERSION\}/); + assert.match(workflow, /SPARKLE_VERSION: '2\.9\.3'/); + assert.match(workflow, /python3 scripts\/update_appcast\.py/); + assert.match(workflow, /git tag --points-at.*GITHUB_SHA/s); + assert.match(workflow, /Reusing.*for a retry/); + assert.match(workflow, /tail -1 \|\| true/); + assert.doesNotMatch(workflow, /git checkout -B appcast-main/); + assert.doesNotMatch(workflow, /brew install create-dmg/); + assert.doesNotMatch(workflow, /git tag -f|git push[^\n]*--force/); +}); + +test('release workflow uses the Sparkle version resolved by SwiftPM', () => { + const workflow = fs.readFileSync(workflowPath, 'utf8'); + const packageResolution = JSON.parse(fs.readFileSync(packageResolutionPath, 'utf8')); + const sparkle = packageResolution.pins.find((dependency) => dependency.identity === 'sparkle'); + const workflowVersion = workflow.match(/SPARKLE_VERSION: '([^']+)'/)?.[1]; + + assert.ok(sparkle, 'Sparkle is missing from Package.resolved'); + assert.equal(workflowVersion, sparkle.state.version); +}); \ No newline at end of file