mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
2.3.0 RC 1 (WIP)
This commit is contained in:
parent
d077e232a9
commit
61a1e4fca0
10 changed files with 1621 additions and 264 deletions
|
|
@ -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;
|
||||
|
|
|
|||
202
StikJIT/Utilities/SystemLogStream.swift
Normal file
202
StikJIT/Utilities/SystemLogStream.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue