mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
794 lines
32 KiB
Swift
794 lines
32 KiB
Swift
//
|
||
// ContentView.swift
|
||
// StikJIT
|
||
//
|
||
// Created by Stephen on 3/26/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
import Pipify
|
||
|
||
struct JITEnableConfiguration {
|
||
var bundleID: String? = nil
|
||
var pid : Int? = nil
|
||
var scriptData: Data? = nil
|
||
var scriptName : String? = nil
|
||
}
|
||
|
||
struct HomeView: View {
|
||
|
||
@AppStorage("username") private var username = "User"
|
||
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@Environment(\.accentColor) private var environmentAccentColor
|
||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||
@AppStorage("bundleID") private var bundleID: String = ""
|
||
@State private var isProcessing = false
|
||
@State private var isShowingInstalledApps = false
|
||
@State private var isShowingPairingFilePicker = false
|
||
@State private var pairingFileExists: Bool = false
|
||
@State private var showPairingFileMessage = false
|
||
@State private var pairingFileIsValid = false
|
||
@State private var isImportingFile = false
|
||
@State private var showingConsoleLogsView = false
|
||
@State private var importProgress: Float = 0.0
|
||
|
||
@State private var pidTextAlertShow = false
|
||
@State private var pidStr = ""
|
||
|
||
@State private var viewDidAppeared = false
|
||
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
|
||
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
||
|
||
@AppStorage("useDefaultScript") private var useDefaultScript = false
|
||
@AppStorage("enablePiP") private var enablePiP = true
|
||
@State var scriptViewShow = false
|
||
@State var pipRequired = false
|
||
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
|
||
@State var jsModel: RunJSViewModel?
|
||
|
||
// Observe VPN status
|
||
@StateObject private var tunnel = TunnelManager.shared
|
||
// Mirror heartbeat boolean
|
||
@State private var heartbeatOK = false
|
||
|
||
private var accentColor: Color {
|
||
if customAccentColorHex.isEmpty {
|
||
return .white
|
||
} else {
|
||
return Color(hex: customAccentColorHex) ?? .white
|
||
}
|
||
}
|
||
|
||
// Derived states
|
||
private var ddiMounted: Bool { isMounted() }
|
||
private var canConnectByApp: Bool { pairingFileExists && ddiMounted }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
// DeviceInfo-style subtle gradient background
|
||
LinearGradient(
|
||
gradient: Gradient(colors: [
|
||
Color(UIColor.systemBackground),
|
||
Color(UIColor.secondarySystemBackground)
|
||
]),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.ignoresSafeArea()
|
||
|
||
ScrollView {
|
||
VStack(spacing: 20) {
|
||
// Top card: status + both actions
|
||
topStatusAndActionsCard
|
||
|
||
// Tools (Console)
|
||
toolsCard
|
||
|
||
// Helpful tips
|
||
tipsCard
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 30)
|
||
}
|
||
|
||
// Busy overlay during pairing import (DeviceInfo-style)
|
||
if isImportingFile {
|
||
Color.black.opacity(0.35).ignoresSafeArea()
|
||
VStack(spacing: 12) {
|
||
ProgressView("Processing pairing file...")
|
||
VStack(spacing: 8) {
|
||
GeometryReader { geometry in
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(Color(UIColor.tertiarySystemFill))
|
||
.frame(height: 8)
|
||
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(Color.green)
|
||
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
||
.animation(.linear(duration: 0.3), value: importProgress)
|
||
}
|
||
}
|
||
.frame(height: 8)
|
||
Text("\(Int(importProgress * 100))%")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(.top, 6)
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||
}
|
||
|
||
// Success toast after import (DeviceInfo-style)
|
||
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
|
||
VStack {
|
||
Spacer()
|
||
Text("✓ Pairing file successfully imported")
|
||
.font(.footnote.weight(.semibold))
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.ultraThinMaterial, in: Capsule())
|
||
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
.padding(.bottom, 30)
|
||
}
|
||
.animation(.easeInOut(duration: 0.25), value: showPairingFileMessage)
|
||
}
|
||
}
|
||
.navigationTitle("Home")
|
||
}
|
||
.onAppear {
|
||
checkPairingFileExists()
|
||
refreshBackground()
|
||
|
||
NotificationCenter.default.addObserver(
|
||
forName: NSNotification.Name("ShowPairingFilePicker"),
|
||
object: nil,
|
||
queue: .main
|
||
) { _ in
|
||
isShowingPairingFilePicker = true
|
||
}
|
||
}
|
||
.onReceive(timer) { _ in
|
||
refreshBackground()
|
||
checkPairingFileExists()
|
||
// Update heartbeat mirror from global
|
||
heartbeatOK = pubHeartBeat
|
||
}
|
||
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) {result in
|
||
switch result {
|
||
case .success(let url):
|
||
let fileManager = FileManager.default
|
||
let accessing = url.startAccessingSecurityScopedResource()
|
||
|
||
if fileManager.fileExists(atPath: url.path) {
|
||
do {
|
||
if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) {
|
||
try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
||
}
|
||
|
||
try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
||
print("File copied successfully!")
|
||
|
||
// Show progress bar and initialize progress
|
||
DispatchQueue.main.async {
|
||
isImportingFile = true
|
||
importProgress = 0.0
|
||
pairingFileExists = true
|
||
}
|
||
|
||
// Start heartbeat in background
|
||
startHeartbeatInBackground()
|
||
|
||
// Create timer to update progress instead of sleeping
|
||
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
|
||
DispatchQueue.main.async {
|
||
if importProgress < 1.0 {
|
||
importProgress += 0.25
|
||
} else {
|
||
timer.invalidate()
|
||
isImportingFile = false
|
||
pairingFileIsValid = true
|
||
|
||
// Show success message
|
||
withAnimation {
|
||
showPairingFileMessage = true
|
||
}
|
||
|
||
// Hide message after delay
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||
withAnimation {
|
||
showPairingFileMessage = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ensure timer keeps running
|
||
RunLoop.current.add(progressTimer, forMode: .common)
|
||
|
||
} catch {
|
||
print("Error copying file: \(error)")
|
||
}
|
||
} else {
|
||
print("Source file does not exist.")
|
||
}
|
||
|
||
if accessing {
|
||
url.stopAccessingSecurityScopedResource()
|
||
}
|
||
case .failure(let error):
|
||
print("Failed to import file: \(error)")
|
||
}
|
||
}
|
||
.sheet(isPresented: $isShowingInstalledApps) {
|
||
InstalledAppsListView { selectedBundle in
|
||
bundleID = selectedBundle
|
||
isShowingInstalledApps = false
|
||
HapticFeedbackHelper.trigger()
|
||
startJITInBackground(bundleID: selectedBundle)
|
||
}
|
||
}
|
||
.pipify(isPresented: Binding(
|
||
get: { pipRequired && enablePiP },
|
||
set: { newValue in pipRequired = newValue }
|
||
)) {
|
||
RunJSViewPiP(model: $jsModel)
|
||
}
|
||
.sheet(isPresented: $scriptViewShow) {
|
||
NavigationView {
|
||
if let jsModel {
|
||
RunJSView(model: jsModel)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") {
|
||
scriptViewShow = false
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(selectedScript)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: scriptViewShow) { oldValue, newValue in
|
||
if !newValue, let jsModel {
|
||
jsModel.executionInterrupted = true
|
||
jsModel.semaphore?.signal()
|
||
}
|
||
}
|
||
.textFieldAlert(
|
||
isPresented: $pidTextAlertShow,
|
||
title: "Please enter the PID of the process you want to connect to".localized,
|
||
text: $pidStr,
|
||
placeholder: "",
|
||
action: { newText in
|
||
|
||
guard let pidStr = newText, pidStr != "" else {
|
||
return
|
||
}
|
||
|
||
guard let pid = Int(pidStr) else {
|
||
showAlert(title: "", message: "Invalid PID".localized, showOk: true, completion: { _ in })
|
||
return
|
||
}
|
||
startJITInBackground(pid: pid)
|
||
|
||
},
|
||
actionCancel: {_ in
|
||
pidStr = ""
|
||
}
|
||
)
|
||
.onOpenURL { url in
|
||
print(url.path)
|
||
if url.host != "enable-jit" {
|
||
return
|
||
}
|
||
|
||
var config = JITEnableConfiguration()
|
||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||
|
||
if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) {
|
||
config.pid = pid
|
||
}
|
||
if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value {
|
||
config.bundleID = bundleId
|
||
}
|
||
if let scriptBase64URL = components?.queryItems?.first(where: { $0.name == "script-data" })?.value?.removingPercentEncoding {
|
||
let base64 = base64URLToBase64(scriptBase64URL)
|
||
if let scriptData = Data(base64Encoded: base64) {
|
||
config.scriptData = scriptData
|
||
}
|
||
}
|
||
if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value {
|
||
config.scriptName = scriptName
|
||
}
|
||
|
||
if viewDidAppeared {
|
||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
||
} else {
|
||
pendingJITEnableConfiguration = config
|
||
}
|
||
|
||
}
|
||
.onAppear() {
|
||
viewDidAppeared = true
|
||
if let config = pendingJITEnableConfiguration {
|
||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
||
self.pendingJITEnableConfiguration = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Styled Sections
|
||
|
||
// Top card that includes: status icons + both actions
|
||
private var topStatusAndActionsCard: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
// Header row with icon-only indicators
|
||
HStack(spacing: 12) {
|
||
// Pairing
|
||
indicatorCapsule(
|
||
ok: pairingFileExists,
|
||
systemImage: "doc.badge.plus",
|
||
a11y: "Pairing"
|
||
)
|
||
// DDI
|
||
indicatorCapsule(
|
||
ok: ddiMounted,
|
||
systemImage: "externaldrive",
|
||
a11y: "Developer Disk Image"
|
||
)
|
||
// VPN
|
||
indicatorCapsule(
|
||
ok: tunnel.tunnelStatus == .connected,
|
||
systemImage: "lock.shield",
|
||
a11y: "VPN"
|
||
)
|
||
// Heartbeat
|
||
indicatorCapsule(
|
||
ok: heartbeatOK,
|
||
systemImage: "waveform.path.ecg",
|
||
a11y: "Heartbeat"
|
||
)
|
||
|
||
Spacer()
|
||
}
|
||
|
||
// Welcome text
|
||
VStack(spacing: 4) {
|
||
Text("Welcome, \(username)")
|
||
.font(.system(.title2, design: .rounded).weight(.bold))
|
||
.foregroundColor(.primary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(helperSubtitle())
|
||
.font(.system(.subheadline, design: .rounded))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.bottom, 4)
|
||
|
||
// Buttons in one vertical stack
|
||
VStack(spacing: 10) {
|
||
// Connect by App / Select Pairing File
|
||
Button(action: primaryActionTapped) {
|
||
HStack {
|
||
Image(systemName: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus")
|
||
.font(.system(size: 20))
|
||
Text(pairingFileExists ? "Connect by App" : "Select Pairing File")
|
||
.font(.system(.title3, design: .rounded))
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 14)
|
||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.foregroundColor(.black)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||
)
|
||
}
|
||
.disabled(pairingFileExists && !ddiMounted)
|
||
.opacity(pairingFileExists && !ddiMounted ? 0.6 : 1.0)
|
||
|
||
// Connect by PID (Advanced only)
|
||
if pairingFileExists && enableAdvancedOptions {
|
||
Button(action: { pidTextAlertShow = true }) {
|
||
HStack {
|
||
Image(systemName: "number.circle")
|
||
.font(.system(size: 20))
|
||
Text("Connect by PID")
|
||
.font(.system(.title3, design: .rounded))
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 14)
|
||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.foregroundColor(.black)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inline status / progress
|
||
if isImportingFile {
|
||
VStack(spacing: 8) {
|
||
HStack {
|
||
Text("Processing pairing file…")
|
||
.font(.system(.caption, design: .rounded))
|
||
.foregroundColor(.secondary)
|
||
Spacer()
|
||
Text("\(Int(importProgress * 100))%")
|
||
.font(.system(.caption, design: .rounded))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
GeometryReader { geometry in
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(Color(UIColor.tertiarySystemFill))
|
||
.frame(height: 8)
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(Color.green)
|
||
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
||
.animation(.linear(duration: 0.3), value: importProgress)
|
||
}
|
||
}
|
||
.frame(height: 8)
|
||
}
|
||
.padding(.top, 4)
|
||
} else if showPairingFileMessage && pairingFileIsValid {
|
||
HStack(spacing: 10) {
|
||
StatusDot(color: .green)
|
||
Text("Pairing file successfully imported")
|
||
.font(.system(.callout, design: .rounded))
|
||
.foregroundColor(.green)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||
}
|
||
|
||
// Compact indicator builder: dot + SF Symbol in a capsule
|
||
private func indicatorCapsule(ok: Bool, systemImage: String, a11y: String) -> some View {
|
||
let fixedHeight: CGFloat = 32
|
||
return HStack(spacing: 8) {
|
||
StatusDot(color: ok ? .green : .orange)
|
||
Image(systemName: systemImage)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundColor(.primary)
|
||
.accessibilityLabel(a11y)
|
||
}
|
||
.frame(height: fixedHeight)
|
||
.padding(.horizontal, 10)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(Color(UIColor.tertiarySystemBackground))
|
||
)
|
||
}
|
||
|
||
private var toolsCard: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Tools")
|
||
.font(.headline)
|
||
.foregroundColor(.secondary)
|
||
|
||
Button(action: { showingConsoleLogsView = true }) {
|
||
// Centered icon + text, no chevron
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "terminal")
|
||
.font(.system(size: 20))
|
||
Text("Open Console")
|
||
.font(.system(.title3, design: .rounded))
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.foregroundColor(.black)
|
||
.padding(.vertical, 14)
|
||
.padding(.horizontal, 12)
|
||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||
)
|
||
}
|
||
.sheet(isPresented: $showingConsoleLogsView) {
|
||
ConsoleLogsView()
|
||
}
|
||
}
|
||
.padding(20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||
}
|
||
|
||
private var tipsCard: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Tips")
|
||
.font(.headline)
|
||
.foregroundColor(.secondary)
|
||
|
||
if !pairingFileExists {
|
||
tipRow(
|
||
systemImage: "doc.badge.plus",
|
||
title: "Pairing file required",
|
||
message: "Import your device’s pairing file to begin."
|
||
)
|
||
}
|
||
|
||
if pairingFileExists && !ddiMounted {
|
||
tipRow(
|
||
systemImage: "externaldrive.badge.exclamationmark",
|
||
title: "Developer Disk Image not mounted",
|
||
message: "Go to Settings → Developer Disk Image and ensure it’s mounted."
|
||
)
|
||
}
|
||
|
||
tipRow(
|
||
systemImage: "lock.shield",
|
||
title: "Local only",
|
||
message: "StikDebug runs entirely on-device. No data leaves your device."
|
||
)
|
||
}
|
||
.padding(20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||
}
|
||
|
||
private func tipRow(systemImage: String, title: String, message: String) -> some View {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Image(systemName: systemImage)
|
||
.foregroundColor(accentColor)
|
||
.font(.system(size: 18))
|
||
.frame(width: 24)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title).font(.subheadline).fontWeight(.semibold)
|
||
Text(message).font(.footnote).foregroundColor(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
// MARK: - Helpers (UI)
|
||
|
||
private func helperSubtitle() -> String {
|
||
if canConnectByApp { return "Select an app to start debugging." }
|
||
if pairingFileExists { return "Mount the Developer Disk Image in Settings." }
|
||
return "Import your pairing file to get started."
|
||
}
|
||
|
||
private func primaryActionTapped() {
|
||
if pairingFileExists {
|
||
if !ddiMounted {
|
||
showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
|
||
return
|
||
}
|
||
isShowingInstalledApps = true
|
||
} else {
|
||
isShowingPairingFilePicker = true
|
||
}
|
||
}
|
||
|
||
// MARK: - Logic
|
||
|
||
private func checkPairingFileExists() {
|
||
let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path)
|
||
pairingFileExists = fileExists && isPairing()
|
||
}
|
||
|
||
private func refreshBackground() {
|
||
// kept for compatibility
|
||
}
|
||
|
||
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
||
return { pid, debugProxyHandle, semaphore in
|
||
jsModel = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, semaphore: semaphore)
|
||
scriptViewShow = true
|
||
|
||
DispatchQueue.global(qos: .background).async {
|
||
do {
|
||
try jsModel?.runScript(data: script, name: name)
|
||
} catch {
|
||
showAlert(title: "Error Occurred While Executing the Default Script.".localized, message: error.localizedDescription, showOk: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// launch app following this order: pid > bundleID
|
||
// load script following this order: scriptData > script file from script name > saved script for bundleID > default script
|
||
// if advanced mode is disabled the whole script loading will be skipped. If use default script is disabled default script will not be loaded
|
||
private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName : String? = nil, triggeredByURLScheme: Bool = false) {
|
||
isProcessing = true
|
||
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
||
|
||
DispatchQueue.global(qos: .background).async {
|
||
var scriptData = scriptData
|
||
var scriptName = scriptName
|
||
if enableAdvancedOptions && scriptData == nil {
|
||
if scriptName == nil, let bundleID, let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] {
|
||
scriptName = mapping[bundleID]
|
||
}
|
||
|
||
if useDefaultScript && scriptName == nil {
|
||
scriptName = selectedScript
|
||
}
|
||
|
||
if scriptData == nil, let scriptName {
|
||
let selectedScriptURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
.appendingPathComponent("scripts").appendingPathComponent(scriptName)
|
||
|
||
if FileManager.default.fileExists(atPath: selectedScriptURL.path) {
|
||
do {
|
||
scriptData = try Data(contentsOf: selectedScriptURL)
|
||
} catch {
|
||
print("failed to load data from script \(error)")
|
||
}
|
||
|
||
}
|
||
}
|
||
} else {
|
||
scriptData = nil
|
||
}
|
||
|
||
var callback: DebugAppCallback? = nil
|
||
|
||
if let scriptData {
|
||
callback = getJsCallback(scriptData, name: scriptName ?? bundleID ?? "Script")
|
||
if triggeredByURLScheme {
|
||
usleep(500000)
|
||
}
|
||
|
||
pipRequired = true
|
||
}
|
||
|
||
let logger: LogFunc = { message in
|
||
if let message = message {
|
||
LogManager.shared.addInfoLog(message)
|
||
}
|
||
}
|
||
var success : Bool
|
||
if let pid {
|
||
success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
|
||
} else if let bundleID {
|
||
success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
|
||
} else {
|
||
DispatchQueue.main.async {
|
||
showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
|
||
}
|
||
success = false
|
||
}
|
||
|
||
if success {
|
||
DispatchQueue.main.async {
|
||
LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))")
|
||
}
|
||
}
|
||
isProcessing = false
|
||
pipRequired = false
|
||
}
|
||
}
|
||
|
||
func base64URLToBase64(_ base64url: String) -> String {
|
||
var base64 = base64url
|
||
.replacingOccurrences(of: "-", with: "+")
|
||
.replacingOccurrences(of: "_", with: "/")
|
||
|
||
// Pad with "=" to make length a multiple of 4
|
||
let paddingLength = 4 - (base64.count % 4)
|
||
if paddingLength < 4 {
|
||
base64 += String(repeating: "=", count: paddingLength)
|
||
}
|
||
|
||
return base64
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Status Light Components
|
||
|
||
private struct StatusLight: View {
|
||
enum State { case ok, warn }
|
||
var title: String
|
||
var state: State
|
||
|
||
var body: some View {
|
||
let fixedHeight: CGFloat = 32
|
||
HStack(spacing: 8) {
|
||
StatusDot(color: state == .ok ? .green : .orange)
|
||
Text(title)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundColor(.primary)
|
||
}
|
||
.frame(height: fixedHeight)
|
||
.padding(.horizontal, 10)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(Color(UIColor.tertiarySystemBackground))
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct StatusDot: View {
|
||
var color: Color
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(color.opacity(0.25))
|
||
.frame(width: 20, height: 20)
|
||
Circle()
|
||
.fill(color)
|
||
.frame(width: 12, height: 12)
|
||
.shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
|
||
}
|
||
.overlay(
|
||
Circle()
|
||
.stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
|
||
)
|
||
}
|
||
}
|
||
|
||
class InstalledAppsViewModel: ObservableObject {
|
||
@Published var apps: [String: String] = [:]
|
||
|
||
init() {
|
||
loadApps()
|
||
}
|
||
|
||
func loadApps() {
|
||
do {
|
||
self.apps = try JITEnableContext.shared.getAppList()
|
||
} catch {
|
||
print(error)
|
||
self.apps = [:]
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
#Preview {
|
||
HomeView()
|
||
}
|