diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
new file mode 100644
index 0000000..344c9a3
--- /dev/null
+++ b/.github/workflows/auto-release.yml
@@ -0,0 +1,81 @@
+name: Auto Release
+
+on:
+ push:
+ branches: [ main ]
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Select Xcode version
+ run: sudo xcode-select -s /Applications/Xcode_16.0.app
+
+ - name: Determine version
+ id: version
+ run: |
+ if [[ $GITHUB_REF == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/}
+ else
+ # Auto-versioning: vYYYY.MM.DD.RUN_NUMBER
+ VERSION="v$(date +'%Y.%m.%d').${{ github.run_number }}"
+ fi
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "Build version: $VERSION"
+
+ - name: Build application
+ run: |
+ # Build without signing for CI (unless certificates are provided)
+ xcodebuild build \
+ -project Pulse.xcodeproj \
+ -scheme Pulse \
+ -configuration Release \
+ -derivedDataPath Build \
+ CODE_SIGN_IDENTITY="" \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGNING_ALLOWED=NO \
+ MARKETING_VERSION="${{ steps.version.outputs.version }}" \
+ CURRENT_PROJECT_VERSION="${{ github.run_number }}"
+
+ - name: Create DMG
+ run: |
+ APP_NAME="Pulse"
+ VERSION="${{ steps.version.outputs.version }}"
+ DMG_NAME="${APP_NAME}-${VERSION}.dmg"
+
+ # Prepare folder for DMG
+ mkdir -p dist_folder
+ cp -R Build/Build/Products/Release/${APP_NAME}.app dist_folder/
+ ln -s /Applications dist_folder/Applications
+
+ # Create DMG
+ hdiutil create -volname "${APP_NAME}" \
+ -srcfolder dist_folder \
+ -ov -format UDZO \
+ "${DMG_NAME}"
+
+ echo "dmg_name=${DMG_NAME}" >> $GITHUB_OUTPUT
+ id: package
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.version.outputs.version }}
+ name: Pulse ${{ steps.version.outputs.version }}
+ files: ${{ steps.package.outputs.dmg_name }}
+ generate_release_notes: true
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Pulse/Assets.xcassets/AppIcon.appiconset/Contents.json b/Pulse/Assets.xcassets/Pulse.appiconset/Contents.json
similarity index 53%
rename from Pulse/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to Pulse/Assets.xcassets/Pulse.appiconset/Contents.json
index 3f00db4..3b49269 100644
--- a/Pulse/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Pulse/Assets.xcassets/Pulse.appiconset/Contents.json
@@ -3,52 +3,62 @@
{
"idiom" : "mac",
"scale" : "1x",
- "size" : "16x16"
+ "size" : "16x16",
+ "filename": "icon_16.png"
},
{
"idiom" : "mac",
"scale" : "2x",
- "size" : "16x16"
+ "size" : "16x16",
+ "filename": "icon_32.png"
},
{
"idiom" : "mac",
"scale" : "1x",
- "size" : "32x32"
+ "size" : "32x32",
+ "filename": "icon_32.png"
},
{
"idiom" : "mac",
"scale" : "2x",
- "size" : "32x32"
+ "size" : "32x32",
+ "filename": "icon_128.png"
},
{
"idiom" : "mac",
"scale" : "1x",
- "size" : "128x128"
+ "size" : "128x128",
+ "filename": "icon_128.png"
},
{
"idiom" : "mac",
"scale" : "2x",
- "size" : "128x128"
+ "size" : "128x128",
+ "filename": "icon_256.png"
},
{
"idiom" : "mac",
"scale" : "1x",
- "size" : "256x256"
+ "size" : "256x256",
+ "filename": "icon_256.png"
},
{
"idiom" : "mac",
"scale" : "2x",
- "size" : "256x256"
+ "size" : "256x256",
+ "filename": "icon_512.png"
},
{
"idiom" : "mac",
"scale" : "1x",
- "size" : "512x512"
+ "size" : "512x512",
+ "filename": "icon_512.png"
},
{
"idiom" : "mac",
"scale" : "2x",
- "size" : "512x512"
+ "size" : "512x512",
+ "filename": "icon_1024.png"
}
],
"info" : {
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_1024.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_1024.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_1024.png differ
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_128.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_128.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_128.png differ
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_16.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_16.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_16.png differ
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_256.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_256.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_256.png differ
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_32.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_32.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_32.png differ
diff --git a/Pulse/Assets.xcassets/Pulse.appiconset/icon_512.png b/Pulse/Assets.xcassets/Pulse.appiconset/icon_512.png
new file mode 100644
index 0000000..7823b64
Binary files /dev/null and b/Pulse/Assets.xcassets/Pulse.appiconset/icon_512.png differ
diff --git a/Pulse/Core/Configuration/MonitorConfiguration.swift b/Pulse/Core/Configuration/MonitorConfiguration.swift
index 9f1dfd1..bd737f7 100644
--- a/Pulse/Core/Configuration/MonitorConfiguration.swift
+++ b/Pulse/Core/Configuration/MonitorConfiguration.swift
@@ -97,7 +97,7 @@ enum MonitorType: String, Codable, Sendable, CaseIterable, Identifiable {
}
/// Monitor types that have a fully implemented provider.
- static let implemented: [MonitorType] = [.http, .betterstack, .atlassian]
+ static let implemented: [MonitorType] = [.http, .tcp, .betterstack, .atlassian]
/// Whether this type monitors a status page rather than probing directly.
var isStatusPage: Bool {
@@ -155,6 +155,9 @@ struct TCPMonitorConfig: Codable, Sendable, Hashable {
/// Consecutive failures before the monitor is considered down.
var failureThreshold: Int?
+
+ /// If true, the check will wait for a response from the server (e.g. for SSH banners).
+ var expectResponse: Bool?
}
// MARK: - Status Page Monitor
diff --git a/Pulse/Core/Monitoring/MonitorEngine.swift b/Pulse/Core/Monitoring/MonitorEngine.swift
index c7fb9d6..9feef70 100644
--- a/Pulse/Core/Monitoring/MonitorEngine.swift
+++ b/Pulse/Core/Monitoring/MonitorEngine.swift
@@ -157,10 +157,16 @@ final class MonitorEngine {
await self?.pollAggregated(provider: atlassianProvider, key: key, monitorType: monitorType, frequency: frequency)
}
- case .tcp, .statusio, .incidentio:
- // Not yet implemented — show unknown state.
+ case .tcp:
+ guard let config = monitor.tcp else { return }
+ let tcpProvider = TCPMonitorProvider(config: config)
initializeSingleState(key: key, monitorType: monitorType)
- logger.info("Monitor type '\(monitorType.rawValue)' not yet implemented for '\(monitor.name)'.")
+ pollTasks[key] = Task { [weak self] in
+ await self?.pollSingle(provider: tcpProvider, key: key, frequency: frequency)
+ }
+
+ case .statusio, .incidentio:
+ break
}
}
diff --git a/Pulse/Core/Monitoring/Providers/TCPMonitorProvider.swift b/Pulse/Core/Monitoring/Providers/TCPMonitorProvider.swift
new file mode 100644
index 0000000..4624079
--- /dev/null
+++ b/Pulse/Core/Monitoring/Providers/TCPMonitorProvider.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Network
+import os
+
+/// Performs TCP connectivity checks against a configured host and port.
+struct TCPMonitorProvider: MonitorProvider {
+ private let config: TCPMonitorConfig
+ private let logger = Logger(subsystem: "com.sattlerjoshua.Pulse", category: "TCPMonitor")
+ private let queue = DispatchQueue(label: "com.sattlerjoshua.Pulse.tcp-check")
+
+ init(config: TCPMonitorConfig) {
+ self.config = config
+ }
+
+ func check() async throws -> CheckResult {
+ let endpoint = NWEndpoint.hostPort(
+ host: NWEndpoint.Host(config.host),
+ port: NWEndpoint.Port(integerLiteral: UInt16(config.port))
+ )
+
+ let parameters = NWParameters.tcp
+ let connection = NWConnection(to: endpoint, using: parameters)
+ let lock = OSAllocatedUnfairLock(initialState: false)
+ let start = ContinuousClock.now
+
+ return await withCheckedContinuation { continuation in
+ connection.stateUpdateHandler = { state in
+ self.logger.debug("TCP state update for \(self.config.host):\(self.config.port): \(String(describing: state))")
+
+ switch state {
+ case .ready:
+ if config.expectResponse == true {
+ // Start receiving data to verify the handshake
+ self.logger.debug("TCP [\(self.config.host)] ready, waiting for data...")
+ receiveBanner(connection: connection)
+ } else {
+ complete(with: .operational, message: nil)
+ }
+ case .failed(let error):
+ complete(with: .downtime, message: error.localizedDescription)
+ case .waiting(let error):
+ complete(with: .downtime, message: "Waiting/Refused: \(error.localizedDescription)")
+ default:
+ break
+ }
+ }
+
+ func receiveBanner(connection: NWConnection) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { content, _, isComplete, error in
+ if let error = error {
+ complete(with: .downtime, message: "Read failed: \(error.localizedDescription)")
+ } else if content != nil {
+ complete(with: .operational, message: nil)
+ } else if isComplete {
+ complete(with: .downtime, message: "Connection closed by server without data")
+ }
+ }
+ }
+
+ func complete(with status: MonitorStatus, message: String?) {
+ let alreadyResponded = lock.withLock { isResponded in
+ let original = isResponded
+ isResponded = true
+ return original
+ }
+
+ if !alreadyResponded {
+ connection.stateUpdateHandler = nil
+ connection.cancel()
+
+ let elapsed = ContinuousClock.now - start
+ self.logger.info("TCP check for \(self.config.host):\(self.config.port) completed: \(status.rawValue) in \(elapsed)")
+
+ continuation.resume(returning: CheckResult(
+ status: status,
+ responseTime: elapsed,
+ timestamp: .now,
+ message: message
+ ))
+ }
+ }
+
+ // Hard timeout at 5 seconds.
+ Task {
+ try? await Task.sleep(for: .seconds(5))
+ complete(with: .downtime, message: "Timeout")
+ }
+
+ connection.start(queue: queue)
+ }
+ }
+}
diff --git a/Pulse/Features/Settings/MonitorDetailView.swift b/Pulse/Features/Settings/MonitorDetailView.swift
index 481271f..791b3c2 100644
--- a/Pulse/Features/Settings/MonitorDetailView.swift
+++ b/Pulse/Features/Settings/MonitorDetailView.swift
@@ -24,6 +24,11 @@ struct MonitorDetailView: View {
@State private var checkFrequency: String = "60"
@State private var failureThreshold: String = "1"
+ // TCP fields
+ @State private var tcpHost: String = ""
+ @State private var tcpPort: String = "22"
+ @State private var tcpExpectResponse: Bool = false
+
// Status page fields
@State private var statusPageURL: String = ""
@@ -49,6 +54,8 @@ struct MonitorDetailView: View {
switch monitorType {
case .http:
return !httpURL.trimmingCharacters(in: .whitespaces).isEmpty
+ case .tcp:
+ return !tcpHost.trimmingCharacters(in: .whitespaces).isEmpty && Int(tcpPort) != nil
case .betterstack, .atlassian:
return !statusPageURL.trimmingCharacters(in: .whitespaces).isEmpty
default:
@@ -80,6 +87,14 @@ struct MonitorDetailView: View {
checkFrequency: $checkFrequency,
failureThreshold: $failureThreshold
)
+ case .tcp:
+ TCPMonitorSection(
+ host: $tcpHost,
+ port: $tcpPort,
+ checkFrequency: $checkFrequency,
+ failureThreshold: $failureThreshold,
+ expectResponse: $tcpExpectResponse
+ )
case .betterstack, .atlassian:
StatusPageMonitorSection(url: $statusPageURL, monitorType: monitorType)
default:
@@ -132,6 +147,14 @@ struct MonitorDetailView: View {
failureThreshold = http.failureThreshold.map(String.init) ?? "1"
}
+ if let tcp = monitor.tcp {
+ tcpHost = tcp.host
+ tcpPort = String(tcp.port)
+ tcpExpectResponse = tcp.expectResponse ?? false
+ checkFrequency = tcp.checkFrequency.map(String.init) ?? "60"
+ failureThreshold = tcp.failureThreshold.map(String.init) ?? "1"
+ }
+
if let config = monitor.betterstack ?? monitor.atlassian {
statusPageURL = config.url
}
@@ -158,6 +181,15 @@ struct MonitorDetailView: View {
failureThreshold: Int(failureThreshold)
)
+ case .tcp:
+ monitor.tcp = TCPMonitorConfig(
+ host: tcpHost.trimmingCharacters(in: .whitespaces),
+ port: Int(tcpPort) ?? 0,
+ checkFrequency: Int(checkFrequency),
+ failureThreshold: Int(failureThreshold),
+ expectResponse: tcpExpectResponse
+ )
+
case .betterstack:
monitor.betterstack = StatusPageMonitorConfig(
url: statusPageURL.trimmingCharacters(in: .whitespaces),
@@ -212,6 +244,28 @@ struct HTTPMonitorSection: View {
}
}
+// MARK: - TCP Monitor Section
+
+/// Form section with fields specific to TCP monitors.
+struct TCPMonitorSection: View {
+ @Binding var host: String
+ @Binding var port: String
+ @Binding var checkFrequency: String
+ @Binding var failureThreshold: String
+ @Binding var expectResponse: Bool
+
+ var body: some View {
+ Section("TCP / SSH") {
+ TextField("Host", text: $host, prompt: Text("example.com or 1.2.3.4"))
+ TextField("Port", text: $port, prompt: Text("22"))
+ Toggle("Verify Service Handshake", isOn: $expectResponse)
+ .help("If enabled, Pulse waits for the server to send data (e.g. an SSH banner) after connecting.")
+ TextField("Check Frequency (seconds)", text: $checkFrequency, prompt: Text("60"))
+ TextField("Failure Threshold", text: $failureThreshold, prompt: Text("1"))
+ }
+ }
+}
+
// MARK: - Status Page Monitor Section
/// Form section with fields specific to status page monitors.
diff --git a/Pulse/Info.plist b/Pulse/Info.plist
index c1cfff2..0266788 100644
--- a/Pulse/Info.plist
+++ b/Pulse/Info.plist
@@ -6,6 +6,11 @@
Pulse needs local network access to monitor the health of services on your network.
NSUserNotificationUsageDescription
Pulse sends notifications when a monitored service changes status.
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
SUFeedURL
https://github.com/jsattler/Pulse/releases/latest/download/appcast.xml
SUPublicEDKey
diff --git a/Pulse/Pulse.entitlements b/Pulse/Pulse.entitlements
index 0c67376..38da9a9 100644
--- a/Pulse/Pulse.entitlements
+++ b/Pulse/Pulse.entitlements
@@ -1,5 +1,12 @@
-
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.files.user-selected.read-write
+
+