StikDebug/StikJIT/Views/HomeView.swift
2025-09-05 12:44:01 -04:00

794 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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