StikDebug/StikJIT/Utilities/SystemLogStream.swift
Stephen f3d2dec2e5
Semi-Rewrite – Major Refactor & Codebase Cleanup (#328)
* Change default UPLOAD_IPA value to 'false'

* Part 1 of the great debloat

* Remove Mini Tools, adopt native alerts in StikJITApp, and update HomeView UI

* Refactor views to use native SwiftUI alerts instead of CustomErrorView

* Update ProfileView to use minimal List UI style with native alerts

* Fix up homeview

* Squash: Redesign HomeView, remove Feature Flags, enable all tabs, refactor alerts, and updates

- Redesign HomeView to a simpler style with 'Enable JIT' action.
- Remove 'Help & Info' and 'Quick Launch' from Home.
- Fix DDI false positive by starting heartbeat on HomeView appear.
- Restore robust DDI checks and update app name to 'StikDebug'.
- Remove FeatureFlags and Beta tags; enable 'profiles', 'processes', 'location' tabs by default.
- Remove 'Devices' tab; add 'Target Device IP' field in Settings.
- Hardcode 'Settings' tab to 4th position.
- Refactor 'SettingsView' tab customization to be reorder-only.
- Remove 'Appearance' theme section from Settings.
- Refactor custom error cards to use native SwiftUI/UIKit alerts across the app (HomeView, ConsoleLogsView, ProfileView, etc).
- Restore JS callback support and script execution logic in HomeView.
- Add necessary Color extensions.
- Update project version to 3.0.0.

* Clean up: Remove In-App Purchases, update checker, and unused IPA installer code

* delete more bloat

* fix build issue

* Remove widget to save on App IDs

* Attempt heartbeat restart when Enable JIT button is pressed

* Get rid of PIP

* Update Tab Naming

* remove more bloat

* final changes for today

* Remove unused packages

* UX Improvements

* revert bundle id

* Update README.md

* remove old idevice

* add updated idevice

* Fix ddi false positive errors and add templates for GitHub stuff

* Remove more unused stuff.

* Fix links and update README content

* Update compatibility table in README.md

* Improve performance of console logs

* Remove old code

* Change default UPLOAD_IPA value to 'true'

* remove the welcome sheet

* import support for .mobiledevicepair

* Add back LC JIT stuff

* Fix heartbeat issues with LC JIT

* update the build yml for nightly stuff

* Update README.md

* Update README.md

* Update GitHub Actions workflow for IPA build

* Update GitHub Actions workflow for nightly builds

* Add URL scheme to kill tasks stikjit://kill-process?pid=1234

* fix build issues

* Added App Intents

* Unify the error handling style across the app
2026-02-25 13:44:00 -05:00

216 lines
6.3 KiB
Swift

//
// SystemLogStream.swift
// StikJIT
//
// Created by Stephen on 09/21/2025.
//
import Foundation
@MainActor
final class SystemLogStream: ObservableObject {
struct Entry: Identifiable {
let id = UUID()
let timestamp: Date
let message: String
let raw: String
}
@Published private(set) var entries: [Entry] = []
@Published var lastError: String? = nil
@Published private(set) var isStreaming: Bool = false
@Published private(set) var isPaused: Bool = false
@Published var updateInterval: TimeInterval = 0 {
didSet {
if updateInterval < 0 { updateInterval = 0 }
flushTimer?.invalidate()
flushTimer = nil
if !isPaused && !pendingEntries.isEmpty {
if updateInterval == 0 {
flushAllPending()
} else {
scheduleFlushIfNeeded()
}
}
}
}
private let maxEntries = 1500
private var pendingEntries: [Entry] = []
private var flushTimer: Timer?
private var retryTimer: Timer?
private var batchTimer: Timer?
private static let batchInterval: TimeInterval = 0.1
func start() {
retryTimer?.invalidate()
retryTimer = nil
guard !isStreaming else {
if isPaused { resume() }
return
}
isStreaming = true
isPaused = false
lastError = nil
startBatchTimer()
JITEnableContext.shared.startSyslogRelay(handler: { [weak self] line in
guard let line else { return }
self?.handleLine(line)
}, onError: { [weak self] error in
self?.handleError(error as NSError?)
})
}
func stop() {
guard isStreaming else { return }
isStreaming = false
isPaused = false
JITEnableContext.shared.stopSyslogRelay()
flushTimer?.invalidate()
flushTimer = nil
batchTimer?.invalidate()
batchTimer = nil
pendingEntries.removeAll()
retryTimer?.invalidate()
retryTimer = nil
}
func clear() {
entries.removeAll()
pendingEntries.removeAll()
flushTimer?.invalidate()
flushTimer = nil
retryTimer?.invalidate()
retryTimer = nil
}
func togglePause() {
isPaused ? resume() : pause()
}
func pause() {
guard !isPaused else { return }
isPaused = true
flushTimer?.invalidate()
flushTimer = nil
}
func resume() {
guard isPaused else { return }
isPaused = false
if updateInterval == 0 {
flushAllPending()
} else {
scheduleFlushIfNeeded()
}
}
private func handleLine(_ line: String) {
guard isStreaming else { return }
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let entry = Entry(timestamp: Date(), message: prettify(line: trimmed), raw: trimmed)
pendingEntries.append(entry)
if pendingEntries.count > maxEntries {
pendingEntries.removeFirst(pendingEntries.count - maxEntries)
}
// Batching is handled by batchTimer / scheduleFlushIfNeeded
if updateInterval > 0 {
scheduleFlushIfNeeded()
}
}
private func handleError(_ error: NSError?) {
isStreaming = false
isPaused = false
flushTimer?.invalidate()
flushTimer = nil
batchTimer?.invalidate()
batchTimer = nil
lastError = error?.localizedDescription ?? "System log stream stopped"
scheduleAutoRetry()
}
private func startBatchTimer() {
batchTimer?.invalidate()
batchTimer = Timer.scheduledTimer(withTimeInterval: Self.batchInterval, repeats: true) { [weak self] _ in
guard let self else { return }
guard !self.isPaused, self.updateInterval == 0, !self.pendingEntries.isEmpty else { return }
self.flushAllPending()
}
if let batchTimer {
RunLoop.main.add(batchTimer, forMode: .common)
}
}
private func flushAllPending() {
guard !pendingEntries.isEmpty else { return }
entries.append(contentsOf: pendingEntries)
pendingEntries.removeAll()
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
private func prettify(line: String) -> String {
if let range = line.range(of: ": ") {
let messagePart = line[range.upperBound...]
return String(messagePart)
}
return line
}
func concatenatedLog(limit: Int = 500) -> String {
let slice = entries.suffix(limit)
return slice.map { "[\(DateFormatter.consoleFormatter.string(from: $0.timestamp))] \($0.raw)" }
.joined(separator: "\n")
}
private func scheduleFlushIfNeeded() {
guard !isPaused else { return }
guard !pendingEntries.isEmpty else { return }
guard flushTimer == nil else { return }
guard updateInterval > 0 else {
// Live mode uses batchTimer instead
return
}
flushTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: false) { [weak self] _ in
guard let self else { return }
self.flushTimer = nil
guard !self.isPaused else { return }
self.flushAllPending()
if !self.pendingEntries.isEmpty {
self.scheduleFlushIfNeeded()
}
}
if let flushTimer {
RunLoop.main.add(flushTimer, forMode: .common)
}
}
private func scheduleAutoRetry() {
guard !isStreaming else { return }
guard retryTimer == nil else { return }
let timer = Timer(timeInterval: 2.0, repeats: false) { [weak self] _ in
guard let self else { return }
self.retryTimer = nil
if !self.isStreaming && !self.isPaused {
self.start()
}
}
retryTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
}
private extension DateFormatter {
static let consoleFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}