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 + +