3 Commits

Author SHA1 Message Date
Micha
b96b018f70 chore: release 26.0.41 2025-12-30 13:18:57 +01:00
Micha
65a65939a7 chore: remove local appcast testing scripts
Remove make_local_appcast.sh and serve_local_appcast.sh as they added
complexity without sufficient benefit. Test updates directly with published releases.
2025-12-30 13:15:27 +01:00
Micha
25723b7f07 feat: add in-app Sparkle update logging
- Add published logMessages array to SparkleUpdater to track all update events
- Display logs in the Updates preferences tab with Show/Hide toggle
- Each log entry is timestamped and shows both info and error messages
- Logs persist during session with max 100 entries
- Users can clear logs manually
- Helps diagnose update failures directly in the app UI
2025-12-30 13:12:27 +01:00
7 changed files with 115 additions and 132 deletions

View File

@@ -9,6 +9,8 @@ final class SparkleUpdater: NSObject, ObservableObject {
}() }()
private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle") private let logger = Logger(subsystem: "net.24unix.iKeyMon", category: "Sparkle")
private let verboseLogging: Bool private let verboseLogging: Bool
@Published var logMessages: [String] = []
@Published var showLogs: Bool = false
override init() { override init() {
self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1" self.verboseLogging = ProcessInfo.processInfo.environment["SPARKLE_VERBOSE_LOGGING"] == "1"
@@ -34,6 +36,7 @@ final class SparkleUpdater: NSObject, ObservableObject {
private func log(_ message: String) { private func log(_ message: String) {
logger.log("\(message, privacy: .public)") logger.log("\(message, privacy: .public)")
addLogMessage("[INFO] \(message)")
if verboseLogging { if verboseLogging {
print("[Sparkle] \(message)") print("[Sparkle] \(message)")
} }
@@ -41,11 +44,21 @@ final class SparkleUpdater: NSObject, ObservableObject {
private func logError(_ message: String) { private func logError(_ message: String) {
logger.error("\(message, privacy: .public)") logger.error("\(message, privacy: .public)")
addLogMessage("[ERROR] \(message)")
if verboseLogging { if verboseLogging {
fputs("[Sparkle][error] \(message)\n", stderr) fputs("[Sparkle][error] \(message)\n", stderr)
} }
} }
private func addLogMessage(_ message: String) {
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
let timestampedMessage = "[\(timestamp)] \(message)"
logMessages.append(timestampedMessage)
if logMessages.count > 100 {
logMessages.removeFirst()
}
}
private func describe(update item: SUAppcastItem) -> String { private func describe(update item: SUAppcastItem) -> String {
let short = item.displayVersionString let short = item.displayVersionString
let build = item.versionString let build = item.versionString
@@ -53,41 +66,58 @@ final class SparkleUpdater: NSObject, ObservableObject {
} }
} }
@MainActor
extension SparkleUpdater: SPUUpdaterDelegate { extension SparkleUpdater: SPUUpdaterDelegate {
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
Task { @MainActor in
log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).") log("Loaded Sparkle appcast containing \(appcast.items.count) item(s).")
} }
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Found valid update \(describe(update: item))") log("Found valid update \(describe(update: item))")
} }
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater) { nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
Task { @MainActor in
log("No updates available.") log("No updates available.")
} }
}
func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) { nonisolated func updater(_ updater: SPUUpdater, willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest) {
Task { @MainActor in
log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")") log("Downloading \(describe(update: item)) from \(request.url?.absoluteString ?? "unknown URL")")
} }
}
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Finished downloading \(describe(update: item))") log("Finished downloading \(describe(update: item))")
} }
}
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
Task { @MainActor in
logError("Failed to download \(describe(update: item)): \(error.localizedDescription)") logError("Failed to download \(describe(update: item)): \(error.localizedDescription)")
} }
}
func userDidCancelDownload(_ updater: SPUUpdater) { nonisolated func userDidCancelDownload(_ updater: SPUUpdater) {
Task { @MainActor in
log("User cancelled Sparkle download.") log("User cancelled Sparkle download.")
} }
func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
log("Will install update \(describe(update: item))")
} }
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { nonisolated func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
Task { @MainActor in
log("Will install update \(describe(update: item))")
}
}
nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
Task { @MainActor in
logError("Sparkle aborted: \(error.localizedDescription)") logError("Sparkle aborted: \(error.localizedDescription)")
} }
} }
}

