diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 694a4a4..2287c05 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -725,7 +725,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.7; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -778,7 +778,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.7; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/StikJIT/Utilities/SystemLogStream.swift b/StikJIT/Utilities/SystemLogStream.swift new file mode 100644 index 0000000..5e565fc --- /dev/null +++ b/StikJIT/Utilities/SystemLogStream.swift @@ -0,0 +1,202 @@ +// +// 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 + flushImmediatelyIfNeeded() + } + } + + private let maxEntries = 1500 + private var pendingEntries: [Entry] = [] + private var flushTimer: Timer? + private var retryTimer: Timer? + + func start() { + retryTimer?.invalidate() + retryTimer = nil + guard !isStreaming else { + if isPaused { resume() } + return + } + isStreaming = true + isPaused = false + lastError = nil + + 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 + 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 + flushImmediatelyIfNeeded() + } + + 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) + } + if updateInterval == 0 && !isPaused { + flushImmediatelyIfNeeded() + } else { + scheduleFlushIfNeeded() + } + } + + private func handleError(_ error: NSError?) { + isStreaming = false + isPaused = false + flushTimer?.invalidate() + flushTimer = nil + lastError = error?.localizedDescription ?? "System log stream stopped" + scheduleAutoRetry() + } + + 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 appendEntry(_ entry: Entry) { + entries.append(entry) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + private func flushImmediatelyIfNeeded() { + guard !isPaused else { return } + if updateInterval == 0 { + while !pendingEntries.isEmpty { + let next = pendingEntries.removeFirst() + appendEntry(next) + } + } else { + scheduleFlushIfNeeded() + } + } + + private func scheduleFlushIfNeeded() { + guard !isPaused else { return } + guard !pendingEntries.isEmpty else { return } + guard flushTimer == nil else { return } + + if updateInterval <= 0 { + flushImmediatelyIfNeeded() + return + } + + flushTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: false) { [weak self] _ in + guard let self else { return } + self.flushTimer = nil + guard !self.isPaused else { return } + if let next = self.pendingEntries.first { + self.pendingEntries.removeFirst() + self.appendEntry(next) + } + 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 + }() +} diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index e9c5388..053d6cf 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -13,8 +13,10 @@ struct ConsoleLogsView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.accentColor) private var environmentAccentColor @StateObject private var logManager = LogManager.shared - @State private var autoScroll = true - @State private var scrollView: ScrollViewProxy? = nil + @StateObject private var systemLogStream = SystemLogStream() + @State private var selectedTab: ConsoleLogsTab = .jit + @State private var jitScrollView: ScrollViewProxy? = nil + @State private var systemScrollView: ScrollViewProxy? = nil @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @State private var showingCustomAlert = false @@ -27,7 +29,10 @@ struct ConsoleLogsView: View { @State private var isViewActive = false @State private var lastProcessedLineCount = 0 @State private var isLoadingLogs = false - @State private var isAtBottom = true + @State private var jitIsAtBottom = true + @State private var systemIsAtBottom = true + @State private var systemFilterText: String = "" + @State private var systemFilterLevel: SystemLogFilterLevel = .all private var accentColor: Color { if customAccentColorHex.isEmpty { @@ -36,6 +41,56 @@ struct ConsoleLogsView: View { return Color(hex: customAccentColorHex) ?? .blue } } + + private let systemSpeedOptions: [(title: String, interval: TimeInterval)] = [ + ("Real-time", 0), + ("0.25s", 0.25), + ("0.5s", 0.5), + ("1s", 1), + ("2s", 2) + ] + + private enum ConsoleLogsTab: Int, CaseIterable, Identifiable { + case jit + case system + + var id: Int { rawValue } + + var title: String { + switch self { + case .jit: return "App Logs" + case .system: return "System Logs" + } + } + } + + private enum SystemLogFilterLevel: String, CaseIterable, Identifiable { + case all + case warnings + case errors + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .warnings: return "Warnings" + case .errors: return "Errors" + } + } + + func matches(_ line: String) -> Bool { + let lower = line.lowercased() + switch self { + case .all: + return true + case .warnings: + return lower.contains("warn") || lower.contains("warning") + case .errors: + return lower.contains("err") || lower.contains("fail") || lower.contains("fault") || lower.contains("critical") + } + } + } var body: some View { NavigationView { @@ -44,127 +99,34 @@ struct ConsoleLogsView: View { .edgesIgnoringSafeArea(.all) VStack(spacing: 0) { - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 4) { - Text("=== DEVICE INFORMATION ===") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .padding(.vertical, 4) - - Text("iOS Version: \(UIDevice.current.systemVersion)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("Device: \(UIDevice.current.name)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("Model: \(UIDevice.current.model)") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - - Text("=== LOG ENTRIES ===") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .padding(.vertical, 4) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - - ForEach(logManager.logs) { logEntry in - Text(AttributedString(createLogAttributedString(logEntry))) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 1) - .padding(.horizontal, 4) - .id(logEntry.id) - } - } - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("scroll")).minY - ) - } - ) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - isAtBottom = offset > -20 - } - .onChange(of: logManager.logs.count) { _ in - if isAtBottom { - withAnimation { - if let lastLog = logManager.logs.last { - proxy.scrollTo(lastLog.id, anchor: .bottom) - } - } - } - } - .onAppear { - scrollView = proxy - isViewActive = true - Task { - await loadIdeviceLogsAsync() - } - startLogCheckTimer() - } - .onDisappear { - isViewActive = false - stopLogCheckTimer() - } + tabPicker + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + Divider() + .background(Color.white.opacity(colorScheme == .dark ? 0.05 : 0.15)) + + if selectedTab == .jit { + jitLogsPane + } else { + systemLogsPane } - + Spacer(minLength: 0) - - HStack(spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .imageScale(.small) - .foregroundColor(.red) - Text("\(logManager.errorCount) Errors") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer(minLength: 8) - - Button { - var logsContent = "=== DEVICE INFORMATION ===\n" - logsContent += "Version: \(UIDevice.current.systemVersion)\n" - logsContent += "Name: \(UIDevice.current.name)\n" - logsContent += "Model: \(UIDevice.current.model)\n" - logsContent += "StikJIT Version: App Version: 1.0\n\n" - logsContent += "=== LOG ENTRIES ===\n" - - logsContent += logManager.logs.map { - "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" - }.joined(separator: "\n") - - UIPasteboard.general.string = logsContent - - alertTitle = "Logs Copied" - alertMessage = "Logs have been copied to clipboard." - isError = false - showingCustomAlert = true - } label: { - HStack(spacing: 6) { - Image(systemName: "doc.on.doc") - Text("Copy") - } - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - - exportControl + + if selectedTab == .jit { + jitFooter + } else { + systemFooter + } + } + .onChange(of: selectedTab) { tab in + if tab == .system { + systemLogStream.start() + } else { + systemLogStream.stop() } - .padding(.horizontal, 16) - .padding(.vertical, 10) } .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -223,9 +185,345 @@ struct ConsoleLogsView: View { } } ) + .onDisappear { + systemLogStream.stop() + } + .onAppear { + if selectedTab == .system { + systemLogStream.start() + } + } } } + private var tabPicker: some View { + Picker("Log Source".localized, selection: $selectedTab) { + ForEach(ConsoleLogsTab.allCases) { tab in + Text(tab.title.localized).tag(tab) + } + } + .pickerStyle(.segmented) + } + + private var jitLogsPane: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("=== DEVICE INFORMATION ===") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.vertical, 4) + + Text("iOS Version: \(UIDevice.current.systemVersion)") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + + Text("Device: \(UIDevice.current.name)") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + + Text("Model: \(UIDevice.current.model)") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + + Text("=== LOG ENTRIES ===") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.vertical, 4) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + + ForEach(logManager.logs) { logEntry in + Text(AttributedString(createLogAttributedString(logEntry))) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 1) + .padding(.horizontal, 4) + .id(logEntry.id) + } + } + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("jitScroll")).minY + ) + } + ) + } + .coordinateSpace(name: "jitScroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + jitIsAtBottom = offset > -20 + } + .onChange(of: logManager.logs.count) { _ in + guard jitIsAtBottom, let lastLog = logManager.logs.last else { return } + withAnimation { + proxy.scrollTo(lastLog.id, anchor: .bottom) + } + } + .onAppear { + jitScrollView = proxy + isViewActive = true + Task { await loadIdeviceLogsAsync() } + startLogCheckTimer() + } + .onDisappear { + isViewActive = false + stopLogCheckTimer() + } + } + .onDisappear { + systemLogStream.stop() + } + } + + private var systemLogsPane: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + systemFilterControls + + if let error = systemLogStream.lastError { + Text(error) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.red) + .padding(.vertical, 4) + .padding(.horizontal, 4) + } + + ForEach(filteredSystemLogs) { entry in + Text(AttributedString(systemLogAttributedString(entry))) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 1) + .padding(.horizontal, 4) + .id(entry.id) + } + } + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("systemScroll")).minY + ) + } + ) + } + .coordinateSpace(name: "systemScroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + systemIsAtBottom = offset > -20 + } + .onChange(of: systemLogStream.entries.count) { _ in + guard systemIsAtBottom, let last = systemLogStream.entries.last else { return } + withAnimation { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + .onAppear { + systemScrollView = proxy + systemLogStream.start() + } + .onDisappear { + systemScrollView = nil + } + } + } + + private var filteredSystemLogs: [SystemLogStream.Entry] { + var entries = systemLogStream.entries + let trimmed = systemFilterText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + let query = trimmed.lowercased() + entries = entries.filter { entry in + entry.raw.lowercased().contains(query) + } + } + + if systemFilterLevel != .all { + entries = entries.filter { systemFilterLevel.matches($0.raw) } + } + + return entries + } + + private var systemSpeedSymbol: String { + switch systemLogStream.updateInterval { + case 0: + return "hare.fill" + case ..<0.5: + return "tortoise.fill" + case ..<1.5: + return "tortoise" + default: + return "tortoise" + } + } + + private var systemFilterControls: some View { + VStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease.circle") + .foregroundStyle(.secondary) + + TextField("Filter".localized, text: $systemFilterText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(.system(size: 12, design: .monospaced)) + + if !systemFilterText.isEmpty { + Button { + systemFilterText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear filter".localized) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + + Picker("Severity".localized, selection: $systemFilterLevel) { + ForEach(SystemLogFilterLevel.allCases) { level in + Text(level.title.localized).tag(level) + } + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 4) + .padding(.bottom, 6) + } + + private var jitFooter: some View { + HStack(spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .imageScale(.small) + .foregroundColor(.red) + Text("\(logManager.errorCount) Errors") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer(minLength: 8) + + Button { + var logsContent = "=== DEVICE INFORMATION ===\n" + logsContent += "Version: \(UIDevice.current.systemVersion)\n" + logsContent += "Name: \(UIDevice.current.name)\n" + logsContent += "Model: \(UIDevice.current.model)\n" + logsContent += "StikJIT Version: App Version: 1.0\n\n" + logsContent += "=== LOG ENTRIES ===\n" + + logsContent += logManager.logs.map { + "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" + }.joined(separator: "\n") + + UIPasteboard.general.string = logsContent + + alertTitle = "Logs Copied" + alertMessage = "Logs have been copied to clipboard." + isError = false + showingCustomAlert = true + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Copy app logs") + + exportControl + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var systemFooter: some View { + HStack(spacing: 12) { + if let error = systemLogStream.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.subheadline) + .foregroundColor(.red) + .lineLimit(2) + } else { + let status = systemStatusDisplay() + Label(status.text, systemImage: status.icon) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer(minLength: 8) + + Button { + UIPasteboard.general.string = systemLogStream.concatenatedLog() + alertTitle = "System Logs Copied" + alertMessage = "Recent system logs copied to clipboard.".localized + isError = false + showingCustomAlert = true + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Copy system logs") + + Button { + systemLogStream.clear() + } label: { + Image(systemName: "trash") + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Clear system logs") + + Button { + systemLogStream.togglePause() + } label: { + Image(systemName: systemLogStream.isPaused ? "play.fill" : "pause.fill") + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel(systemLogStream.isPaused ? "Resume stream" : "Pause stream") + + Menu { + ForEach(systemSpeedOptions, id: \.title) { option in + Button(option.title.localized) { + systemLogStream.updateInterval = option.interval + } + } + } label: { + Image(systemName: systemSpeedSymbol) + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Adjust speed") + + if !systemLogStream.isStreaming { + Button { + systemLogStream.start() + } label: { + Image(systemName: "arrow.clockwise") + .foregroundColor(accentColor) + } + .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Restart stream") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + @ViewBuilder private var exportControl: some View { let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") @@ -234,13 +532,11 @@ struct ConsoleLogsView: View { item: logURL, preview: SharePreview("idevice_log.txt", image: Image(systemName: "doc.text")) ) { - HStack(spacing: 6) { - Image(systemName: "square.and.arrow.up") - Text("Export") - } - .foregroundColor(accentColor) + Image(systemName: "square.and.arrow.up") + .foregroundColor(accentColor) } .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Export app logs") } else { Button { alertTitle = "Export Failed" @@ -248,13 +544,11 @@ struct ConsoleLogsView: View { isError = true showingCustomAlert = true } label: { - HStack(spacing: 6) { - Image(systemName: "square.and.arrow.up") - Text("Export") - } - .foregroundColor(accentColor) + Image(systemName: "square.and.arrow.up") + .foregroundColor(accentColor) } .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) + .accessibilityLabel("Export app logs") } } @@ -286,11 +580,28 @@ struct ConsoleLogsView: View { return fullString } - - private func timeString() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter.string(from: Date()) + + private func systemLogAttributedString(_ entry: SystemLogStream.Entry) -> NSAttributedString { + let fullString = NSMutableAttributedString() + + let timestamp = "[\(formatTime(date: entry.timestamp))]" + let tsAttr = NSAttributedString(string: timestamp, + attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray]) + fullString.append(tsAttr) + fullString.append(NSAttributedString(string: " ")) + + let type = systemLogType(for: entry.raw) + let typeString = "[\(type.rawValue)]" + let typeAttr = NSAttributedString(string: typeString, + attributes: [.foregroundColor: UIColor(colorForLogType(type))]) + fullString.append(typeAttr) + fullString.append(NSAttributedString(string: " ")) + + let messageAttr = NSAttributedString(string: entry.message, + attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black]) + fullString.append(messageAttr) + + return fullString } private func formatTime(date: Date) -> String { @@ -311,6 +622,33 @@ struct ConsoleLogsView: View { return .orange } } + + private func systemLogType(for raw: String) -> LogManager.LogEntry.LogType { + let lower = raw.lowercased() + if lower.contains("error") || lower.contains("failed") || lower.contains("fatal") || lower.contains("fault") { + return .error + } + if lower.contains("warn") { + return .warning + } + if lower.contains("debug") || lower.contains("trace") { + return .debug + } + return .info + } + + private func systemStatusDisplay() -> (text: String, icon: String) { + if !systemLogStream.isStreaming { + return ("Stopped".localized, "pause.circle") + } + if systemLogStream.isPaused { + return ("Paused".localized, "pause.circle") + } + if systemLogStream.updateInterval > 0 { + return ("Throttled".localized, systemSpeedSymbol) + } + return ("Streaming".localized, "dot.radiowaves.left.and.right") + } private func loadIdeviceLogsAsync() async { guard !isLoadingLogs else { return } @@ -360,6 +698,10 @@ struct ConsoleLogsView: View { logManager.addInfoLog(line) } } + + if jitIsAtBottom, let last = logManager.logs.last { + jitScrollView?.scrollTo(last.id, anchor: .bottom) + } } } catch { await MainActor.run { @@ -421,6 +763,10 @@ struct ConsoleLogsView: View { let excessCount = logManager.logs.count - maxLines logManager.removeOldestLogs(count: excessCount) } + + if jitIsAtBottom, let last = logManager.logs.last { + jitScrollView?.scrollTo(last.id, anchor: .bottom) + } } } } catch { diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 9bc2218..a392d61 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -30,12 +30,77 @@ struct InstalledAppsListView: View { @AppStorage("performanceMode") private var performanceMode = false @State private var showPerformanceToast = false + @State private var selectedTab: AppListTab = .debuggable + @State private var launchingBundles: Set = [] + @State private var launchFeedback: LaunchFeedback? = nil + @State private var otherSearchText: String = "" @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void private var filteredRecents: [String] { - recentApps.filter { !favoriteApps.contains($0) } + recentApps.filter { viewModel.debuggableApps[$0] != nil && !favoriteApps.contains($0) } + } + + private enum AppListTab: String, CaseIterable, Identifiable { + case debuggable + case other + + var id: String { rawValue } + + var title: String { + switch self { + case .debuggable: return "Debuggable" + case .other: return "Other" + } + } + } + + private struct LaunchFeedback: Identifiable { + let id = UUID() + let message: String + let success: Bool + } + + private var sortedOtherApps: [(key: String, value: String)] { + viewModel.nonDebuggableApps.sorted { lhs, rhs in + let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value) + if comparison == .orderedSame { + return lhs.key < rhs.key + } + return comparison == .orderedAscending + } + } + + private var otherSearchIsActive: Bool { + !otherSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var filteredOtherApps: [(key: String, value: String)] { + let base = sortedOtherApps + guard otherSearchIsActive else { return base } + let query = otherSearchText + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + return base.filter { + let identifier = $0.key + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + let name = $0.value + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + return identifier.contains(query) || name.contains(query) + } + } + + private func isEmpty(for tab: AppListTab) -> Bool { + switch tab { + case .debuggable: + return viewModel.debuggableApps.isEmpty + case .other: + return filteredOtherApps.isEmpty + } } var body: some View { @@ -43,12 +108,22 @@ struct InstalledAppsListView: View { ZStack { Color.clear.ignoresSafeArea() - if viewModel.apps.isEmpty { - emptyState - .padding(.horizontal) + if viewModel.isLoading { + ProgressView("Loading Apps…".localized) + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) .transition(.opacity.combined(with: .scale)) } else { - appsList + tabbedContent .transition(.opacity) .transaction { t in t.disablesAnimations = true } } @@ -67,6 +142,29 @@ struct InstalledAppsListView: View { } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPerformanceToast) } + + if let feedback = launchFeedback { + VStack { + Spacer() + Text(feedback.message) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(.ultraThinMaterial) + .overlay( + Capsule() + .stroke(feedback.success ? Color.green.opacity(0.35) : Color.red.opacity(0.35), lineWidth: 1) + ) + ) + .foregroundStyle(feedback.success ? .green : .red) + .shadow(radius: 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 40) + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id) + } } .navigationTitle("Installed Apps".localized) .toolbar { @@ -99,7 +197,7 @@ struct InstalledAppsListView: View { // MARK: Empty State - private var emptyState: some View { + private func emptyState(for tab: AppListTab) -> some View { VStack(spacing: 16) { Image(systemName: "magnifyingglass") .resizable() @@ -107,76 +205,53 @@ struct InstalledAppsListView: View { .frame(width: 60, height: 60) .foregroundStyle(.secondary) - Text("No Debuggable App Found") - .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) + switch tab { + case .debuggable: + Text("No Debuggable App Found".localized) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) - Text(""" - StikDebug can only connect to apps with the “get-task-allow” entitlement. - Please check if the app you want to connect to is signed with a development certificate. - """) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + Text(""" + StikDebug can only connect to apps with the “get-task-allow” entitlement. + Please check if the app you want to connect to is signed with a development certificate. + """.localized) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + case .other: + Text("Nothing To Show".localized) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + + Text(""" + Once your device pairing file is imported and CoreDevice is connected, every installed app will appear here for quick access. + """.localized) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } } .padding(24) .glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12) .accessibilityElement(children: .combine) - .accessibilityLabel("No debuggable app found. Ensure the app is signed for development.") + .accessibilityLabel("Empty list. Import pairing file or sign app appropriately.".localized) } // MARK: Apps List - private var appsList: some View { - ScrollView { - VStack(spacing: 18) { - if !favoriteApps.isEmpty { - glassSection( - title: String(format: "Favorites (%d/4)".localized, favoriteApps.count) - ) { - LazyVStack(spacing: 12) { - ForEach(favoriteApps, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) - } - } - } - } - - if !filteredRecents.isEmpty { - glassSection(title: "Recents".localized) { - LazyVStack(spacing: 12) { - ForEach(filteredRecents, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) - } - } - } - } - - glassSection(title: "All Applications".localized) { + private var debuggableSections: some View { + VStack(spacing: 18) { + if !favoriteApps.isEmpty { + glassSection( + title: String(format: "Favorites (%d/4)".localized, favoriteApps.count) + ) { LazyVStack(spacing: 12) { - ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + ForEach(favoriteApps, id: \.self) { bundleID in AppButton( bundleID: bundleID, - appName: appName, + appName: viewModel.debuggableApps[bundleID] ?? bundleID, recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, @@ -188,11 +263,208 @@ struct InstalledAppsListView: View { } } } + + if !filteredRecents.isEmpty { + glassSection(title: "Recents".localized) { + LazyVStack(spacing: 12) { + ForEach(filteredRecents, id: \.self) { bundleID in + AppButton( + bundleID: bundleID, + appName: viewModel.debuggableApps[bundleID] ?? bundleID, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults, + performanceMode: performanceMode + ) + } + } + } + } + + glassSection(title: "All Applications".localized) { + LazyVStack(spacing: 12) { + ForEach(viewModel.debuggableApps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + AppButton( + bundleID: bundleID, + appName: appName, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults, + performanceMode: performanceMode + ) + } + } + } + } + } + + private func otherSections(apps: [(key: String, value: String)]) -> some View { + VStack(spacing: 18) { + glassSection(title: "How It Works".localized) { + VStack(alignment: .leading, spacing: 8) { + Text("Launch apps without get-task-allow by tunneling through CoreDevice. This only starts the app; attach debugging from Recents once it exposes get-task-allow.".localized) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + glassSection(title: "All Installed Apps".localized) { + LazyVStack(spacing: 12) { + ForEach(apps, id: \.key) { bundleID, appName in + LaunchAppRow( + bundleID: bundleID, + appName: appName, + isLaunching: launchingBundles.contains(bundleID), + appIcons: $appIcons, + performanceMode: performanceMode + ) { + startLaunching(bundleID: bundleID) + } + } + } + } + } + } + + private var tabbedContent: some View { + TabView(selection: $selectedTab) { + tabContent(for: .debuggable) + .tag(AppListTab.debuggable) + .tabItem { Text(AppListTab.debuggable.title.localized) } + + tabContent(for: .other) + .tag(AppListTab.other) + .tabItem { Text(AppListTab.other.title.localized) } + } + } + + @ViewBuilder + private func tabContent(for tab: AppListTab) -> some View { + switch tab { + case .debuggable: + if isEmpty(for: .debuggable) { + VStack { + Spacer(minLength: 0) + emptyState(for: .debuggable) + Spacer(minLength: 0) + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + .transition(.opacity.combined(with: .scale)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(spacing: 18) { + if let error = viewModel.lastError { + errorBanner(error) + } + + debuggableSections + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + } + case .other: + otherTabContent(apps: filteredOtherApps) + } + } + + private func otherTabContent(apps: [(key: String, value: String)]) -> some View { + ScrollView { + VStack(spacing: 18) { + searchBar + + if let error = viewModel.lastError { + errorBanner(error) + } + + if apps.isEmpty { + if otherSearchIsActive { + otherSearchEmptyState + .transition(.opacity.combined(with: .scale)) + } else { + emptyState(for: .other) + .transition(.opacity.combined(with: .scale)) + } + } else { + otherSections(apps: apps) + } + } .padding(.horizontal, 20) .padding(.vertical, 24) } } + private var otherSearchEmptyState: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(.secondary) + + Text("No matches".localized) + .font(.title3.weight(.semibold)) + + Text("Try another name or bundle identifier.".localized) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(24) + .glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12) + } + + private var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search".localized, text: $otherSearchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + if !otherSearchText.isEmpty { + Button { + otherSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } + .accessibilityLabel("Clear search".localized) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + ) + ) + } + + private func errorBanner(_ message: String) -> some View { + Text(message) + .font(.footnote) + .foregroundStyle(.orange) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.12), lineWidth: 1) + ) + ) + } + // MARK: Section Wrapper private func glassSection( @@ -228,6 +500,35 @@ struct InstalledAppsListView: View { } if touched { WidgetCenter.shared.reloadAllTimelines() } } + + private func startLaunching(bundleID: String) { + guard !launchingBundles.contains(bundleID) else { return } + launchingBundles.insert(bundleID) + Haptics.selection() + + viewModel.launchWithoutDebug(bundleID: bundleID) { success in + launchingBundles.remove(bundleID) + + let message = success ? "Launch request sent".localized : "Launch failed".localized + let feedback = LaunchFeedback(message: message, success: success) + + if success { + Haptics.light() + } + + withAnimation { + launchFeedback = feedback + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if launchFeedback?.id == feedback.id { + withAnimation { + launchFeedback = nil + } + } + } + } + } } // MARK: - App Button Row @@ -435,60 +736,231 @@ struct AppButton: View { private func loadAppIcon(for bundleID: String) { guard loadAppIconsOnJIT else { return } - if let mem = IconCache.shared.cached(bundleID) { - appIcons[bundleID] = mem - return - } - if let disk = loadCachedIcon(bundleID: bundleID) { - IconCache.shared.store(disk, for: bundleID) - appIcons[bundleID] = disk - return - } - IconCache.shared.enqueue { [bundleID] in - AppStoreIconFetcher.getIcon(for: bundleID) { image in - guard let image else { return } - IconCache.shared.store(image, for: bundleID) - DispatchQueue.main.async { - withAnimation(.linear(duration: 0.12)) { - appIcons[bundleID] = image - } - saveIconToGroup(image, bundleID: bundleID) + IconCache.shared.fetchIcon(for: bundleID) { image in + guard let image else { return } + let shouldAnimate = appIcons[bundleID] == nil + if shouldAnimate { + withAnimation(.linear(duration: 0.12)) { + appIcons[bundleID] = image } + } else { + appIcons[bundleID] = image } } } - - private func loadCachedIcon(bundleID: String) -> UIImage? { - guard let containerURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") else { - return nil - } - let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true) - let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") - if FileManager.default.fileExists(atPath: fileURL.path) { - return UIImage(contentsOfFile: fileURL.path) - } - return nil - } } // MARK: - IconCache +struct LaunchAppRow: View { + let bundleID: String + let appName: String + let isLaunching: Bool + + @Binding var appIcons: [String: UIImage] + + @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true + + let performanceMode: Bool + var launchAction: () -> Void + + var body: some View { + Button { + guard !isLaunching else { return } + launchAction() + } label: { + HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { + iconView + + VStack(alignment: .leading, spacing: 3) { + Text(appName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(bundleID) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer() + + if isLaunching { + ProgressView() + .controlSize(.small) + } else { + Text("Launch".localized) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.18)) + ) + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, loadAppIconsOnJIT ? 8 : 12) + .padding(.horizontal, 12) + .background(rowBackground) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isLaunching) + .onAppear { + if loadAppIconsOnJIT { + loadAppIcon(for: bundleID) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(appName)") + .accessibilityHint("Double-tap to launch via CoreDevice.") + } + + private var iconView: some View { + Group { + if loadAppIconsOnJIT, let image = appIcons[bundleID] { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 54, height: 54) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5) + .transition(.opacity.combined(with: .scale)) + } else { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.systemGray5)) + .frame(width: 54, height: 54) + .overlay( + Image(systemName: "app") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(.gray) + ) + } + } + .accessibilityHidden(true) + } + + private var rowBackground: some View { + Group { + if performanceMode { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemBackground).opacity(0.65)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.10), lineWidth: 1) + ) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.15), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3) + } + } + } + + private func loadAppIcon(for bundleID: String) { + IconCache.shared.fetchIcon(for: bundleID) { image in + guard let image else { return } + let shouldAnimate = appIcons[bundleID] == nil + if shouldAnimate { + withAnimation(.linear(duration: 0.12)) { + appIcons[bundleID] = image + } + } else { + appIcons[bundleID] = image + } + } + } +} + final class IconCache { static let shared = IconCache() + private let mem = NSCache() private let queue = OperationQueue() + private let callbackQueue = DispatchQueue(label: "com.stik.iconcache.callbacks") + private var pendingCallbacks: [String: [(UIImage?) -> Void]] = [:] + private init() { queue.maxConcurrentOperationCount = 4 + queue.qualityOfService = .userInitiated mem.countLimit = 1000 mem.totalCostLimit = 64 * 1024 * 1024 } - func cached(_ key: String) -> UIImage? { mem.object(forKey: key as NSString) } - func store(_ image: UIImage, for key: String) { - let cost = Int(image.size.width * image.size.height * image.scale * image.scale) - mem.setObject(image, forKey: key as NSString, cost: cost) + + func fetchIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) { + if let memImage = mem.object(forKey: bundleID as NSString) { + DispatchQueue.main.async { completion(memImage) } + return + } + + var shouldStartLoad = false + callbackQueue.sync { + if var existing = pendingCallbacks[bundleID] { + existing.append(completion) + pendingCallbacks[bundleID] = existing + } else { + pendingCallbacks[bundleID] = [completion] + shouldStartLoad = true + } + } + + guard shouldStartLoad else { return } + + queue.addOperation { [weak self] in + guard let self else { return } + + var loadedFromDisk = false + var result = self.diskCachedIcon(for: bundleID) + if result != nil { + loadedFromDisk = true + } + + if result == nil { + let semaphore = DispatchSemaphore(value: 0) + AppStoreIconFetcher.getIcon(for: bundleID) { image in + result = image + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 10) + } + + if let image = result { + let cost = Int(image.size.width * image.size.height * image.scale * image.scale) + self.mem.setObject(image, forKey: bundleID as NSString, cost: cost) + if !loadedFromDisk { + saveIconToGroup(image, bundleID: bundleID) + } + } + + let callbacks = self.callbackQueue.sync { () -> [(UIImage?) -> Void] in + let handlers = self.pendingCallbacks[bundleID] ?? [] + self.pendingCallbacks[bundleID] = nil + return handlers + } + + DispatchQueue.main.async { + callbacks.forEach { $0(result) } + } + } + } + + private func diskCachedIcon(for bundleID: String) -> UIImage? { + guard let containerURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") + else { return nil } + + let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true) + let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + return UIImage(contentsOfFile: fileURL.path) } - func enqueue(_ op: @escaping () -> Void) { queue.addOperation(op) } } // MARK: - Shared UI Bits @@ -572,10 +1044,51 @@ extension Array: @retroactive RawRepresentable where Element: Codable { } class InstalledAppsViewModel: ObservableObject { - @Published var apps: [String: String] = [:] - init() { loadApps() } - func loadApps() { - do { self.apps = try JITEnableContext.shared.getAppList() } - catch { print(error); self.apps = [:] } + @Published var debuggableApps: [String: String] = [:] + @Published var nonDebuggableApps: [String: String] = [:] + @Published var isLoading = false + @Published var lastError: String? = nil + + private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated) + + init() { refreshAppLists() } + + func refreshAppLists() { + isLoading = true + lastError = nil + + workQueue.async { [weak self] in + guard let self else { return } + do { + let debuggable = try JITEnableContext.shared.getAppList() + let allApps = try JITEnableContext.shared.getAllApps() + + let nonDebuggableSequence = allApps.filter { debuggable[$0.key] == nil } + let nonDebuggable = Dictionary(uniqueKeysWithValues: nonDebuggableSequence.map { ($0.key, $0.value) }) + + DispatchQueue.main.async { + self.debuggableApps = debuggable + self.nonDebuggableApps = nonDebuggable + self.isLoading = false + } + } catch { + DispatchQueue.main.async { + self.debuggableApps = [:] + self.nonDebuggableApps = [:] + self.isLoading = false + self.lastError = error.localizedDescription + print("Failed to load apps: \(error)") + } + } + } + } + + func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) { + workQueue.async { + let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) + DispatchQueue.main.async { + completion(success) + } + } } } diff --git a/StikJIT/idevice/JITEnableContext.h b/StikJIT/idevice/JITEnableContext.h index c85f075..4eb78bb 100644 --- a/StikJIT/idevice/JITEnableContext.h +++ b/StikJIT/idevice/JITEnableContext.h @@ -12,6 +12,8 @@ typedef void (^HeartbeatCompletionHandler)(int result, NSString *message); typedef void (^LogFuncC)(const char* message, ...); typedef void (^LogFunc)(NSString *message); +typedef void (^SyslogLineHandler)(NSString *line); +typedef void (^SyslogErrorHandler)(NSError *error); @interface JITEnableContext : NSObject @property (class, readonly)JITEnableContext* shared; @@ -20,5 +22,10 @@ typedef void (^LogFunc)(NSString *message); - (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; - (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; - (NSDictionary*)getAppListWithError:(NSError**)error; +- (NSDictionary*)getAllAppsWithError:(NSError**)error; - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error; +- (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger; +- (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler + onError:(SyslogErrorHandler)errorHandler NS_SWIFT_NAME(startSyslogRelay(handler:onError:)); +- (void)stopSyslogRelay; @end diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m index 4f4af94..4aa6a6d 100644 --- a/StikJIT/idevice/JITEnableContext.m +++ b/StikJIT/idevice/JITEnableContext.m @@ -20,6 +20,11 @@ JITEnableContext* sharedJITContext = nil; @implementation JITEnableContext { bool heartbeatRunning; IdeviceProviderHandle* provider; + dispatch_queue_t syslogQueue; + BOOL syslogStreaming; + SyslogRelayClientHandle *syslogClient; + SyslogLineHandler syslogLineHandler; + SyslogErrorHandler syslogErrorHandler; } + (instancetype)shared { @@ -34,6 +39,9 @@ JITEnableContext* sharedJITContext = nil; NSURL* docPathUrl = [fm URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; NSURL* logURL = [docPathUrl URLByAppendingPathComponent:@"idevice_log.txt"]; idevice_init_logger(Info, Debug, (char*)logURL.path.UTF8String); + syslogQueue = dispatch_queue_create("com.stik.syslogrelay.queue", DISPATCH_QUEUE_SERIAL); + syslogStreaming = NO; + syslogClient = NULL; return self; } @@ -179,6 +187,22 @@ JITEnableContext* sharedJITContext = nil; return apps; } +- (NSDictionary*)getAllAppsWithError:(NSError**)error { + if (!provider) { + NSLog(@"Provider not initialized!"); + *error = [self errorWithStr:@"Provider not initialized!" code:-1]; + return nil; + } + + NSString* errorStr = nil; + NSDictionary* apps = list_all_apps(provider, &errorStr); + if (errorStr) { + *error = [self errorWithStr:errorStr code:-17]; + return nil; + } + return apps; +} + - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error { if (!provider) { NSLog(@"Provider not initialized!"); @@ -195,7 +219,137 @@ JITEnableContext* sharedJITContext = nil; return icon; } +- (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger { + if (!provider) { + if (logger) { + logger(@"Provider not initialized!"); + } + NSLog(@"Provider not initialized!"); + return NO; + } + + [self ensureHeartbeat]; + + int result = launch_app_via_proxy(provider, + [bundleID UTF8String], + [self createCLogger:logger]); + return result == 0; +} + +- (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler + onError:(SyslogErrorHandler)errorHandler +{ + if (!provider) { + if (errorHandler) { + errorHandler([self errorWithStr:@"Provider not initialized!" code:-1]); + } + return; + } + if (!lineHandler || syslogStreaming) { + return; + } + + syslogStreaming = YES; + syslogLineHandler = [lineHandler copy]; + syslogErrorHandler = [errorHandler copy]; + + __weak typeof(self) weakSelf = self; + dispatch_async(syslogQueue, ^{ + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) { return; } + + [strongSelf ensureHeartbeat]; + + SyslogRelayClientHandle *client = NULL; + IdeviceFfiError *err = syslog_relay_connect_tcp(strongSelf->provider, &client); + if (err != NULL) { + NSString *message = err->message ? [NSString stringWithCString:err->message encoding:NSASCIIStringEncoding] : @"Failed to connect to syslog relay"; + NSError *nsError = [strongSelf errorWithStr:message code:err->code]; + idevice_error_free(err); + [strongSelf handleSyslogFailure:nsError]; + return; + } + + strongSelf->syslogClient = client; + + while (strongSelf && strongSelf->syslogStreaming) { + char *message = NULL; + IdeviceFfiError *nextErr = syslog_relay_next(client, &message); + if (nextErr != NULL) { + NSString *errMsg = nextErr->message ? [NSString stringWithCString:nextErr->message encoding:NSASCIIStringEncoding] : @"Syslog relay read failed"; + NSError *nsError = [strongSelf errorWithStr:errMsg code:nextErr->code]; + idevice_error_free(nextErr); + if (message) { idevice_string_free(message); } + [strongSelf handleSyslogFailure:nsError]; + client = NULL; + break; + } + + if (!message) { + continue; + } + + NSString *line = [NSString stringWithCString:message encoding:NSUTF8StringEncoding]; + idevice_string_free(message); + if (!line || !strongSelf->syslogLineHandler) { + continue; + } + + SyslogLineHandler handlerCopy = strongSelf->syslogLineHandler; + if (handlerCopy) { + dispatch_async(dispatch_get_main_queue(), ^{ + handlerCopy(line); + }); + } + } + + if (client) { + syslog_relay_client_free(client); + } + + strongSelf->syslogClient = NULL; + strongSelf->syslogStreaming = NO; + strongSelf->syslogLineHandler = nil; + strongSelf->syslogErrorHandler = nil; + }); +} + +- (void)stopSyslogRelay { + if (!syslogStreaming) { + return; + } + + syslogStreaming = NO; + syslogLineHandler = nil; + syslogErrorHandler = nil; + + dispatch_async(syslogQueue, ^{ + if (self->syslogClient) { + syslog_relay_client_free(self->syslogClient); + self->syslogClient = NULL; + } + }); +} + +- (void)handleSyslogFailure:(NSError *)error { + syslogStreaming = NO; + if (syslogClient) { + syslog_relay_client_free(syslogClient); + syslogClient = NULL; + } + SyslogErrorHandler errorCopy = syslogErrorHandler; + syslogLineHandler = nil; + syslogErrorHandler = nil; + + if (errorCopy) { + dispatch_async(dispatch_get_main_queue(), ^{ + errorCopy(error); + }); + } +} + - (void)dealloc { + [self stopSyslogRelay]; if (provider) { idevice_provider_free(provider); } diff --git a/StikJIT/idevice/applist.h b/StikJIT/idevice/applist.h index 1696ddb..2f0cdf3 100644 --- a/StikJIT/idevice/applist.h +++ b/StikJIT/idevice/applist.h @@ -11,6 +11,7 @@ @import UIKit; NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error); +NSDictionary* list_all_apps(IdeviceProviderHandle* provider, NSString** error); UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error); #endif /* APPLIST_H */ diff --git a/StikJIT/idevice/applist.m b/StikJIT/idevice/applist.m index 95478ff..01f0d7c 100644 --- a/StikJIT/idevice/applist.m +++ b/StikJIT/idevice/applist.m @@ -11,34 +11,25 @@ #include #import "applist.h" -NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) { - InstallationProxyClientHandle *client = NULL; - if (installation_proxy_connect_tcp(provider, &client)) { - *error = @"Failed to connect to installation proxy"; - return nil; - } - - void *apps = NULL; - size_t count = 0; - if (installation_proxy_get_apps(client, "User", NULL, 0, &apps, &count)) { - installation_proxy_client_free(client); - *error = @"Failed to get apps"; - return nil; - } - +static NSDictionary *buildAppDictionary(void *apps, + size_t count, + BOOL requireGetTaskAllow) +{ NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:count]; for (size_t i = 0; i < count; i++) { plist_t app = ((plist_t *)apps)[i]; plist_t ent = plist_dict_get_item(app, "Entitlements"); - if (!ent) continue; - plist_t tnode = plist_dict_get_item(ent, "get-task-allow"); - if (!tnode) continue; + if (requireGetTaskAllow) { + if (!ent) continue; + plist_t tnode = plist_dict_get_item(ent, "get-task-allow"); + if (!tnode) continue; - uint8_t isAllowed = 0; - plist_get_bool_val(tnode, &isAllowed); - if (!isAllowed) continue; + uint8_t isAllowed = 0; + plist_get_bool_val(tnode, &isAllowed); + if (!isAllowed) continue; + } plist_t bidNode = plist_dict_get_item(app, "CFBundleIdentifier"); if (!bidNode) continue; @@ -49,6 +40,7 @@ NSDictionary* list_installed_apps(IdeviceProviderHandle* p free(bidC); continue; } + NSString *bundleID = [NSString stringWithUTF8String:bidC]; free(bidC); @@ -66,10 +58,40 @@ NSDictionary* list_installed_apps(IdeviceProviderHandle* p result[bundleID] = appName; } + return result; +} + +static NSDictionary *performAppQuery(IdeviceProviderHandle *provider, + BOOL requireGetTaskAllow, + NSString **error) +{ + InstallationProxyClientHandle *client = NULL; + if (installation_proxy_connect_tcp(provider, &client)) { + *error = @"Failed to connect to installation proxy"; + return nil; + } + + void *apps = NULL; + size_t count = 0; + if (installation_proxy_get_apps(client, NULL, NULL, 0, &apps, &count)) { + installation_proxy_client_free(client); + *error = @"Failed to get apps"; + return nil; + } + + NSDictionary *result = buildAppDictionary(apps, count, requireGetTaskAllow); installation_proxy_client_free(client); return result; } +NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) { + return performAppQuery(provider, YES, error); +} + +NSDictionary* list_all_apps(IdeviceProviderHandle* provider, NSString** error) { + return performAppQuery(provider, NO, error); +} + UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error) { SpringBoardServicesClientHandle *client = NULL; if (springboard_services_connect(provider, &client)) { diff --git a/StikJIT/idevice/jit.c b/StikJIT/idevice/jit.c index f178590..2e0fe55 100644 --- a/StikJIT/idevice/jit.c +++ b/StikJIT/idevice/jit.c @@ -302,3 +302,114 @@ int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, logger("Debug session completed"); return 0; } + +int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger) { + idevice_init_logger(Info, Disabled, NULL); + IdeviceFfiError* err = NULL; + + CoreDeviceProxyHandle *core_device = NULL; + err = core_device_proxy_connect(tcp_provider, &core_device); + if (err != NULL) { + fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + return 1; + } + + uint16_t rsd_port; + err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); + if (err != NULL) { + fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + core_device_proxy_free(core_device); + return 1; + } + + AdapterHandle *adapter = NULL; + err = core_device_proxy_create_tcp_adapter(core_device, &adapter); + if (err != NULL) { + fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + core_device_proxy_free(core_device); + return 1; + } + + AdapterStreamHandle *stream = NULL; + err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); + if (err != NULL) { + fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + adapter_free(adapter); + core_device_proxy_free(core_device); + return 1; + } + + RsdHandshakeHandle *handshake = NULL; + err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); + if (err != NULL) { + fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + adapter_close(stream); + adapter_free(adapter); + core_device_proxy_free(core_device); + return 1; + } + + RemoteServerHandle *remote_server = NULL; + err = remote_server_connect_rsd(adapter, handshake, &remote_server); + if (err != NULL) { + fprintf(stderr, "Failed to create remote server: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + rsd_handshake_free(handshake); + adapter_free(adapter); + core_device_proxy_free(core_device); + return 1; + } + + ProcessControlHandle *process_control = NULL; + err = process_control_new(remote_server, &process_control); + if (err != NULL) { + fprintf(stderr, "Failed to create process control client: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + remote_server_free(remote_server); + rsd_handshake_free(handshake); + adapter_free(adapter); + core_device_proxy_free(core_device); + return 1; + } + + uint64_t pid = 0; + err = process_control_launch_app(process_control, + bundle_id, + NULL, + 0, + NULL, + 0, + false, + true, + &pid); + if (err != NULL) { + fprintf(stderr, "Failed to launch app: [%d] %s\n", err->code, err->message); + idevice_error_free(err); + process_control_free(process_control); + remote_server_free(remote_server); + rsd_handshake_free(handshake); + adapter_free(adapter); + core_device_proxy_free(core_device); + if (logger) { + logger("Failed to launch app: %s", bundle_id); + } + return 1; + } + + if (logger) { + logger("Launched app (PID %llu)", pid); + } + + process_control_free(process_control); + remote_server_free(remote_server); + rsd_handshake_free(handshake); + adapter_free(adapter); + core_device_proxy_free(core_device); + + return 0; +} diff --git a/StikJIT/idevice/jit.h b/StikJIT/idevice/jit.h index ee5b195..87f8ec4 100644 --- a/StikJIT/idevice/jit.h +++ b/StikJIT/idevice/jit.h @@ -14,5 +14,6 @@ typedef void (^LogFuncC)(const char* message, ...); typedef void (^DebugAppCallback)(int pid, struct DebugProxyHandle* debug_proxy, dispatch_semaphore_t semaphore); int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger, DebugAppCallback callback); int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, DebugAppCallback callback); +int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger); #endif /* JIT_H */