mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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
|
||||
default: false
|
||||
build_mac_notarized:
|
||||
description: 'Build macOS (notarized)'
|
||||
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
|
||||
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:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
@@ -174,15 +181,18 @@ jobs:
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- name: Resolve artifact paths
|
||||
run: |
|
||||
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
|
||||
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
|
||||
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
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
path: |
|
||||
${{ env.ZIP_PATH }}
|
||||
${{ env.DMG_PATH }}
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
@@ -192,9 +202,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -212,14 +225,121 @@ jobs:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
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
|
||||
with:
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
|
||||
commit: ${{ github.ref_name }}
|
||||
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
|
||||
prerelease: true
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
|
||||
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:
|
||||
needs: [release]
|
||||
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`
|
||||
**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
|
||||
|
||||
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
|
||||
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
|
||||
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
|
||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
|
||||
@@ -125,6 +126,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
|
||||
37767AB32F05766100D248FC /* Nuke in Frameworks */,
|
||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||
@@ -206,6 +208,7 @@
|
||||
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
|
||||
37767AB22F05766100D248FC /* Nuke */,
|
||||
37767AB42F05766100D248FC /* NukeUI */,
|
||||
370E71972F9A1A41000E04B2 /* Sparkle */,
|
||||
);
|
||||
productName = Yattee;
|
||||
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
|
||||
@@ -315,6 +318,7 @@
|
||||
packageReferences = (
|
||||
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
|
||||
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
|
||||
@@ -1179,6 +1183,14 @@
|
||||
/* End XCConfigurationList 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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kean/Nuke";
|
||||
@@ -1198,6 +1210,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
370E71972F9A1A41000E04B2 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
37767AB22F05766100D248FC /* Nuke */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
|
||||
"originHash" : "263c985773143365961084fa2985b6cfc81ede4476036a7c65b223ed7ced8b32",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
@@ -18,6 +18,15 @@
|
||||
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
|
||||
"version" : "12.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<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>
|
||||
<array>
|
||||
<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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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 {
|
||||
SettingsFormContainer {
|
||||
#if SPARKLE && os(macOS)
|
||||
updatesSection
|
||||
#endif
|
||||
streamDetailsSection
|
||||
mpvSection
|
||||
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
|
||||
private var developerSection: some View {
|
||||
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) {
|
||||
SettingsWindowMenuItem()
|
||||
}
|
||||
#if SPARKLE
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesMenuItem()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -322,11 +322,13 @@ platform :mac do
|
||||
team_id: TEAM_ID,
|
||||
code_sign_identity: "Developer ID Application",
|
||||
profile_name: "match Direct #{DEVELOPER_APP_IDENTIFIER} macos",
|
||||
targets: [SCHEME]
|
||||
targets: [SCHEME],
|
||||
build_configurations: ["Release-DeveloperID"]
|
||||
)
|
||||
|
||||
build_mac_app(
|
||||
scheme: SCHEME,
|
||||
configuration: "Release-DeveloperID",
|
||||
output_directory: "fastlane/builds/#{version}-#{build}/macOS",
|
||||
output_name: APP_NAME,
|
||||
export_method: "developer-id",
|
||||
@@ -343,5 +345,23 @@ platform :mac do
|
||||
api_key: api_key,
|
||||
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
|
||||
|
||||
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