Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/rules/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/
- Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. `build-plugin.yml` picks its runner from the ref it builds (`RELEASING.md`).
- The Xcode version is written once, in `.github/actions/setup-xcode/action.yml`, which runs `xcode-select --switch` and prints `xcodebuild -version`. A workflow never selects Xcode another way.
- XcodeGen comes from `.github/actions/setup-xcodegen`, pinned by version and SHA-256.
- `scripts/release/feed.py` runs Sparkle's `generate_appcast` and `sign_update` from `SPARKLE_BIN`, which `.github/actions/setup-sparkle` sets. The action reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision.
- The tag release (`build.yml`) signs the feed with the copy `scripts/ci/sign-and-appcast.sh` downloads, pinned by its own `SPARKLE_VERSION` and `SPARKLE_SHA256`. No check compares them with `project.yml`: a Sparkle bump changes all three.
- The compilation cache is off on CI (`COMPILATION_CACHE_ENABLE_CACHING=NO`); `Configs/Base.xcconfig` turns it on locally.

## macOS Tests (`macos-tests.yml`)
Expand Down Expand Up @@ -88,7 +90,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/
- `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`;
- `localization.py plugins` and `plugins --target ios`;
- `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`;
- `check-pluginkit-bump-cadence.py`, `check-sparkle-version.py`;
- `check-pluginkit-bump-cadence.py`;
- `scripts/audit-refactor-health.sh --check`, `check-hub-ratchet.py`;
- the Go checks on the HANA helper, when the `hana` detector is true.
- `audit-refactor-health.sh --check` fails a file defining a production `DatabaseType` outside `Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift`, unless `.github/duplicate-contract-baseline.txt` lists its path. Moving a listed file moves its baseline path in the same commit. Never add an entry to let a new copy pass.
Expand Down
51 changes: 51 additions & 0 deletions .github/actions/setup-sparkle/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Setup Sparkle
description: Installs Sparkle's generate_appcast and sign_update at the version the app resolves, and exports SPARKLE_BIN.

runs:
using: composite
steps:
- name: Install the Sparkle tools
shell: bash
# These tools hold the EdDSA key that signs every update, so they come from the archive SwiftPM
# verifies for the framework: Sparkle's Package.swift at the pinned revision names its checksum.
env:
RESOLVED: TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
run: |
set -euo pipefail
pin="$RUNNER_TEMP/sparkle-pin.txt"
python3 - "$RESOLVED" > "$pin" << 'PYTHON'
import json
import sys

pins = [pin for pin in json.load(open(sys.argv[1]))["pins"] if pin["identity"] == "sparkle"]
if len(pins) != 1:
sys.exit(f"{sys.argv[1]} pins Sparkle {len(pins)} times")
print(pins[0]["state"]["version"], pins[0]["state"]["revision"])
PYTHON
read -r version revision < "$pin"
case "$version" in *[!0-9.]* | '') echo "::error::Sparkle pin '$version' is not a version"; exit 1 ;; esac
case "$revision" in *[!0-9a-f]* | '') echo "::error::Sparkle pin '$revision' is not a commit"; exit 1 ;; esac

manifest="$(curl -fsSL --retry 3 "https://raw.githubusercontent.com/sparkle-project/Sparkle/$revision/Package.swift")"
checksum="$(sed -n 's/^let checksum = "\([0-9a-f]\{64\}\)"$/\1/p' <<< "$manifest")"
tag="$(sed -n 's/^let tag = "\(.*\)"$/\1/p' <<< "$manifest")"
if [ -z "$checksum" ] || [ "$tag" != "$version" ]; then
echo "::error::Sparkle's Package.swift at $revision names tag '$tag' and checksum '$checksum', not $version"
exit 1
fi

archive="$RUNNER_TEMP/sparkle-$version.zip"
dir="$RUNNER_TEMP/sparkle-$version"
curl -fsSL --retry 3 -o "$archive" \
"https://github.com/sparkle-project/Sparkle/releases/download/$version/Sparkle-for-Swift-Package-Manager.zip"
echo "$checksum $archive" | shasum -a 256 -c -
rm -rf "$dir"
unzip -q "$archive" -d "$dir"
for tool in generate_appcast sign_update; do
if [ ! -x "$dir/bin/$tool" ]; then
echo "::error::the Sparkle $version archive has no bin/$tool"
exit 1
fi
done
echo "SPARKLE_BIN=$dir/bin" >> "$GITHUB_ENV"
echo "Sparkle $version tools in $dir/bin"
3 changes: 0 additions & 3 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,6 @@ jobs:
- name: Check currentPluginKitVersion moved at most once since the last release
run: python3 scripts/ci/check-pluginkit-bump-cadence.py --remote origin

