mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
454 lines
18 KiB
Swift
454 lines
18 KiB
Swift
//
|
|
// HomeView.swift
|
|
// StikDebug
|
|
//
|
|
// Created by Stephen on 3/26/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct HomeView: View {
|
|
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
|
|
@AppStorage("bundleID") private var bundleID: String = ""
|
|
@AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true
|
|
|
|
@ObservedObject private var mounting = MountingProgress.shared
|
|
|
|
@State private var hasAppeared = false
|
|
@State private var pendingJITEnableConfiguration: JITEnableConfiguration?
|
|
@State private var isShowingPairingFilePicker = false
|
|
@State private var debugFeedback: DebugFeedback?
|
|
@State private var pendingExternalURLAction: HomeExternalAction?
|
|
@State private var pendingLiveContainerApp: LiveContainerApp?
|
|
@State private var scriptRunModel: RunJSViewModel?
|
|
|
|
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
|
|
private struct DebugFeedback: Identifiable {
|
|
let id = UUID()
|
|
let message: String
|
|
let isError: Bool
|
|
let isWorking: Bool
|
|
}
|
|
|
|
private struct LiveContainerApp {
|
|
let bundleID: String
|
|
let name: String
|
|
}
|
|
|
|
var body: some View {
|
|
InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in
|
|
if selectedName.localizedCaseInsensitiveContains("LiveContainer") {
|
|
pendingLiveContainerApp = LiveContainerApp(bundleID: selectedBundle, name: selectedName)
|
|
} else {
|
|
enableJITForSelectedApp(bundleID: selectedBundle, name: selectedName)
|
|
}
|
|
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
|
.overlay(alignment: .bottom) {
|
|
if let debugFeedback {
|
|
debugFeedbackView(debugFeedback)
|
|
.padding(.bottom, 40)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.onAppear(perform: handleAppear)
|
|
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification)
|
|
.onReceive(timer) { _ in
|
|
refreshMountStatusIfNeeded()
|
|
}
|
|
.onOpenURL { url in
|
|
handleExternalURL(url)
|
|
}
|
|
.confirmationDialog(
|
|
pendingExternalURLAction?.title ?? "External Request",
|
|
isPresented: Binding(
|
|
get: { pendingExternalURLAction != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
pendingExternalURLAction = nil
|
|
}
|
|
}
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingExternalURLAction
|
|
) { action in
|
|
Button(action.confirmationTitle, role: action.role) {
|
|
performExternalURLAction(action)
|
|
pendingExternalURLAction = nil
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
pendingExternalURLAction = nil
|
|
}
|
|
} message: { action in
|
|
Text(action.message)
|
|
}
|
|
.alert(
|
|
"Enable JIT for LiveContainer?",
|
|
isPresented: Binding(
|
|
get: { pendingLiveContainerApp != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
pendingLiveContainerApp = nil
|
|
}
|
|
}
|
|
),
|
|
presenting: pendingLiveContainerApp
|
|
) { app in
|
|
Button("Cancel", role: .cancel) {
|
|
pendingLiveContainerApp = nil
|
|
}
|
|
Button("Proceed") {
|
|
pendingLiveContainerApp = nil
|
|
enableJITForSelectedApp(bundleID: app.bundleID, name: app.name)
|
|
}
|
|
} message: { _ in
|
|
Text("StikDebug isn't meant to enable JIT for LiveContainer manually. Select StikDebug in LiveContainer settings, enable \"Run with JIT\" for your app, then open your app normally. It will automatically jump back here.")
|
|
}
|
|
.fileImporter(
|
|
isPresented: $isShowingPairingFilePicker,
|
|
allowedContentTypes: PairingFileStore.supportedContentTypes,
|
|
onCompletion: importPairingFile
|
|
)
|
|
.sheet(item: $scriptRunModel) { model in
|
|
NavigationStack {
|
|
RunJSView(model: model)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") { scriptRunModel = nil }
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func enableJITForSelectedApp(bundleID selectedBundle: String, name: String) {
|
|
bundleID = selectedBundle
|
|
Haptics.medium()
|
|
startJITInBackground(bundleID: selectedBundle, displayName: name)
|
|
}
|
|
|
|
private func handleAppear() {
|
|
startTunnelInBackground()
|
|
MountingProgress.shared.checkforMounted()
|
|
hasAppeared = true
|
|
|
|
if let config = pendingJITEnableConfiguration {
|
|
startJITInBackground(
|
|
bundleID: config.bundleID,
|
|
pid: config.pid,
|
|
scriptData: config.scriptData,
|
|
scriptName: config.scriptName,
|
|
triggeredByURLScheme: true
|
|
)
|
|
pendingJITEnableConfiguration = nil
|
|
}
|
|
}
|
|
|
|
private func handleScriptReadyNotification(_ notification: Notification) {
|
|
guard let model = notification.userInfo?["model"] as? RunJSViewModel else {
|
|
return
|
|
}
|
|
|
|
scriptRunModel = model
|
|
}
|
|
|
|
private func refreshMountStatusIfNeeded() {
|
|
guard mounting.mountingThread == nil, !mounting.coolisMounted else {
|
|
return
|
|
}
|
|
MountingProgress.shared.checkforMounted()
|
|
}
|
|
|
|
private func importPairingFile(_ result: Result<URL, Error>) {
|
|
switch result {
|
|
case .success(let url):
|
|
do {
|
|
try PairingFileStore.importFromPicker(url)
|
|
markTunnelDisconnected()
|
|
startTunnelInBackground()
|
|
NotificationCenter.default.post(name: .pairingFileImported, object: nil)
|
|
AlertPresenter.dismissPresentedAlert()
|
|
} catch {
|
|
LogManager.shared.addErrorLog("Failed to import pairing file: \(error.localizedDescription)")
|
|
}
|
|
case .failure(let error):
|
|
LogManager.shared.addErrorLog("Pairing file picker failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func queryValue(_ names: [String], in components: URLComponents?) -> String? {
|
|
guard let queryItems = components?.queryItems else { return nil }
|
|
for name in names {
|
|
if let rawValue = queryItems.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value {
|
|
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func handleExternalURL(_ url: URL) {
|
|
guard let host = url.host()?.lowercased() else { return }
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
|
|
switch host {
|
|
case "enable-jit":
|
|
var config = JITEnableConfiguration()
|
|
if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) {
|
|
config.pid = pid
|
|
}
|
|
if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) {
|
|
config.bundleID = bundleId
|
|
}
|
|
if let scriptBase64URL = queryValue(["script-data", "scriptData", "script_data"], in: components)?.removingPercentEncoding {
|
|
let base64 = base64URLToBase64(scriptBase64URL)
|
|
if let scriptData = Data(base64Encoded: base64) {
|
|
config.scriptData = scriptData
|
|
}
|
|
}
|
|
if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) {
|
|
config.scriptName = scriptName
|
|
if config.scriptData == nil {
|
|
if let namedScript = ScriptStore.script(named: scriptName) {
|
|
config.scriptData = namedScript.data
|
|
config.scriptName = namedScript.name
|
|
} else {
|
|
LogManager.shared.addWarningLog("Script \(scriptName) was not found in the scripts folder")
|
|
}
|
|
}
|
|
}
|
|
if config.scriptData == nil, let bundleID = config.bundleID,
|
|
let scriptInfo = ScriptStore.preferredScript(for: bundleID) {
|
|
config.scriptData = scriptInfo.data
|
|
config.scriptName = scriptInfo.name
|
|
}
|
|
let action = HomeExternalAction.enableJIT(config)
|
|
if confirmExternalJITRequests {
|
|
pendingExternalURLAction = action
|
|
} else {
|
|
performExternalURLAction(action)
|
|
}
|
|
case "kill-process":
|
|
if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) {
|
|
pendingExternalURLAction = .killProcess(pid)
|
|
}
|
|
case "launch-app":
|
|
if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) {
|
|
pendingExternalURLAction = .launchApp(bundleId)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func performExternalURLAction(_ action: HomeExternalAction) {
|
|
switch action {
|
|
case .enableJIT(let config):
|
|
if hasAppeared {
|
|
startJITInBackground(
|
|
bundleID: config.bundleID,
|
|
pid: config.pid,
|
|
scriptData: config.scriptData,
|
|
scriptName: config.scriptName,
|
|
triggeredByURLScheme: true
|
|
)
|
|
} else {
|
|
pendingJITEnableConfiguration = config
|
|
}
|
|
case .killProcess(let pid):
|
|
markTunnelDisconnected()
|
|
startTunnelInBackground(showErrorUI: false)
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
sleep(1)
|
|
do {
|
|
try JITEnableContext.shared.killProcess(withPID: Int32(pid))
|
|
DispatchQueue.main.async {
|
|
LogManager.shared.addInfoLog("Killed process \(pid) via URL scheme")
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
LogManager.shared.addErrorLog("Failed to kill process \(pid): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
case .launchApp(let bundleID):
|
|
Haptics.medium()
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func debugFeedbackView(_ feedback: DebugFeedback) -> some View {
|
|
HStack(spacing: 10) {
|
|
if feedback.isWorking {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
Text(feedback.message)
|
|
.font(.subheadline.weight(.semibold))
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Capsule().fill(.ultraThinMaterial))
|
|
.foregroundStyle(feedback.isError ? .red : .primary)
|
|
.shadow(radius: 4)
|
|
.padding(.horizontal, 20)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(feedback.message)
|
|
}
|
|
|
|
private func getJsCallback(_ script: Data, name: String? = nil, resumeBundleID: String? = nil) -> DebugAppCallback {
|
|
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
|
|
let model = RunJSViewModel(pid: Int(pid),
|
|
debugProxy: debugProxyHandle,
|
|
remoteServer: remoteServerHandle,
|
|
semaphore: semaphore,
|
|
resumeBundleID: resumeBundleID)
|
|
|
|
DispatchQueue.main.async {
|
|
scriptRunModel = model
|
|
}
|
|
|
|
do {
|
|
try model.runScript(data: script, name: name)
|
|
} catch {
|
|
semaphore.signal()
|
|
DispatchQueue.main.async {
|
|
showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false, displayName: String? = nil) {
|
|
let targetName = displayName ?? bundleID ?? pid.map { String(format: "process %d".localized, $0) } ?? "app".localized
|
|
let startingMessage = String(format: "Starting JIT for %@".localized, targetName)
|
|
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
|
withAnimation {
|
|
debugFeedback = DebugFeedback(message: startingMessage, isError: false, isWorking: true)
|
|
}
|
|
AccessibilityAnnouncer.announce(startingMessage)
|
|
|
|
if triggeredByURLScheme {
|
|
markTunnelDisconnected()
|
|
startTunnelInBackground(showErrorUI: false)
|
|
}
|
|
|
|
DispatchQueue.global(qos: .background).async {
|
|
let keepAliveLease = DebugKeepAliveLease()
|
|
defer { keepAliveLease.invalidate() }
|
|
|
|
if triggeredByURLScheme, !waitForJITPrerequisites() {
|
|
DispatchQueue.main.async {
|
|
withAnimation {
|
|
debugFeedback = nil
|
|
}
|
|
showAlert(
|
|
title: "Failed to Enable JIT".localized,
|
|
message: "The device connection or Developer Disk Image wasn't ready in time. Open StikDebug directly, wait for it to finish connecting, then try again.".localized,
|
|
showOk: true
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
let finishProcessing: (Bool, String?) -> Void = { success, detail in
|
|
DispatchQueue.main.async {
|
|
let message = success
|
|
? String(format: "JIT request completed for %@".localized, targetName)
|
|
: String(format: "JIT failed for %@".localized, targetName)
|
|
let feedback = DebugFeedback(message: message, isError: !success, isWorking: false)
|
|
withAnimation {
|
|
debugFeedback = feedback
|
|
}
|
|
AccessibilityAnnouncer.announce(message)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
if debugFeedback?.id == feedback.id {
|
|
withAnimation {
|
|
debugFeedback = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if !success {
|
|
let failureMessage = detail ?? "StikDebug could not launch or attach to the selected app. Check that the VPN is enabled, the pairing file is current, and the app is still installed.".localized
|
|
showAlert(title: "Failed to Enable JIT".localized, message: failureMessage, showOk: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
var scriptData = scriptData
|
|
var scriptName = scriptName
|
|
if scriptData == nil,
|
|
let bundleID,
|
|
let preferred = ScriptStore.preferredScript(for: bundleID) {
|
|
scriptName = preferred.name
|
|
scriptData = preferred.data
|
|
}
|
|
|
|
let resumeBundleID = pid == nil ? nil : bundleID
|
|
|
|
var callback: DebugAppCallback? = nil
|
|
if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
|
|
callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script", resumeBundleID: resumeBundleID)
|
|
}
|
|
|
|
var lastDebugMessage: String?
|
|
let logger: LogFunc = { message in
|
|
if let message {
|
|
lastDebugMessage = 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 {
|
|
lastDebugMessage = "Either bundle ID or PID should be specified.".localized
|
|
success = false
|
|
}
|
|
|
|
if success {
|
|
DispatchQueue.main.async {
|
|
LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))")
|
|
|
|
if doAutoQuitAfterEnablingJIT {
|
|
exit(0)
|
|
}
|
|
}
|
|
}
|
|
finishProcessing(success, success ? nil : lastDebugMessage)
|
|
}
|
|
}
|
|
|
|
private func waitForJITPrerequisites(timeout: TimeInterval = 20) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted {
|
|
return true
|
|
}
|
|
usleep(250_000)
|
|
}
|
|
return TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted
|
|
}
|
|
|
|
private func base64URLToBase64(_ base64url: String) -> String {
|
|
var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
let pad = 4 - (base64.count % 4)
|
|
if pad < 4 { base64 += String(repeating: "=", count: pad) }
|
|
return base64
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
HomeView()
|
|
}
|