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
81 changes: 81 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion Pulse/Core/Configuration/MonitorConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions Pulse/Core/Monitoring/MonitorEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
92 changes: 92 additions & 0 deletions Pulse/Core/Monitoring/Providers/TCPMonitorProvider.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
54 changes: 54 additions & 0 deletions Pulse/Features/Settings/MonitorDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand All @@ -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),
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Pulse/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
<string>Pulse needs local network access to monitor the health of services on your network.</string>
<key>NSUserNotificationUsageDescription</key>
<string>Pulse sends notifications when a monitored service changes status.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>SUFeedURL</key>
<string>https://github.com/jsattler/Pulse/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
Expand Down
Loading