Skip to content
Open
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
49 changes: 48 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ env:
APP_NAME: PureMac
BUNDLE_ID: com.puremac.app
TEAM_ID: H3WXHVTP97
SPARKLE_VERSION: 2.9.2
SCHEME: PureMac
PROJECT: PureMac.xcodeproj
CONFIGURATION: Release
Expand Down Expand Up @@ -129,13 +130,23 @@ jobs:
run: |
set -euo pipefail
APP="build/export/PureMac.app"
codesign --verify --deep --strict --verbose=2 "${APP}"
codesign --verify --deep --strict --verbose=4 "${APP}"
codesign -dvv "${APP}" 2>&1 | tee /tmp/cs-info.txt
grep -q "flags=0x10000(runtime)" /tmp/cs-info.txt || { echo "::error::Hardened runtime missing"; exit 1; }
spctl --assess --type execute --verbose=4 "${APP}" || true
# Universal arch check
lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "x86_64" && lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "arm64"

- name: Verify Sparkle helper bundles
run: |
set -euo pipefail
APP="build/export/PureMac.app"
while IFS= read -r helper; do
if [[ -n "${helper}" ]]; then
codesign --verify --deep --strict --verbose=4 "${helper}"
fi
done < <(find "${APP}" -type d \( -name 'Autoupdate.app' -o -name 'Updater.app' \) | sort)

- name: Build DMG
env:
VERSION: ${{ steps.ver.outputs.version }}
Expand Down Expand Up @@ -202,6 +213,39 @@ jobs:
# Final shippable zip with stapled ticket
ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app "${ZIP}"

- name: Download Sparkle release tools
run: |
set -euo pipefail
SPARKLE_TARBALL="${RUNNER_TEMP}/Sparkle-${SPARKLE_VERSION}.tar.xz"
SPARKLE_DIR="${RUNNER_TEMP}/Sparkle-${SPARKLE_VERSION}"
curl -L -o "${SPARKLE_TARBALL}" "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
rm -rf "${SPARKLE_DIR}"
tar -xf "${SPARKLE_TARBALL}" -C "${RUNNER_TEMP}"
echo "SPARKLE_BIN_DIR=${SPARKLE_DIR}/bin" >> "$GITHUB_ENV"

- name: Generate Sparkle appcast
env:
VERSION: ${{ steps.ver.outputs.version }}
TAG: ${{ steps.ver.outputs.tag }}
SPARKLE_ED25519_PRIVATE_KEY: ${{ secrets.SPARKLE_ED25519_PRIVATE_KEY }}
run: |
set -euo pipefail
APPCAST_DIR="build/appcast"
DMG="build/PureMac-${VERSION}.dmg"
rm -rf "${APPCAST_DIR}"
mkdir -p "${APPCAST_DIR}"
cp "${DMG}" "${APPCAST_DIR}/"
# Sign the DMG explicitly (sign_update) so the signature is available
# for auditing and log output. Then generate the signed appcast.
printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_BIN_DIR}/sign_update" --ed-key-file - "${APPCAST_DIR}/$(basename "${DMG}")" || true

printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_BIN_DIR}/generate_appcast" \
--ed-key-file - \
--output-path "${APPCAST_DIR}/appcast.xml" \
--download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \
--full-release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" \
"${APPCAST_DIR}"

- name: Compute SHA256 checksums
id: sha
env:
Expand All @@ -228,6 +272,7 @@ jobs:
path: |
build/PureMac-${{ steps.ver.outputs.version }}.dmg
build/PureMac-${{ steps.ver.outputs.version }}.zip
build/appcast/appcast.xml
build/CHECKSUMS.md
retention-days: 14