View File

@@ -257,11 +257,64 @@ private struct UpdatesPreferencesView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, 4) .padding(.top, 4)
Divider()
.padding(.vertical, 8)
Button(action: { sparkleUpdater.showLogs.toggle() }) {
Label(sparkleUpdater.showLogs ? "Hide Logs" : "Show Logs", systemImage: "terminal.fill")
}
if sparkleUpdater.showLogs {
logsView
}
Spacer() Spacer()
} }
.toggleStyle(.switch) .toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
private var logsView: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Update Logs")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
if sparkleUpdater.logMessages.isEmpty {
Text("No logs yet. Check for updates to see activity.")
.font(.caption)
.foregroundColor(.secondary)
.italic()
} else {
ForEach(sparkleUpdater.logMessages, id: \.self) { message in
Text(message)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(height: 200)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
.border(.separator, width: 1)
Button(action: {
sparkleUpdater.logMessages.removeAll()
}) {
Label("Clear Logs", systemImage: "trash")
.font(.caption)
}
.disabled(sparkleUpdater.logMessages.isEmpty)
}
}
} }
private struct NotificationsPreferencesView: View { private struct NotificationsPreferencesView: View {

16
Sparkle/appcast.xml vendored
View File

@@ -2,6 +2,14 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>iKeyMon</title> <title>iKeyMon</title>
<item>
<title>26.0.41</title>
<pubDate>Tue, 30 Dec 2025 13:18:56 +0100</pubDate>
<sparkle:version>86</sparkle:version>
<sparkle:shortVersionString>26.0.41</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.41/iKeyMon-26.0.41.zip" length="4847296" type="application/octet-stream" sparkle:edSignature="8ITx6pM0LDVh0IpAz7eej7+eomnLTvRbchNaxSbdfQT/A16Bxg6F+FgtRDJkV5khJTrunpEPal9x3OUi7FO4Bg=="/>
</item>
<item> <item>
<title>26.0.40</title> <title>26.0.40</title>
<pubDate>Tue, 30 Dec 2025 13:01:18 +0100</pubDate> <pubDate>Tue, 30 Dec 2025 13:01:18 +0100</pubDate>
@@ -18,13 +26,5 @@
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.39/iKeyMon-26.0.39.zip" length="4829477" type="application/octet-stream" sparkle:edSignature="/J0LTgTj5uFa30okaLC7l6+wFUQxu8/E18y8vu3wqCXnM8tCG6TanZaGY69UWTUzaO9858oORy2yY6/MYtERBw=="/> <enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.39/iKeyMon-26.0.39.zip" length="4829477" type="application/octet-stream" sparkle:edSignature="/J0LTgTj5uFa30okaLC7l6+wFUQxu8/E18y8vu3wqCXnM8tCG6TanZaGY69UWTUzaO9858oORy2yY6/MYtERBw=="/>
</item> </item>
<item>
<title>26.0.38</title>
<pubDate>Tue, 30 Dec 2025 12:37:13 +0100</pubDate>
<sparkle:version>80</sparkle:version>
<sparkle:shortVersionString>26.0.38</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.38/iKeyMon-26.0.38.zip" length="4829624" type="application/octet-stream" sparkle:edSignature="Pydppvzp9xiEXWOTObEyx6f7m3qZyFZYHCovclUk8xEbXNUAas70296TrjKvAhJvu9dLkObEyO39A/B2ZLQTAw=="/>
</item>
</channel> </channel>
</rss> </rss>

View File

@@ -322,7 +322,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 83; CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -337,7 +337,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.40; MARKETING_VERSION = 26.0.41;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -353,7 +353,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 83; CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT; DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 26.0.40; MARKETING_VERSION = 26.0.41;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon; PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

View File

@@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate a local appcast that reuses the latest Sparkle entry but bumps the
# sparkle:version so Sparkle will reinstall the same build. Useful for testing
# updates without cutting a new release.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INPUT="${1:-$ROOT_DIR/Sparkle/appcast.xml}"
OUTPUT="${2:-$ROOT_DIR/Sparkle/appcast-local.xml}"
python3 - "$INPUT" "$OUTPUT" <<'PY'
import sys, xml.etree.ElementTree as ET, datetime, email.utils
if len(sys.argv) != 3:
sys.exit("Usage: make_local_appcast.sh [input] [output]")
src, dst = sys.argv[1], sys.argv[2]
tree = ET.parse(src)
root = tree.getroot()
channel = root.find("channel")
items = channel.findall("item") if channel is not None else []
if not items:
sys.exit("No items found in appcast")
latest = items[0]
enc = latest.find("enclosure")
if enc is None:
sys.exit("Latest item missing enclosure")
sparkle_ns = "{http://www.andymatuschak.org/xml-namespaces/sparkle}"
def get_text(tag):
el = latest.find(tag)
return el.text if el is not None else ""
short_version = get_text(f"{sparkle_ns}shortVersionString")
version_el = latest.find(f"{sparkle_ns}version")
try:
next_build = str(int(version_el.text) + 1000 if version_el is not None else 9999)
except ValueError:
next_build = "9999"
now_rfc822 = email.utils.format_datetime(datetime.datetime.now(datetime.timezone.utc))
new_item = ET.Element("item")
ET.SubElement(new_item, "title").text = f"{short_version} (reinstall)"
ET.SubElement(new_item, "pubDate").text = now_rfc822
sv = ET.SubElement(new_item, f"{sparkle_ns}shortVersionString")
sv.text = short_version
bv = ET.SubElement(new_item, f"{sparkle_ns}version")
bv.text = next_build
min_os = latest.find(f"{sparkle_ns}minimumSystemVersion")
if min_os is not None and min_os.text:
ET.SubElement(new_item, f"{sparkle_ns}minimumSystemVersion").text = min_os.text
new_enc = ET.SubElement(new_item, "enclosure")
for attr in ("url", "length", "type", f"{sparkle_ns}edSignature"):
if attr in enc.attrib:
new_enc.set(attr, enc.attrib[attr])
new_channel = ET.Element("channel")
title = channel.find("title")
ET.SubElement(new_channel, "title").text = title.text if title is not None else "Local"
new_channel.append(new_item)
new_root = ET.Element("rss", {
"version": "2.0",
"xmlns:sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"
})
new_root.append(new_channel)
ET.indent(new_root, space=" ")
ET.ElementTree(new_root).write(dst, encoding="utf-8", xml_declaration=True)
print(f"✅ wrote {dst}")
PY

View File

@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate a local appcast and serve it over HTTP for Sparkle testing without
# publishing a new release. Sparkle disallows file:// feeds, so we use
# http://localhost:${PORT}/appcast-local.xml.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PORT="${PORT:-8000}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
"$ROOT_DIR/scripts/make_local_appcast.sh" "${1:-$ROOT_DIR/Sparkle/appcast.xml}" "$WORKDIR/appcast-local.xml"
echo "🍏 Serving local appcast on http://127.0.0.1:${PORT}/appcast-local.xml"
echo "Set SUFeedURL to that URL, then launch iKeyMon and check for updates."
echo
echo "To set it:"
echo " defaults write net.24unix.iKeyMon SUFeedURL \"http://127.0.0.1:${PORT}/appcast-local.xml\""
echo " killall iKeyMon 2>/dev/null; open /Applications/iKeyMon.app"
echo
echo "Press Ctrl+C to stop."
cd "$WORKDIR"
python3 -m http.server "$PORT"

View File

@@ -1,3 +1,3 @@
{ {
"marketing_version": "26.0.40" "marketing_version": "26.0.41"
} }