26 Commits

Author SHA1 Message Date
Micha
06932cde21 chore: release 26.0.70 2026-01-03 15:49:43 +01:00
Micha
7a286c68e3 feat: add status notifications for server monitoring
- Add notification preferences (Status Notifications and Alert Notifications toggles)
- Implement ping failure/recovery notifications when servers go offline/online
- Track individual service status changes and notify when services fail
- Request notification permissions on app launch
- Services like DNS, FTP, SSH, etc. now trigger alerts when status changes
- Notifications only sent when settings are enabled

Changes:
- PreferencesView: Add NotificationsPreferencesView with two toggles
- PingService: Add notification support with state tracking for ping events
- MainView: Add service status monitoring with change detection
- Track previous service states to detect transitions
2026-01-03 15:48:01 +01:00
Micha
e7b776942b fix: use AppStorage values for ping and refresh intervals
Make the ping and refresh intervals from preferences actually control
the timer frequencies. Settings now properly update timers when changed.
- Read pingInterval and refreshInterval from AppStorage
- Recreate timers when settings change
- Setup timers on app appearance with correct intervals
2026-01-03 14:31:51 +01:00
Micha
8cf974118b fix: add connection test feedback in server form
- Show error message when connection test fails
- Disable test button while testing
- Provide clear feedback when API key or hostname are invalid
- Reset error message on successful connection
2026-01-03 14:16:11 +01:00
Micha
ae83ea7dab chore: release 26.0.69 2026-01-03 14:00:42 +01:00
Micha
39205230b6 Sparkle test 2026-01-03 13:58:38 +01:00
Micha
55a266014c chore: release 26.0.68 2026-01-03 13:52:34 +01:00
Micha
c002cab616 chore: disable sandbox for now to use Sparkle updates
Disable app-sandbox to allow Sparkle auto-updates to work properly.
Keep entitlements file structure for future sandbox re-enablement.
Sandbox integration with Sparkle requires more complex authorization
setup that can be tackled later when preparing for App Store.
2026-01-03 13:50:26 +01:00
Micha
117134cead chore: release 26.0.67 2026-01-03 13:36:30 +01:00
Micha
35711d33c0 Sparkle test 2026-01-03 13:34:50 +01:00
Micha
4f3d56dc3c chore: release 26.0.66 2026-01-03 13:28:45 +01:00
Micha
0d80a0f912 fix: use basic Sparkle updater for sandboxed apps
Disable SUEnableInstallerLauncherService and remove XPC entitlements.
Use Sparkle's standard update mechanism which works with sandboxed apps.
Add file access entitlements for update storage.
2026-01-03 13:26:37 +01:00
Micha
b1d6e61f05 chore: release 26.0.65 2025-12-30 20:19:29 +01:00
Micha
2dd2c2154f fix: sign Sparkle framework separately for sandboxed builds
Sign the Sparkle framework before signing the whole app to ensure
proper code signature chain for sandboxed installation.
2025-12-30 20:17:47 +01:00
Micha
c6ecbbe511 chore: release 26.0.64 2025-12-30 20:14:03 +01:00
Micha
77a145604c Sparkle test 2025-12-30 20:12:24 +01:00
Micha
0016030ff3 chore: release 26.0.63 2025-12-30 20:06:55 +01:00
Micha
615d664731 fix: configure sandbox for Sparkle installer with proper entitlements
- Add downloads folder read-write access for installer
- Enable SUEnableInstallerLauncherService for sandboxed update installation
- Keep XPC service entitlements for installer communication
2025-12-30 20:04:26 +01:00
Micha
5644fbdfe0 chore: release 26.0.62 2025-12-30 19:55:55 +01:00
Micha
9b5883fe77 Sparkle test 2025-12-30 19:54:08 +01:00
Micha
5d15810802 chore: release 26.0.61 2025-12-30 19:53:12 +01:00
Micha
519d15ed10 Sparkle test 2025-12-30 19:51:35 +01:00
Micha
446bbe7f98 chore: release 26.0.60 2025-12-30 19:50:43 +01:00
Micha
b67fffd3f0 fix: re-add XPC service entitlements for sandboxed Sparkle installer
Add back InstallerConnection and InstallerStatus entitlements which are
required for the sandboxed app to communicate with Sparkle's installer
XPC service.
2025-12-30 19:48:44 +01:00
Micha
84935ee8fd chore: release 26.0.59 2025-12-30 19:42:29 +01:00
Micha
0f266f7046 Sparkle test 2025-12-30 19:40:37 +01:00
10 changed files with 186 additions and 43 deletions

View File

@@ -5,6 +5,5 @@
add a marker for "reboot required"
dummy2
1112

View File