Expand Down Expand Up @@ -259,11 +304,13 @@ jobs:
gh release upload "${TAG}" \
"build/PureMac-${VERSION}.dmg" \
"build/PureMac-${VERSION}.zip" \
"build/appcast/appcast.xml" \
--repo "${GITHUB_REPOSITORY}" --clobber
else
gh release create "${TAG}" \
"build/PureMac-${VERSION}.dmg" \
"build/PureMac-${VERSION}.zip" \
"build/appcast/appcast.xml" \
--title "PureMac ${TAG}" \
--notes-file "${NOTES_FILE}" \
--repo "${GITHUB_REPOSITORY}"
Expand Down
4 changes: 4 additions & 0 deletions PureMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,8 @@
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PureMac/Info.plist;
MACOSX_DEPLOYMENT_TARGET = 13.0;
PUREMAC_FEED_URL = "http://127.0.0.1:8000/appcast.xml";
PUREMAC_PUBLIC_ED_KEY = "G2pCmpU2grTTNsigrEmNsPmZX2LjiMBkvMP1fvMCuig=";
MARKETING_VERSION = 2.6.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
Expand Down Expand Up @@ -673,6 +675,8 @@
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PureMac/Info.plist;
MACOSX_DEPLOYMENT_TARGET = 13.0;
PUREMAC_FEED_URL = "https://github.com/momenbasel/PureMac/releases/latest/download/appcast.xml";
PUREMAC_PUBLIC_ED_KEY = "IEOLcqFwQpIXmRrnmBzgjxIR2L9bnh+C+Oye6cMkePk=";
MARKETING_VERSION = 2.6.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
Expand Down
8 changes: 8 additions & 0 deletions PureMac/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>SUFeedURL</key>
<string>$(PUREMAC_FEED_URL)</string>
<key>SUPublicEDKey</key>
<string>$(PUREMAC_PUBLIC_ED_KEY)</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2026. MIT License.</string>
<key>NSDesktopFolderUsageDescription</key>
Expand Down
6 changes: 6 additions & 0 deletions PureMac/PureMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Touch TCC-protected paths so macOS registers PureMac in the
// Full Disk Access pane on first launch (fixes issue #75).
FullDiskAccessManager.shared.triggerRegistration()

if ProcessInfo.processInfo.environment["PUREMAC_AUTO_CHECK_UPDATES"] == "1" {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
UpdateService.shared.checkForUpdates()
}
}
}
}

Expand Down
106 changes: 105 additions & 1 deletion PureMac/Services/UpdateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,115 @@ final class UpdateService: ObservableObject {

private init() {
#if canImport(Sparkle)
// Do not start automatic checking by default; keep control minimal.
// Create the standard updater controller but do not start it automatically.
updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)

// Install lightweight runtime observers to surface Sparkle notifications to the app console.
installDebugObservers()

// If the updater's stored preference indicates automatic checks, start the updater so Sparkle can schedule checks.
if let updater = updaterController?.updater, updater.automaticallyChecksForUpdates {
updaterController?.startUpdater()
}
#endif
}

#if canImport(Sparkle)
private var debugObservers: [Any] = []

private func installDebugObservers() {
let nc = NotificationCenter.default

debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidFinishLoadingAppCastNotification"), object: nil, queue: .main) { note in
print("NOTIFICATION_APPCAST")
if let appcast = note.userInfo?["appcast"] as? SUAppcast {
print("APPCAST_LOADED \(appcast.items.count)")
}
})

debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidFindValidUpdateNotification"), object: nil, queue: .main) { _ in
print("NOTIFICATION_FOUND")
})

debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidNotFindUpdateNotification"), object: nil, queue: .main) { _ in
print("NOTIFICATION_NOT_FOUND")
})

debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidAbortWithErrorNotification"), object: nil, queue: .main) { note in
let msg = "NOTIFICATION_ABORTED \(String(describing: note.userInfo?["error"]))"
print(msg)
self.appendDebugLog(msg)
})
// Global observer: capture any Sparkle-related notifications for debugging.
debugObservers.append(nc.addObserver(forName: nil, object: nil, queue: .main) { note in
let name = note.name.rawValue
if name.contains("SU") || name.lowercased().contains("sparkle") {
let msg = "NOTIF: \(name) userInfo=\(String(describing: note.userInfo))"
print(msg)
self.appendDebugLog(msg)
}
})
}

private func appendDebugLog(_ message: String) {
let fm = FileManager.default
let logDir = fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/PureMac")
try? fm.createDirectory(at: logDir, withIntermediateDirectories: true)
let logFile = logDir.appendingPathComponent("update-debug.log")
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] \(message)\n"
if let data = line.data(using: .utf8) {
if fm.fileExists(atPath: logFile.path) {
if let fh = try? FileHandle(forWritingTo: logFile) {
fh.seekToEndOfFile()
fh.write(data)
try? fh.close()
}
} else {
try? data.write(to: logFile)
}
}
}
#endif

#if canImport(Sparkle)
var updater: SPUUpdater? { updaterController?.updater }

/// Set whether Sparkle should automatically check for updates.
func setAutomaticallyChecks(_ enabled: Bool) {
DispatchQueue.main.async {
guard let updater = self.updaterController?.updater else {
UserDefaults.standard.set(enabled, forKey: "settings.updates.checkAutomatically")
return
}
updater.automaticallyChecksForUpdates = enabled
if enabled {
self.updaterController?.startUpdater()
}
updater.resetUpdateCycleAfterShortDelay()
}
}

