2.3.0 RC 1 (WIP)

This commit is contained in:
Stephen B 2025-09-21 23:24:07 -04:00
parent d077e232a9
commit 61a1e4fca0
10 changed files with 1621 additions and 264 deletions

View file

@ -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;

View file

@ -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
}()
}

View file

@ -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 {

View file

@ -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<String> = []
@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<Content: View>(
@ -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<NSString, UIImage>()
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)
}
}
}
}

View file

@ -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<NSString*, NSString*>*)getAppListWithError:(NSError**)error;
- (NSDictionary<NSString*, NSString*>*)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

View file

@ -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<NSString*, NSString*>*)getAllAppsWithError:(NSError**)error {
if (!provider) {
NSLog(@"Provider not initialized!");
*error = [self errorWithStr:@"Provider not initialized!" code:-1];
return nil;
}
NSString* errorStr = nil;
NSDictionary<NSString*, NSString*>* 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);
}

View file

@ -11,6 +11,7 @@
@import UIKit;
NSDictionary<NSString*, NSString*>* list_installed_apps(IdeviceProviderHandle* provider, NSString** error);
NSDictionary<NSString*, NSString*>* list_all_apps(IdeviceProviderHandle* provider, NSString** error);
UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error);
#endif /* APPLIST_H */

View file

@ -11,34 +11,25 @@
#include <string.h>
#import "applist.h"
NSDictionary<NSString*, NSString*>* 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<NSString*, NSString*> *buildAppDictionary(void *apps,
size_t count,
BOOL requireGetTaskAllow)
{
NSMutableDictionary<NSString*, NSString*> *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<NSString*, NSString*>* list_installed_apps(IdeviceProviderHandle* p
free(bidC);
continue;
}
NSString *bundleID = [NSString stringWithUTF8String:bidC];
free(bidC);
@ -66,10 +58,40 @@ NSDictionary<NSString*, NSString*>* list_installed_apps(IdeviceProviderHandle* p
result[bundleID] = appName;
}
return result;
}
static NSDictionary<NSString*, NSString*> *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<NSString*, NSString*> *result = buildAppDictionary(apps, count, requireGetTaskAllow);
installation_proxy_client_free(client);
return result;
}
NSDictionary<NSString*, NSString*>* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) {
return performAppQuery(provider, YES, error);
}
NSDictionary<NSString*, NSString*>* 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)) {

View file

@ -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;
}

View file

@ -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 */