@@ -1,7 +1,10 @@
import Foundation
import UserNotifications
enum PingService {
static func ping(hostname: String, apiKey: String) async -> Bool {
private static var previousPingStates: [String: Bool] = [:]
static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool {
guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
print("❌ [PingService] Invalid URL for \(hostname)")
return false
@@ -18,17 +21,49 @@ enum PingService {
if let responseString = String(data: data, encoding: .utf8) {
print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)")
}
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false
}
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" {
handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled)
return true
} else {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false
}
} catch {
print("❌ [PingService] Error pinging \(hostname): \(error)")
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false
}
}
private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) {
let wasPreviouslyDown = previousPingStates[hostname] == false
previousPingStates[hostname] = true
if wasPreviouslyDown && notificationsEnabled {
sendNotification(title: "Server Online", body: "\(hostname) is now online")
}
}
private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) {
let wasPreviouslyUp = previousPingStates[hostname] != false
previousPingStates[hostname] = false
if wasPreviouslyUp && notificationsEnabled {
sendNotification(title: "Server Offline", body: "\(hostname) is offline")
}
}
private static func sendNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}

View File

@@ -6,6 +6,8 @@
//
import SwiftUI
import Combine
import UserNotifications
struct MainView: View {
@@ -17,8 +19,15 @@ struct MainView: View {
@State private var serverToDelete: Server?
@State private var showDeleteConfirmation = false
@State private var isFetchingInfo: Bool = false
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
@AppStorage("pingInterval") private var pingInterval: Int = 10
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
@State private var refreshTimer: Timer.TimerPublisher?
@State private var refreshSubscription: AnyCancellable?
@State private var pingTimer: Timer?
@State private var lastRefreshInterval: Int?
@State private var previousServiceStates: [String: String] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic
@@ -97,12 +106,9 @@ struct MainView: View {
}
Button("Cancel", role: .cancel) {}
}
.onReceive(refreshTimer) { _ in
for server in servers {
fetchServerInfo(for: server.id)
}
}
.onAppear {
requestNotificationPermissions()
let initialID: UUID?
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID),
@@ -124,9 +130,14 @@ struct MainView: View {
await prefetchOtherServers(activeID: initialID)
}
}
pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
pingAllServers()
setupTimers()
}
.onChange(of: pingInterval) { _, _ in
setupPingTimer()
}
.onChange(of: refreshInterval) { oldValue, newValue in
if oldValue != newValue {
setupRefreshTimer()
}
}
.frame(minWidth: 800, minHeight: 450)
@@ -159,6 +170,7 @@ struct MainView: View {
var updated = servers[index]
updated.info = info
servers[index] = updated
checkServiceStatusChanges(for: server.hostname, newInfo: info)
}
}
} catch {
@@ -219,7 +231,7 @@ struct MainView: View {
for (index, server) in servers.enumerated() {
Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey)
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
await MainActor.run {
servers[index].pingable = pingable
}
@@ -230,6 +242,30 @@ struct MainView: View {
}
}
private func setupTimers() {
setupPingTimer()
setupRefreshTimer()
}
private func setupPingTimer() {
pingTimer?.invalidate()
pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: Double(pingInterval), repeats: true) { _ in
pingAllServers()
}
}
private func setupRefreshTimer() {
refreshSubscription?.cancel()
refreshSubscription = nil
refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
refreshSubscription = refreshTimer?.autoconnect().sink { _ in
for server in servers {
fetchServerInfo(for: server.id)
}
}
}
private static func loadStoredServers() -> [Server] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: storedServersKeyStatic) else {
@@ -256,6 +292,46 @@ struct MainView: View {
return []
}
}
private func requestNotificationPermissions() {
Task {
do {
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
} catch {
print("❌ [MainView] Failed to request notification permissions: \(error)")
}
}
}
private func checkServiceStatusChanges(for hostname: String, newInfo: ServerInfo) {
guard let ports = newInfo.ports else { return }
for port in ports {
let key = "\(hostname)-\(port.id)"
let previousStatus = previousServiceStates[key]
let currentStatus = port.status
previousServiceStates[key] = currentStatus
if let previousStatus, previousStatus != currentStatus {
if currentStatus == "offline" && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "offline")
} else if currentStatus == "online" && previousStatus == "offline" && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "online")
}
}
}
}
private func sendServiceNotification(service: String, hostname: String, status: String) {
let content = UNMutableNotificationContent()
content.title = "\(service) \(status.uppercased())"
content.body = "\(service) on \(hostname) is \(status)"
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
#Preview {

View File

@@ -260,19 +260,18 @@ private struct UpdatesPreferencesView: View {
}
private struct NotificationsPreferencesView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Notifications")
.font(.headline)
.padding(.bottom)
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
Text("Configure notification behavior here.")
.foregroundColor(.secondary)
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Toggle("Status Notifications", isOn: $enableStatusNotifications)
Toggle("Alert Notifications", isOn: $enableAlertNotifications)
Spacer()
}
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}

View File