/// Set the Sparkle update check interval (seconds).
func setUpdateInterval(_ seconds: TimeInterval) {
DispatchQueue.main.async {
if let updater = self.updaterController?.updater {
updater.updateCheckInterval = seconds
updater.resetUpdateCycleAfterShortDelay()
} else {
// Fallback: persist to UserDefaults for older code paths.
let raw: String
switch Int(seconds) {
case 60 * 60 * 24: raw = "Daily"
case 60 * 60 * 24 * 30: raw = "Monthly"
default: raw = "Weekly"
}
UserDefaults.standard.set(raw, forKey: "settings.updates.checkInterval")
}
}
}
#endif

func checkForUpdates() {
#if canImport(Sparkle)
DispatchQueue.main.async {
Expand Down
87 changes: 87 additions & 0 deletions PureMac/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ struct SettingsView: View {
TabView {
GeneralSettingsView()
.tabItem { Label("General", systemImage: "gear") }
UpdatesSettingsView()
.tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath") }
CleaningSettingsView()
.tabItem { Label("Cleaning", systemImage: "trash") }
ScheduleSettingsView()
Expand All @@ -18,6 +20,91 @@ struct SettingsView: View {
}
}

// MARK: - Updates

enum UpdateInterval: String, CaseIterable, Identifiable, Codable {
case daily = "Daily"
case weekly = "Weekly"
case monthly = "Monthly"

var id: String { rawValue }

var timeInterval: TimeInterval {
switch self {
case .daily: return 60 * 60 * 24
case .weekly: return 60 * 60 * 24 * 7
case .monthly: return 60 * 60 * 24 * 30
}
}
}

struct UpdatesSettingsView: View {
@State private var automaticallyChecks: Bool = true
@State private var interval: UpdateInterval = .weekly

init() {
if let updater = UpdateService.shared.updater {
_automaticallyChecks = State(initialValue: updater.automaticallyChecksForUpdates)
let seconds = updater.updateCheckInterval
if let matched = UpdateInterval.allCases.first(where: { $0.timeInterval == seconds }) {
_interval = State(initialValue: matched)
} else {
_interval = State(initialValue: .weekly)
}
} else {
let defaults = UserDefaults.standard
let enabled = defaults.object(forKey: "settings.updates.checkAutomatically") as? Bool ?? true
let raw = defaults.string(forKey: "settings.updates.checkInterval") ?? UpdateInterval.weekly.rawValue
_automaticallyChecks = State(initialValue: enabled)
_interval = State(initialValue: UpdateInterval(rawValue: raw) ?? .weekly)
}
}

var body: some View {
Form {
Section("Updates") {
Toggle("Check automatically", isOn: $automaticallyChecks)
.onChange(of: automaticallyChecks) { newValue in
UpdateService.shared.setAutomaticallyChecks(newValue)
}

Picker("Check interval", selection: Binding(
get: { interval },
set: { newValue in
interval = newValue
UpdateService.shared.setUpdateInterval(newValue.timeInterval)
}
)) {
ForEach(UpdateInterval.allCases) { value in
Text(LocalizedStringKey(value.rawValue)).tag(value)
}
}
.pickerStyle(.radioGroup)
.disabled(!automaticallyChecks)

HStack {
Spacer()
Button("Check Now") {
UpdateService.shared.checkForUpdates()
}
.keyboardShortcut("u", modifiers: [.command])
}
}
}
.formStyle(.grouped)
.onAppear {
// Sync UI from updater in case external changes occurred
if let updater = UpdateService.shared.updater {
automaticallyChecks = updater.automaticallyChecksForUpdates
let seconds = updater.updateCheckInterval
if let matched = UpdateInterval.allCases.first(where: { $0.timeInterval == seconds }) {
interval = matched
}
}
}
}
}

// MARK: - General

enum SearchSensitivity: String, CaseIterable, Identifiable, Codable {
Expand Down
4 changes: 4 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ settings:
MARKETING_VERSION: "2.6.1"
CURRENT_PROJECT_VERSION: "13"
GENERATE_INFOPLIST_FILE: "NO"
PUREMAC_FEED_URL: "https://github.com/momenbasel/PureMac/releases/latest/download/appcast.xml"
PUREMAC_PUBLIC_ED_KEY: "IEOLcqFwQpIXmRrnmBzgjxIR2L9bnh+C+Oye6cMkePk="
ASSETCATALOG_COMPILER_APPICON_NAME: "AppIcon"
COMBINE_HIDPI_IMAGES: "YES"

Expand All @@ -47,6 +49,8 @@ targets:
Debug:
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
PUREMAC_FEED_URL: "http://127.0.0.1:8000/appcast.xml"
PUREMAC_PUBLIC_ED_KEY: "G2pCmpU2grTTNsigrEmNsPmZX2LjiMBkvMP1fvMCuig="

PureMacTests:
type: bundle.unit-test
Expand Down
Loading
Loading