- name: Check the Sparkle version agrees everywhere
run: python3 scripts/ci/check-sparkle-version.py

- name: Check the shared-contract drift gates
run: scripts/audit-refactor-health.sh --check

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Theme Editor window with a live preview, undo and Revert Changes. Open windows show each edit as you make it.
- iCloud sync of custom themes and the chosen light and dark themes, with Settings on in Settings > Sync.
- Theme import by dropping files on the Themes list, and export by dragging a theme to Finder.
- Get beta updates in Settings > General, from a signed update feed at updates.tablepro.app.

### Changed

Expand Down
65 changes: 24 additions & 41 deletions TablePro/Core/Services/Infrastructure/SoftwareUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ import Foundation
import os
import Sparkle

/// Sparkle owns the update preferences, and this is the only place the app reads or writes them.
///
/// `SPUUpdater.h:198-201` says it twice: do not keep an additional user default for
/// `automaticallyChecksForUpdates`, and do not set it on every launch unless the user's own
/// preference is meant to be ignored. The initial values belong in Info.plist instead, which is
/// what `SparkleUpdatePreferences` pins.
///
/// The published values are a mirror, not a store. Each is fed by KVO from the property that owns
/// it, so a managed preference or Sparkle's own alert reaches the UI without anything having to
/// notice it happened.
@MainActor
final class SoftwareUpdater: ObservableObject {
static let shared = SoftwareUpdater()
Expand All @@ -27,8 +17,9 @@ final class SoftwareUpdater: ObservableObject {
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SoftwareUpdater")

private let controller: SPUStandardUpdaterController
/// Retained here because `SPUStandardUpdaterController` holds both delegates weakly.
// Retained here because `SPUStandardUpdaterController` holds both delegates weakly.
private let delegate = SoftwareUpdaterDelegate()
private let defaults = AppStorageEnvironment.shared.defaults
private var observations: [NSKeyValueObservation] = []
private var hasStarted = false

Expand All @@ -38,10 +29,7 @@ final class SoftwareUpdater: ObservableObject {
@Published private(set) var allowsAutomaticUpdates = true
@Published private(set) var updateCheckInterval: TimeInterval = 0
@Published private(set) var lastUpdateCheckDate: Date?

/// A scheduled update the app declined to put in front of the user. Both the app menu item and
/// the settings button read it, because a gentle reminder that shows in one place a person
/// never opens is the same as no reminder at all.
@Published private(set) var receivesBetaUpdates = false
@Published private(set) var hasPendingUpdate = false

private init() {
Expand All @@ -67,8 +55,6 @@ final class SoftwareUpdater: ObservableObject {
controller.updater
}

/// The title both the app menu item and the settings button show, so a deferred update is
/// announced in the two places a person looks without either inventing its own wording.
var checkForUpdatesTitle: String {
Self.checkForUpdatesTitle(hasPendingUpdate: hasPendingUpdate)
}
Expand All @@ -93,21 +79,26 @@ final class SoftwareUpdater: ObservableObject {
refreshFromUpdater()
}

/// Hands the update preferences back to Info.plist.
///
/// `AppSettingsManager.resetToDefaults()` reassigns the settings structs, and these values are
/// not in them, so without this "reset all settings across every section" would leave the
/// update section exactly as the user set it.
///
/// The standard domain rather than `AppStorageEnvironment`: Sparkle reaches these keys through
/// `SUHost`, which reads the main bundle's own domain, so a redirected suite would clear a
/// copy nothing consults.
func setReceivesBetaUpdates(_ enabled: Bool) {
UpdateChannel.select(enabled ? .beta : .stable, in: defaults)
if hasStarted {
updater.resetUpdateCycleAfterShortDelay()
}
refreshFromUpdater()
}

func resetUpdatePreferences() {
// Sparkle reads these through `SUHost`, which uses the main bundle's own domain, never a redirected suite.
SparkleUpdatePreferences.reset(in: UserDefaults.standard)
UpdateChannel.clearSelection(in: defaults)
refreshFromUpdater()
Self.logger.info("Update preferences reset to the values declared in Info.plist")
}

fileprivate var allowedChannels: Set<String> {
UpdateChannel.selected(in: defaults).sparkleChannels
}

fileprivate func recordDeferredUpdate() {
hasPendingUpdate = true
}
Expand All @@ -130,17 +121,11 @@ final class SoftwareUpdater: ObservableObject {
Self.logger.error("Update driver aborted: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)")
}

/// Every property here is documented KVO-compliant in `SPUUpdater.h`.
///
/// The hop onto the main actor is not ceremony and must stay. `SPUUpdater` is
/// `NS_SWIFT_UI_ACTOR`, but these notifications do not all originate in the app: `SUHost.m:88`
/// puts a KVO observer on `NSUserDefaults`, and a change written from outside the process, which
/// is exactly what a managed preference is, arrives on whatever thread delivers it. Reading the
/// updater there would be off-actor, and `assumeIsolated` would trap rather than lag.
private func observeUpdater() {
let updater = controller.updater
func mirror<Value>(_ keyPath: KeyPath<SPUUpdater, Value>) -> NSKeyValueObservation {
updater.observe(keyPath, options: [.new]) { [weak self] _, _ in
// A managed preference reaches `SUHost`'s defaults observer off the main thread.
Task { @MainActor [weak self] in self?.refreshFromUpdater() }
}
}
Expand All @@ -153,28 +138,29 @@ final class SoftwareUpdater: ObservableObject {
]
}

/// Called after every write and from every observation, so a missed KVO edge cannot leave a
/// control showing something the updater does not agree with. `lastUpdateCheckDate` is not
/// documented KVO-compliant, so it rides along here and on the update-cycle callback instead.
private func refreshFromUpdater() {
let updater = controller.updater
canCheckForUpdates = updater.canCheckForUpdates
automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates
allowsAutomaticUpdates = updater.allowsAutomaticUpdates
updateCheckInterval = updater.updateCheckInterval
// Not KVO-compliant, so it is read here and on the update-cycle callback.
lastUpdateCheckDate = updater.lastUpdateCheckDate
receivesBetaUpdates = UpdateChannel.selected(in: defaults) == .beta
}
}

/// Sparkle's two delegate surfaces, kept off `SoftwareUpdater` so the app's model is not also an
/// `NSObject` bound to an Objective-C protocol.
@MainActor
private final class SoftwareUpdaterDelegate: NSObject {
weak var owner: SoftwareUpdater?
}

extension SoftwareUpdaterDelegate: SPUUpdaterDelegate {
func allowedChannels(for updater: SPUUpdater) -> Set<String> {
owner?.allowedChannels ?? []
}

func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
owner?.recordUpdateCycleFinished(error: error)
}
Expand All @@ -184,9 +170,6 @@ extension SoftwareUpdaterDelegate: SPUUpdaterDelegate {
}
}

/// Gentle scheduled reminders, the shape Sparkle documents for an app with a Dock icon: a check the
/// person did not ask for never takes the front, and the app carries the news in its own UI until
/// they come back to it.
extension SoftwareUpdaterDelegate: @preconcurrency SPUStandardUserDriverDelegate {
var supportsGentleScheduledUpdateReminders: Bool { true }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,15 @@
//
// SparkleUpdatePreferences.swift
// TablePro
//

import Foundation

/// The Sparkle preference keys the app depends on, and the rule that every one of them carries a
/// default the app ships rather than a default Sparkle infers.
///
/// Sparkle resolves each key as user defaults first and the bundle's Info.plist second
/// (`SUHost.boolNumberForKey:`). A key declared in neither resolves to `NO`, and the three update
/// settings are chained: with `SUEnableAutomaticChecks` absent, `allowsAutomaticUpdates` is false,
/// and `SPUUpdaterSettings.setAutomaticallyDownloadsUpdates` then returns without writing anything.
/// So a `SUAutomaticallyUpdate` of `true` read as `false` at runtime, and the second toggle in the
/// settings pane was inert rather than merely dimmed.
///
/// `SparkleUpdatePreferencesTests` reads the running bundle and fails when a key here has no
/// declared default, which is the check that absence defeated.
enum SparkleUpdatePreferences {
/// Sparkle writes these into the main bundle's standard domain through `SUHost` when the user
/// changes a control. Removing them hands governance back to Info.plist, which is what
/// resetting settings has to mean.
static let userSettableKeys = [
"SUEnableAutomaticChecks",
"SUScheduledCheckInterval",
"SUAutomaticallyUpdate",
]

/// `SUScheduledImpatientCheckInterval` is deliberately absent: Sparkle's own default is the
/// value we want, and a redeclared default is one more pair of numbers that can drift apart.
static let requiredInfoPlistKeys = [
"SUFeedURL",
"SUPublicEDKey",
"SUEnableAutomaticChecks",
"SUScheduledCheckInterval",
"SUAutomaticallyUpdate",
]

static func reset(in defaults: UserDefaults) {
for key in userSettableKeys {
defaults.removeObject(forKey: key)
}
}

static func keysMissingDefaults(in bundle: Bundle) -> [String] {
requiredInfoPlistKeys.filter { bundle.object(forInfoDictionaryKey: $0) == nil }
}
}
35 changes: 35 additions & 0 deletions TablePro/Core/Services/Infrastructure/UpdateChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

internal enum UpdateChannel: String, Sendable {
case stable
case beta
}

internal extension UpdateChannel {
// Sparkle always adds its default channel, which carries every stable item.
var sparkleChannels: Set<String> {
switch self {
case .stable: []
case .beta: [Self.beta.rawValue]
}
}

func accepts(_ releaseChannel: String) -> Bool {
switch self {
case .stable: releaseChannel == Self.stable.rawValue
case .beta: Self(rawValue: releaseChannel) != nil
}
}

static func selected(in defaults: UserDefaults) -> UpdateChannel {
defaults.bool(forKey: PreferenceKeys.receivesBetaUpdates.name) ? .beta : .stable
}

static func select(_ channel: UpdateChannel, in defaults: UserDefaults) {
defaults.set(channel == .beta, forKey: PreferenceKeys.receivesBetaUpdates.name)
}

static func clearSelection(in defaults: UserDefaults) {
defaults.removeObject(forKey: PreferenceKeys.receivesBetaUpdates.name)
}
}
8 changes: 3 additions & 5 deletions TablePro/Core/Storage/Preferences/PreferenceKeys.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PreferenceKeys.swift
// TablePro
//

import Foundation

enum PreferenceKeys {
Expand All @@ -25,6 +20,8 @@ enum PreferenceKeys {
static let connectionListFavoritesOrder = DefaultsKey<[String]>("com.TablePro.connectionList.favoritesOrder")
static let connectionListShowsRecent = DefaultsKey<Bool>("com.TablePro.connectionList.showsRecent")
static let recentConnections = DefaultsKey<Data>("com.TablePro.connectionList.recentConnections")
/// Device-local, and in the standard domain so a configuration profile can pin it.
static let receivesBetaUpdates = DefaultsKey<Bool>("com.TablePro.updates.receivesBetaUpdates")

static let registeredKeyNames: [String] = [
connectionListSortMode.name,
Expand All @@ -43,6 +40,7 @@ enum PreferenceKeys {
queryPlanBarMetric.name,
lastBackupDirectory.name,
lastSeenAppVersion.name,
receivesBetaUpdates.name,
]

static let columnDisplayFormatsPrefix = "com.TablePro.columns.displayFormat."
Expand Down
8 changes: 6 additions & 2 deletions TablePro/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
<key>NSLocalNetworkUsageDescription</key>
<string>TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames.</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml</string>
<string>https://updates.tablepro.app/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>EongGFyuahKlYPZwgmnFx8nW3s1CqWlSSU5BDqY6n6Q=</string>
<string>PAQXJZpmlif12LfOoRkGQqf5lGQqPPf62E6dn7Jovhk=</string>
<key>SURequireSignedFeed</key>
<true/>
<key>SUVerifyUpdateBeforeExtraction</key>
<true/>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -30471,6 +30471,9 @@
"Bell" : {
"extractionState" : "manual",
"generatesSymbol" : false
},
"Betas get new features first. Turning this off keeps the beta you have until a newer release ships." : {

},
"between" : {
"localizations" : {
Expand Down Expand Up @@ -84183,6 +84186,9 @@
},
"Geometry: %@" : {

},
"Get beta updates" : {

},
"Get by key" : {
"extractionState" : "manual",
Expand Down
Loading
Loading