mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +00:00
Integrate Sparkle auto-updates for macOS Developer ID builds
New Release-DeveloperID configuration gates Sparkle behind a SPARKLE compile flag so the App Store Release build stays Sparkle-free. Adds SPUStandardUpdaterController wrapper, Check for Updates menu command, Advanced Settings section with beta channel toggle, and a Ruby script plus GitHub Actions job that signs each release and publishes the appcast to gh-pages for consumption by Sparkle and Homebrew cask.
This commit is contained in:
142
.github/workflows/release.yml
vendored
142
.github/workflows/release.yml
vendored
@@ -15,9 +15,16 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
build_mac_notarized:
|
build_mac_notarized:
|
||||||
description: 'Build macOS (notarized)'
|
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: true
|
||||||
|
release_channel:
|
||||||
|
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- beta
|
||||||
|
- stable
|
||||||
|
default: beta
|
||||||
create_release:
|
create_release:
|
||||||
description: 'Create GitHub release'
|
description: 'Create GitHub release'
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -174,15 +181,18 @@ jobs:
|
|||||||
- uses: maierj/fastlane-action@v3.0.0
|
- uses: maierj/fastlane-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
lane: mac build_and_notarize
|
lane: mac build_and_notarize
|
||||||
- run: |
|
- name: Resolve artifact paths
|
||||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
run: |
|
||||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
|
||||||
- name: ZIP build
|
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
|
||||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||||
|
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mac-notarized-build
|
name: mac-notarized-build
|
||||||
path: ${{ env.ZIP_PATH }}
|
path: |
|
||||||
|
${{ env.ZIP_PATH }}
|
||||||
|
${{ env.DMG_PATH }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -192,9 +202,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||||
env:
|
env:
|
||||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||||
|
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -212,14 +225,121 @@ jobs:
|
|||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
- name: Compute release tag
|
||||||
|
id: compute_tag
|
||||||
|
run: |
|
||||||
|
if [ "$RELEASE_CHANNEL" = "beta" ]; then
|
||||||
|
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
- uses: ncipollo/release-action@v1
|
- uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
|
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
|
||||||
commit: ${{ github.ref_name }}
|
commit: ${{ github.ref_name }}
|
||||||
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
|
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||||
prerelease: true
|
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
|
||||||
bodyFile: CHANGELOG.md
|
bodyFile: CHANGELOG.md
|
||||||
|
|
||||||
|
publish_appcast:
|
||||||
|
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
|
||||||
|
needs: [determine_build_number, mac_notarized, release]
|
||||||
|
name: Publish Sparkle appcast
|
||||||
|
runs-on: macos-26
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||||
|
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||||
|
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||||
|
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||||
|
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- name: Guard — secret configured
|
||||||
|
run: |
|
||||||
|
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
|
||||||
|
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
|
- name: Download notarized mac artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mac-notarized-build
|
||||||
|
path: mac-artifacts
|
||||||
|
- name: Locate sign_update binary
|
||||||
|
id: find_sign_update
|
||||||
|
run: |
|
||||||
|
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
|
||||||
|
# resolve the Sparkle package so the binary is present on disk.
|
||||||
|
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
|
||||||
|
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
|
||||||
|
if [ -z "$SIGN" ]; then
|
||||||
|
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$SIGN" ]; then
|
||||||
|
echo "::error::Could not locate sign_update binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Checkout gh-pages (create if missing)
|
||||||
|
run: |
|
||||||
|
git fetch origin gh-pages || true
|
||||||
|
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
|
||||||
|
git worktree add gh-pages origin/gh-pages
|
||||||
|
else
|
||||||
|
# First run — create orphan gh-pages with only appcast scaffolding.
|
||||||
|
git worktree add --detach gh-pages HEAD
|
||||||
|
cd gh-pages
|
||||||
|
git checkout --orphan gh-pages
|
||||||
|
git rm -rf . >/dev/null 2>&1 || true
|
||||||
|
cp ../scripts/sparkle/appcast_template.xml appcast.xml
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
- name: Write private key to a temp file
|
||||||
|
id: ed_key
|
||||||
|
run: |
|
||||||
|
KEY_FILE=$(mktemp)
|
||||||
|
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
|
||||||
|
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Sign update and update appcast.xml
|
||||||
|
run: |
|
||||||
|
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
|
||||||
|
if [ -z "$ZIP" ]; then
|
||||||
|
echo "::error::No .zip found in mac-artifacts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
./scripts/sparkle/update_appcast.rb \
|
||||||
|
--zip "$ZIP" \
|
||||||
|
--version "$VERSION_NUMBER" \
|
||||||
|
--build "$BUILD_NUMBER" \
|
||||||
|
--channel "$RELEASE_CHANNEL" \
|
||||||
|
--tag "$RELEASE_TAG" \
|
||||||
|
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
|
||||||
|
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
|
||||||
|
--appcast gh-pages/appcast.xml \
|
||||||
|
--repo "$REPO"
|
||||||
|
- name: Scrub private key
|
||||||
|
if: always()
|
||||||
|
run: rm -f "${{ steps.ed_key.outputs.path }}"
|
||||||
|
- name: Commit & push appcast.xml
|
||||||
|
run: |
|
||||||
|
cd gh-pages
|
||||||
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --local user.name "github-actions[bot]"
|
||||||
|
git add appcast.xml
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No appcast changes to publish"
|
||||||
|
else
|
||||||
|
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
|
||||||
|
git push origin gh-pages
|
||||||
|
fi
|
||||||
|
|
||||||
update_altstore:
|
update_altstore:
|
||||||
needs: [release]
|
needs: [release]
|
||||||
uses: ./.github/workflows/update-altstore.yml
|
uses: ./.github/workflows/update-altstore.yml
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -13,6 +13,18 @@ This project targets the latest OS versions only - use newest APIs freely withou
|
|||||||
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
|
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
|
||||||
**Lint:** `periphery scan` (config: `.periphery.yml`)
|
**Lint:** `periphery scan` (config: `.periphery.yml`)
|
||||||
|
|
||||||
|
## Build Configurations
|
||||||
|
|
||||||
|
Three configurations exist, mapped to distribution channels:
|
||||||
|
|
||||||
|
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| `Debug` | off | local development, tests |
|
||||||
|
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
|
||||||
|
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
|
||||||
|
|
||||||
|
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
|
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
|
||||||
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
|
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
|
||||||
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
|
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
|
||||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
|
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
|
||||||
@@ -125,6 +126,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
|
||||||
37767AB32F05766100D248FC /* Nuke in Frameworks */,
|
37767AB32F05766100D248FC /* Nuke in Frameworks */,
|
||||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
|
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||||
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
|
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||||
@@ -206,6 +208,7 @@
|
|||||||
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
|
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
|
||||||
37767AB22F05766100D248FC /* Nuke */,
|
37767AB22F05766100D248FC /* Nuke */,
|
||||||
37767AB42F05766100D248FC /* NukeUI */,
|
37767AB42F05766100D248FC /* NukeUI */,
|
||||||
|
370E71972F9A1A41000E04B2 /* Sparkle */,
|
||||||
);
|
);
|
||||||
productName = Yattee;
|
productName = Yattee;
|
||||||
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
|
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
|
||||||
@@ -315,6 +318,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
|
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||||
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
|
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
|
||||||
|
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
|
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
|
||||||
@@ -1179,6 +1183,14 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.9.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
|
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/kean/Nuke";
|
repositoryURL = "https://github.com/kean/Nuke";
|
||||||
@@ -1198,6 +1210,11 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
370E71972F9A1A41000E04B2 /* Sparkle */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
|
productName = Sparkle;
|
||||||
|
};
|
||||||
37767AB22F05766100D248FC /* Nuke */ = {
|
37767AB22F05766100D248FC /* Nuke */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;
|
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
|
"originHash" : "263c985773143365961084fa2985b6cfc81ede4476036a7c65b223ed7ced8b32",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "mpvkit",
|
"identity" : "mpvkit",
|
||||||
@@ -18,6 +18,15 @@
|
|||||||
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
|
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
|
||||||
"version" : "12.9.0"
|
"version" : "12.9.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sparkle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261",
|
||||||
|
"version" : "2.9.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
|
||||||
|
<key>SUFeedURL</key>
|
||||||
|
<string>https://yattee.github.io/yattee/appcast.xml</string>
|
||||||
|
<key>SUPublicEDKey</key>
|
||||||
|
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
|
<true/>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>stream.yattee.app.feedRefresh</string>
|
<string>stream.yattee.app.feedRefresh</string>
|
||||||
|
|||||||
@@ -5142,6 +5142,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu.app.checkForUpdates" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Check for updates..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"menu.app.settings" : {
|
"menu.app.settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -14338,6 +14348,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.updates.footer" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Check for and install new versions of Yattee. Beta updates include prereleases."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.updates.receiveBeta" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Receive beta updates"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.updates.title" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Updates"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.videoActions.header" : {
|
"settings.videoActions.header" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
109
Yattee/Services/Updates/SparkleUpdater.swift
Normal file
109
Yattee/Services/Updates/SparkleUpdater.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
//
|
||||||
|
// SparkleUpdater.swift
|
||||||
|
// Yattee
|
||||||
|
//
|
||||||
|
// Sparkle-backed in-app updater for the Developer ID build.
|
||||||
|
// Compiled in only when the `SPARKLE` Swift flag is set, which is
|
||||||
|
// enabled for the `Release-DeveloperID` build configuration and
|
||||||
|
// unset for `Release` (App Store / TestFlight) and `Debug`.
|
||||||
|
//
|
||||||
|
// See AGENTS.md "Build Configurations" for the channel split.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if SPARKLE
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Sparkle
|
||||||
|
|
||||||
|
/// Observable wrapper around `SPUStandardUpdaterController` so SwiftUI views
|
||||||
|
/// can bind enable/disable state and trigger manual update checks.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class AppUpdater {
|
||||||
|
static let shared = AppUpdater()
|
||||||
|
|
||||||
|
/// Whether the "Check for Updates…" menu item should be enabled.
|
||||||
|
/// Mirrors `SPUUpdater.canCheckForUpdates` and tracks it via KVO.
|
||||||
|
private(set) var canCheckForUpdates = false
|
||||||
|
|
||||||
|
/// User preference: receive prerelease (beta) updates in addition to
|
||||||
|
/// stable ones. Persisted in UserDefaults so it survives relaunches.
|
||||||
|
var wantsBetaChannel: Bool {
|
||||||
|
didSet {
|
||||||
|
guard oldValue != wantsBetaChannel else { return }
|
||||||
|
UserDefaults.standard.set(wantsBetaChannel, forKey: Self.wantsBetaKey)
|
||||||
|
// Trigger re-check so the delegate re-reads channels. Deferred off
|
||||||
|
// the main thread — resetUpdateCycle may synchronously touch the
|
||||||
|
// scheduler's feed cache which can stutter SwiftUI scrolling.
|
||||||
|
let updater = self.updaterController.updater
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
updater.resetUpdateCycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let wantsBetaKey = "AppUpdater.wantsBetaChannel"
|
||||||
|
|
||||||
|
private let delegate = AppUpdaterDelegate()
|
||||||
|
private let updaterController: SPUStandardUpdaterController
|
||||||
|
private var observation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load persisted beta preference before constructing the delegate
|
||||||
|
let wantsBeta = UserDefaults.standard.bool(forKey: Self.wantsBetaKey)
|
||||||
|
self.wantsBetaChannel = wantsBeta
|
||||||
|
self.delegate.wantsBeta = wantsBeta
|
||||||
|
|
||||||
|
// `startingUpdater: true` boots the scheduler automatically;
|
||||||
|
// Info.plist `SUEnableAutomaticChecks` governs whether it polls.
|
||||||
|
self.updaterController = SPUStandardUpdaterController(
|
||||||
|
startingUpdater: true,
|
||||||
|
updaterDelegate: delegate,
|
||||||
|
userDriverDelegate: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep delegate in sync when the toggle changes at runtime.
|
||||||
|
self.delegate.wantsBetaProvider = { [weak self] in
|
||||||
|
self?.wantsBetaChannel ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe canCheckForUpdates so menu items can enable/disable correctly.
|
||||||
|
// KVO fires on the thread that mutated the property; hop to @MainActor
|
||||||
|
// explicitly. Dedupe to avoid SwiftUI re-render storms if Sparkle
|
||||||
|
// toggles it rapidly during a feed check cycle.
|
||||||
|
self.observation = updaterController.updater.observe(
|
||||||
|
\.canCheckForUpdates,
|
||||||
|
options: [.initial, .new]
|
||||||
|
) { [weak self] updater, _ in
|
||||||
|
let newValue = updater.canCheckForUpdates
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.canCheckForUpdates != newValue else { return }
|
||||||
|
self.canCheckForUpdates = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered by the "Check for Updates…" menu command.
|
||||||
|
func checkForUpdates() {
|
||||||
|
updaterController.checkForUpdates(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate that exposes the user's channel preference to Sparkle.
|
||||||
|
/// Sparkle calls `allowedChannels(for:)` each feed refresh.
|
||||||
|
private final class AppUpdaterDelegate: NSObject, SPUUpdaterDelegate {
|
||||||
|
/// Cached snapshot; read on the main actor by `AppUpdater`.
|
||||||
|
var wantsBeta: Bool = false
|
||||||
|
/// Live accessor so the delegate reflects the current toggle state.
|
||||||
|
var wantsBetaProvider: (() -> Bool)?
|
||||||
|
|
||||||
|
func allowedChannels(for _: SPUUpdater) -> Set<String> {
|
||||||
|
let beta = wantsBetaProvider?() ?? wantsBeta
|
||||||
|
// Empty set = stable only. Adding "beta" means users get both
|
||||||
|
// untagged (stable) items AND items tagged <sparkle:channel>beta</>.
|
||||||
|
return beta ? ["beta"] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -27,6 +27,9 @@ struct AdvancedSettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SettingsFormContainer {
|
SettingsFormContainer {
|
||||||
|
#if SPARKLE && os(macOS)
|
||||||
|
updatesSection
|
||||||
|
#endif
|
||||||
streamDetailsSection
|
streamDetailsSection
|
||||||
mpvSection
|
mpvSection
|
||||||
settingsSection
|
settingsSection
|
||||||
@@ -350,6 +353,26 @@ struct AdvancedSettingsView: View {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#if SPARKLE && os(macOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private var updatesSection: some View {
|
||||||
|
SettingsFormSection("settings.updates.title", footer: "settings.updates.footer") {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { AppUpdater.shared.wantsBetaChannel },
|
||||||
|
set: { AppUpdater.shared.wantsBetaChannel = $0 }
|
||||||
|
)) {
|
||||||
|
Label(String(localized: "settings.updates.receiveBeta"), systemImage: "testtube.2")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
AppUpdater.shared.checkForUpdates()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "menu.app.checkForUpdates"), systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
.disabled(!AppUpdater.shared.canCheckForUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var developerSection: some View {
|
private var developerSection: some View {
|
||||||
SettingsFormSection(footer: "settings.developer.footer") {
|
SettingsFormSection(footer: "settings.developer.footer") {
|
||||||
|
|||||||
27
Yattee/Views/macOS/CheckForUpdatesMenuItem.swift
Normal file
27
Yattee/Views/macOS/CheckForUpdatesMenuItem.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// CheckForUpdatesMenuItem.swift
|
||||||
|
// Yattee
|
||||||
|
//
|
||||||
|
// "Check for Updates…" menu item shown in the macOS app menu for
|
||||||
|
// Developer ID builds. Wired to the Sparkle-backed `AppUpdater`.
|
||||||
|
//
|
||||||
|
// Compiled in only when the `SPARKLE` Swift flag is set (i.e. the
|
||||||
|
// `Release-DeveloperID` configuration). See AGENTS.md.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if SPARKLE && os(macOS)
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CheckForUpdatesMenuItem: View {
|
||||||
|
@State private var updater = AppUpdater.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(String(localized: "menu.app.checkForUpdates")) {
|
||||||
|
updater.checkForUpdates()
|
||||||
|
}
|
||||||
|
.disabled(!updater.canCheckForUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -310,6 +310,11 @@ struct YatteeApp: App {
|
|||||||
CommandGroup(replacing: .appSettings) {
|
CommandGroup(replacing: .appSettings) {
|
||||||
SettingsWindowMenuItem()
|
SettingsWindowMenuItem()
|
||||||
}
|
}
|
||||||
|
#if SPARKLE
|
||||||
|
CommandGroup(after: .appInfo) {
|
||||||
|
CheckForUpdatesMenuItem()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,11 +322,13 @@ platform :mac do
|
|||||||
team_id: TEAM_ID,
|
team_id: TEAM_ID,
|
||||||
code_sign_identity: "Developer ID Application",
|
code_sign_identity: "Developer ID Application",
|
||||||
profile_name: "match Direct #{DEVELOPER_APP_IDENTIFIER} macos",
|
profile_name: "match Direct #{DEVELOPER_APP_IDENTIFIER} macos",
|
||||||
targets: [SCHEME]
|
targets: [SCHEME],
|
||||||
|
build_configurations: ["Release-DeveloperID"]
|
||||||
)
|
)
|
||||||
|
|
||||||
build_mac_app(
|
build_mac_app(
|
||||||
scheme: SCHEME,
|
scheme: SCHEME,
|
||||||
|
configuration: "Release-DeveloperID",
|
||||||
output_directory: "fastlane/builds/#{version}-#{build}/macOS",
|
output_directory: "fastlane/builds/#{version}-#{build}/macOS",
|
||||||
output_name: APP_NAME,
|
output_name: APP_NAME,
|
||||||
export_method: "developer-id",
|
export_method: "developer-id",
|
||||||
@@ -343,5 +345,23 @@ platform :mac do
|
|||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
print_log: true
|
print_log: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Staple the notarization ticket so Gatekeeper doesn't need to phone home
|
||||||
|
# when the user first launches from Sparkle/DMG.
|
||||||
|
sh "xcrun stapler staple 'fastlane/builds/#{version}-#{build}/macOS/#{APP_NAME}.app'"
|
||||||
|
|
||||||
|
# Package the stapled .app into a .zip (Sparkle) and .dmg (Homebrew / direct).
|
||||||
|
dir = "fastlane/builds/#{version}-#{build}/macOS"
|
||||||
|
app = "#{dir}/#{APP_NAME}.app"
|
||||||
|
zip = "#{dir}/#{APP_NAME}-#{version}-macOS.zip"
|
||||||
|
dmg = "#{dir}/#{APP_NAME}-#{version}-macOS.dmg"
|
||||||
|
|
||||||
|
# .zip: `ditto -c -k --keepParent` is Sparkle's expected archive layout
|
||||||
|
# (preserves the .app wrapper, resource forks, symlinks).
|
||||||
|
sh "/usr/bin/ditto -c -k --keepParent '#{app}' '#{zip}'"
|
||||||
|
|
||||||
|
# .dmg: read-only UDZO, overwrites any stale image from previous runs.
|
||||||
|
sh "/bin/rm -f '#{dmg}'"
|
||||||
|
sh "/usr/bin/hdiutil create -volname '#{APP_NAME} #{version}' -srcfolder '#{app}' -ov -format UDZO '#{dmg}'"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
65
scripts/sparkle/README.md
Normal file
65
scripts/sparkle/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Sparkle appcast pipeline
|
||||||
|
|
||||||
|
This directory contains the scripts and templates that sign Sparkle updates and maintain `appcast.xml` on the `gh-pages` branch. The feed is served at https://yattee.github.io/yattee/appcast.xml and consumed by Sparkle inside the Developer ID build of Yattee (see `Yattee/Services/Updates/SparkleUpdater.swift`).
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
1. **Generate the EdDSA signing key** on a trusted machine (once, ever):
|
||||||
|
```bash
|
||||||
|
# Location inside your Xcode DerivedData, after building Yattee once.
|
||||||
|
SPARKLE=~/Library/Developer/Xcode/DerivedData/Yattee-*/SourcePackages/artifacts/sparkle/Sparkle/bin
|
||||||
|
$SPARKLE/generate_keys # creates the keypair in Keychain
|
||||||
|
$SPARKLE/generate_keys -p # prints the public key; also visible in Info.plist (SUPublicEDKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Export the private key for CI**:
|
||||||
|
```bash
|
||||||
|
$SPARKLE/generate_keys -x /tmp/sparkle_ed_private.key
|
||||||
|
cat /tmp/sparkle_ed_private.key # copy the single-line base64 contents
|
||||||
|
rm /tmp/sparkle_ed_private.key # DO NOT commit, DO NOT leave on disk
|
||||||
|
```
|
||||||
|
Paste the contents into GitHub → Settings → Secrets and variables → Actions → **New repository secret** named `SPARKLE_ED_PRIVATE_KEY`.
|
||||||
|
|
||||||
|
3. **Enable GitHub Pages**: repository Settings → Pages → Source: `Deploy from a branch` → Branch: `gh-pages` / `(root)`. The first `publish_appcast` workflow run creates the branch with the seed `appcast.xml`.
|
||||||
|
|
||||||
|
4. **Confirm the public key matches `Info.plist`**: `Yattee/Info.plist`'s `SUPublicEDKey` value must equal the output of `generate_keys -p`. Any mismatch and Sparkle refuses to install updates signed with the CI's private key.
|
||||||
|
|
||||||
|
## Per-release flow (automated)
|
||||||
|
|
||||||
|
The `.github/workflows/release.yml` `publish_appcast` job:
|
||||||
|
|
||||||
|
1. Downloads the `mac-notarized-build` artifact (contains both `.zip` and `.dmg`).
|
||||||
|
2. Locates Sparkle's `sign_update` binary from the resolved SPM dependency.
|
||||||
|
3. Writes `SPARKLE_ED_PRIVATE_KEY` to a tempfile (scrubbed in `always()` step).
|
||||||
|
4. Invokes `./scripts/sparkle/update_appcast.rb` which:
|
||||||
|
- Signs the `.zip` → `sparkle:edSignature` + `length`.
|
||||||
|
- Prepends a new `<item>` into `gh-pages/appcast.xml` (de-duplicating if the same version+build already exists).
|
||||||
|
- Tags beta items with `<sparkle:channel>beta</>`; stable items are untagged (Sparkle's default).
|
||||||
|
5. Commits and pushes `gh-pages`.
|
||||||
|
|
||||||
|
The `release_channel` workflow input (`beta` | `stable`, default `beta`) controls:
|
||||||
|
- `<sparkle:channel>` in the appcast item
|
||||||
|
- GitHub Release prerelease flag
|
||||||
|
- Release tag shape: `2.0.1-beta.261` vs `2.0.1-261`
|
||||||
|
|
||||||
|
## Manual ad-hoc signing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/sparkle/update_appcast.rb \
|
||||||
|
--zip path/to/Yattee-2.0.1-macOS.zip \
|
||||||
|
--version 2.0.1 \
|
||||||
|
--build 261 \
|
||||||
|
--channel beta \
|
||||||
|
--tag 2.0.1-beta.261 \
|
||||||
|
--sign-update-bin ~/Library/Developer/Xcode/DerivedData/Yattee-*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update \
|
||||||
|
--ed-key-file /tmp/sparkle_ed_private.key \
|
||||||
|
--appcast appcast.xml \
|
||||||
|
--repo yattee/yattee
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After a release:
|
||||||
|
curl -sSL https://yattee.github.io/yattee/appcast.xml | xmllint --noout -
|
||||||
|
```
|
||||||
9
scripts/sparkle/appcast_template.xml
Normal file
9
scripts/sparkle/appcast_template.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Yattee</title>
|
||||||
|
<link>https://yattee.github.io/yattee/appcast.xml</link>
|
||||||
|
<description>Most recent changes with links to updates for Yattee (macOS Developer ID build).</description>
|
||||||
|
<language>en</language>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
152
scripts/sparkle/update_appcast.rb
Executable file
152
scripts/sparkle/update_appcast.rb
Executable file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Signs a Sparkle update .zip with the provided EdDSA private key and prepends
|
||||||
|
# a new <item> entry to the appcast.xml file. Creates the appcast from
|
||||||
|
# scripts/sparkle/appcast_template.xml if it does not yet exist.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# update_appcast.rb \
|
||||||
|
# --zip <path-to-Yattee-X.Y.Z-macOS.zip> \
|
||||||
|
# --version 2.0.1 \
|
||||||
|
# --build 260 \
|
||||||
|
# --channel stable|beta \
|
||||||
|
# --tag 2.0.1-260 \
|
||||||
|
# --sign-update-bin <path-to-sign_update> \
|
||||||
|
# --ed-key-file <path-to-ed-private-key-file> \
|
||||||
|
# --appcast <path-to-appcast.xml> \
|
||||||
|
# [--minimum-system-version 15.0] \
|
||||||
|
# [--release-notes-url <url>]
|
||||||
|
|
||||||
|
require 'optparse'
|
||||||
|
require 'time'
|
||||||
|
require 'rexml/document'
|
||||||
|
require 'fileutils'
|
||||||
|
require 'open3'
|
||||||
|
|
||||||
|
options = {
|
||||||
|
channel: 'stable',
|
||||||
|
minimum_system_version: '15.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionParser.new do |opts|
|
||||||
|
opts.on('--zip PATH') { |v| options[:zip] = v }
|
||||||
|
opts.on('--version V') { |v| options[:version] = v }
|
||||||
|
opts.on('--build B') { |v| options[:build] = v }
|
||||||
|
opts.on('--channel NAME') { |v| options[:channel] = v }
|
||||||
|
opts.on('--tag TAG') { |v| options[:tag] = v }
|
||||||
|
opts.on('--sign-update-bin PATH') { |v| options[:sign_update_bin] = v }
|
||||||
|
opts.on('--ed-key-file PATH') { |v| options[:ed_key_file] = v }
|
||||||
|
opts.on('--appcast PATH') { |v| options[:appcast] = v }
|
||||||
|
opts.on('--minimum-system-version V') { |v| options[:minimum_system_version] = v }
|
||||||
|
opts.on('--release-notes-url URL') { |v| options[:release_notes_url] = v }
|
||||||
|
opts.on('--repo OWNER/NAME') { |v| options[:repo] = v }
|
||||||
|
end.parse!
|
||||||
|
|
||||||
|
%i[zip version build tag sign_update_bin ed_key_file appcast repo].each do |k|
|
||||||
|
raise "Missing required argument: --#{k.to_s.tr('_', '-')}" if options[k].nil? || options[k].empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
raise "Zip not found: #{options[:zip]}" unless File.exist?(options[:zip])
|
||||||
|
raise "sign_update binary not found: #{options[:sign_update_bin]}" unless File.executable?(options[:sign_update_bin])
|
||||||
|
raise "Ed key file not found: #{options[:ed_key_file]}" unless File.exist?(options[:ed_key_file])
|
||||||
|
|
||||||
|
# ---- 1. Produce EdDSA signature via Sparkle's sign_update ----
|
||||||
|
#
|
||||||
|
# sign_update prints: sparkle:edSignature="..." length="..."
|
||||||
|
cmd = [options[:sign_update_bin], '--ed-key-file', options[:ed_key_file], options[:zip]]
|
||||||
|
puts "[appcast] signing: #{cmd.join(' ')}"
|
||||||
|
stdout, status = Open3.capture2(*cmd)
|
||||||
|
raise "sign_update failed (exit #{status.exitstatus}):\n#{stdout}" unless status.success?
|
||||||
|
|
||||||
|
sig_line = stdout.strip.lines.last.to_s.strip
|
||||||
|
ed_signature = sig_line[/edSignature="([^"]+)"/, 1]
|
||||||
|
length = sig_line[/length="([^"]+)"/, 1]
|
||||||
|
raise "Could not parse sign_update output: #{stdout.inspect}" if ed_signature.nil? || length.nil?
|
||||||
|
|
||||||
|
# ---- 2. Load (or seed) the appcast document ----
|
||||||
|
appcast_path = options[:appcast]
|
||||||
|
unless File.exist?(appcast_path)
|
||||||
|
template = File.join(File.dirname(__FILE__), 'appcast_template.xml')
|
||||||
|
FileUtils.mkdir_p(File.dirname(appcast_path))
|
||||||
|
FileUtils.cp(template, appcast_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
doc = REXML::Document.new(File.read(appcast_path))
|
||||||
|
doc.context[:attribute_quote] = :quote
|
||||||
|
channel = doc.root.elements['channel'] or raise 'appcast.xml missing <channel>'
|
||||||
|
|
||||||
|
# ---- 3. Remove any existing item for the same version+build (idempotent re-runs) ----
|
||||||
|
channel.elements.each('item') do |item|
|
||||||
|
existing_build = item.elements['sparkle:version']&.text
|
||||||
|
existing_version = item.elements['sparkle:shortVersionString']&.text
|
||||||
|
if existing_build == options[:build].to_s && existing_version == options[:version]
|
||||||
|
channel.delete_element(item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- 4. Build the new <item> ----
|
||||||
|
zip_basename = File.basename(options[:zip])
|
||||||
|
download_url = "https://github.com/#{options[:repo]}/releases/download/#{options[:tag]}/#{zip_basename}"
|
||||||
|
|
||||||
|
item = REXML::Element.new('item')
|
||||||
|
|
||||||
|
title = REXML::Element.new('title')
|
||||||
|
title.text = "Version #{options[:version]} (#{options[:build]})"
|
||||||
|
item.add_element(title)
|
||||||
|
|
||||||
|
pubdate = REXML::Element.new('pubDate')
|
||||||
|
pubdate.text = Time.now.utc.rfc2822
|
||||||
|
item.add_element(pubdate)
|
||||||
|
|
||||||
|
sparkle_version = REXML::Element.new('sparkle:version')
|
||||||
|
sparkle_version.text = options[:build].to_s
|
||||||
|
item.add_element(sparkle_version)
|
||||||
|
|
||||||
|
short_version = REXML::Element.new('sparkle:shortVersionString')
|
||||||
|
short_version.text = options[:version]
|
||||||
|
item.add_element(short_version)
|
||||||
|
|
||||||
|
min_sys = REXML::Element.new('sparkle:minimumSystemVersion')
|
||||||
|
min_sys.text = options[:minimum_system_version]
|
||||||
|
item.add_element(min_sys)
|
||||||
|
|
||||||
|
# Channel tag only on non-stable items. Sparkle treats untagged items as stable.
|
||||||
|
if options[:channel] && !options[:channel].empty? && options[:channel] != 'stable'
|
||||||
|
channel_el = REXML::Element.new('sparkle:channel')
|
||||||
|
channel_el.text = options[:channel]
|
||||||
|
item.add_element(channel_el)
|
||||||
|
end
|
||||||
|
|
||||||
|
if options[:release_notes_url]
|
||||||
|
notes = REXML::Element.new('sparkle:releaseNotesLink')
|
||||||
|
notes.text = options[:release_notes_url]
|
||||||
|
item.add_element(notes)
|
||||||
|
end
|
||||||
|
|
||||||
|
enclosure = REXML::Element.new('enclosure')
|
||||||
|
enclosure.add_attribute('url', download_url)
|
||||||
|
enclosure.add_attribute('type', 'application/octet-stream')
|
||||||
|
enclosure.add_attribute('sparkle:edSignature', ed_signature)
|
||||||
|
enclosure.add_attribute('length', length)
|
||||||
|
item.add_element(enclosure)
|
||||||
|
|
||||||
|
# ---- 5. Prepend the new item (most recent first) ----
|
||||||
|
first_item = channel.elements['item']
|
||||||
|
if first_item
|
||||||
|
channel.insert_before(first_item, item)
|
||||||
|
else
|
||||||
|
channel.add_element(item)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- 6. Write back, pretty-printed ----
|
||||||
|
formatter = REXML::Formatters::Pretty.new(2)
|
||||||
|
formatter.compact = true
|
||||||
|
File.open(appcast_path, 'w') do |f|
|
||||||
|
f.write(%Q(<?xml version="1.0" encoding="utf-8"?>\n))
|
||||||
|
formatter.write(doc.root, f)
|
||||||
|
f.write("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "[appcast] wrote #{appcast_path}"
|
||||||
|
puts "[appcast] item: version=#{options[:version]} build=#{options[:build]} channel=#{options[:channel]} length=#{length}"
|
||||||
Reference in New Issue
Block a user