@@ -20,6 +20,8 @@ struct ServerFormView: View {
@State private var hostname: String
@State private var apiKey: String
@State private var connectionOK: Bool = false
@State private var testingConnection: Bool = false
@State private var connectionError: String = ""
@Environment(\.dismiss) private var dismiss
@@ -55,6 +57,12 @@ struct ServerFormView: View {
SecureField("API Key", text: $apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
if !connectionError.isEmpty {
Text(connectionError)
.foregroundColor(.red)
.font(.caption)
}
HStack {
Button("Cancel") {
dismiss()
@@ -65,6 +73,8 @@ struct ServerFormView: View {
await testConnection()
}
}
.disabled(hostname.isEmpty || apiKey.isEmpty || testingConnection)
Button("Save") {
saveServer()
updateServer()
@@ -102,9 +112,21 @@ struct ServerFormView: View {
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
await MainActor.run {
testingConnection = true
connectionError = ""
}
let reachable = await PingService.ping(hostname: host, apiKey: key)
await MainActor.run {
connectionOK = reachable
testingConnection = false
if reachable {
connectionOK = true
connectionError = ""
} else {
connectionOK = false
connectionError = "Connection failed. Check hostname and API key."
}
}
//
// guard let url = URL(string: "https://\(host)/api/v2/ping") else {

30
Sparkle/appcast.xml vendored
View File

@@ -3,28 +3,28 @@
<channel>
<title>iKeyMon</title>
<item>
<title>26.0.58</title>
<pubDate>Tue, 30 Dec 2025 19:36:28 +0100</pubDate>
<sparkle:version>128</sparkle:version>
<sparkle:shortVersionString>26.0.58</sparkle:shortVersionString>
<title>26.0.70</title>
<pubDate>Sat, 03 Jan 2026 15:49:42 +0100</pubDate>
<sparkle:version>154</sparkle:version>
<sparkle:shortVersionString>26.0.70</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.58/iKeyMon-26.0.58.zip" length="2993175" type="application/octet-stream" sparkle:edSignature="LwsVJJa6ibPNFN04K5g41lZjrGPhBszCIefY34atBdsNqDhW2ZuapTlgxoWWi7j2wt7CMJf/fDBJUEhdI71zDQ=="/>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.70/iKeyMon-26.0.70.zip" length="3007098" type="application/octet-stream" sparkle:edSignature="XZA2xs40EZnexsv/DvzjiH2yiQACqlU+KSDFGqFQTgCTFEPxg6w/qx1cuolgHD3kQJm/svRTNYRR4OVYt9UQBA=="/>
</item>
<item>
<title>26.0.57</title>
<pubDate>Tue, 30 Dec 2025 19:20:10 +0100</pubDate>
<sparkle:version>123</sparkle:version>
<sparkle:shortVersionString>26.0.57</sparkle:shortVersionString>
<title>26.0.69</title>
<pubDate>Sat, 03 Jan 2026 14:00:40 +0100</pubDate>
<sparkle:version>150</sparkle:version>
<sparkle:shortVersionString>26.0.69</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.57/iKeyMon-26.0.57.zip" length="3008341" type="application/octet-stream" sparkle:edSignature="CWtvRnfpBpHHN7X2sK/hq1HWC3NgGe9VGuUfcXOeHkLXpylVoTR+jMRG2em2I6hRsZHQmOq0U9pWs3mlx/e8CQ=="/>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.69/iKeyMon-26.0.69.zip" length="2993457" type="application/octet-stream" sparkle:edSignature="cIqWamcPRsxA7zPaGcUuUOqLYs5KTcoAgXQkhblCF+Wc2tEnGHFVysARtMH68jGq7ObfhDuI3oZJNg857rQ0Dg=="/>
</item>
<item>
<title>26.0.56</title>
<pubDate>Tue, 30 Dec 2025 19:09:34 +0100</pubDate>
<sparkle:version>121</sparkle:version>
<sparkle:shortVersionString>26.0.56</sparkle:shortVersionString>
<title>26.0.68</title>
<pubDate>Sat, 03 Jan 2026 13:52:33 +0100</pubDate>
<sparkle:version>148</sparkle:version>
<sparkle:shortVersionString>26.0.68</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.56/iKeyMon-26.0.56.zip" length="3007443" type="application/octet-stream" sparkle:edSignature="m6cENPrqaTn3Y2pP+9UTkKFgNuJy7Rbs0pMxKSpNhBPEK2jwUm0cNNdIbpXWgD0kw6U2pTBLzYoWg7FFJQnqDA=="/>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.0.68/iKeyMon-26.0.68.zip" length="2993469" type="application/octet-stream" sparkle:edSignature="M5WBkO4BN8RwMJ0ZU3Ku4CyQllnbEzz9X6MYR4IVX5prO9oyMBGoceHA3C97wZA6+++9u7RnRsKrFvei2CsWBQ=="/>
</item>
</channel>
</rss>

View File

@@ -3,8 +3,12 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

View File

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

View File

@@ -159,6 +159,14 @@ if [[ ! -d "$APP_PATH" ]]; then
fi
if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then
echo "🔏 Codesigning Sparkle framework..."
codesign \
--force \
--options runtime \
--timestamp \
--sign "$CODESIGN_IDENTITY" \
"$APP_PATH/Contents/Frameworks/Sparkle.framework"
echo "🔏 Codesigning app with identity: $CODESIGN_IDENTITY"
codesign \
--deep \

View File

@@ -1,3 +1,3 @@
{
"marketing_version": "26.0.58"
"marketing_version": "26.0.70"
}