mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
* Heartbeat rework * remove some debug loggings * stik mini tool poc * Saperate JITEnableContext into extensions and put each feature into their own files. Fix local pairing file is removed after switched to external device by directly loading the remote device's pairing file in place. * custom origin & xmlhttprequest * Fix some memory leak * allow background script access idevice (codes are from the old JSSupport branch) Tests are not carried out but most of the js api should work. Do we need to obfuscate some functions' name? * Fix JITEnableContext singleton initializer race condition * Fix instprox callback --------- Co-authored-by: Stephen B <158498287+StephenDev0@users.noreply.github.com>
529 lines
20 KiB
Swift
529 lines
20 KiB
Swift
//
|
|
// ScriptListView.swift
|
|
// StikDebug
|
|
//
|
|
// Created by s s on 2025/7/4.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import UIKit
|
|
|
|
struct ScriptListView: View {
|
|
@State private var scripts: [URL] = []
|
|
@State private var showNewFileAlert = false
|
|
@State private var newFileName = ""
|
|
@State private var showImporter = false
|
|
@AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js"
|
|
|
|
@State private var isBusy = false
|
|
@State private var alertVisible = false
|
|
@State private var alertTitle = ""
|
|
@State private var alertMessage = ""
|
|
@State private var alertIsSuccess = false
|
|
@State private var justCopied = false
|
|
@State private var searchText = ""
|
|
|
|
@State private var showDeleteConfirmation = false
|
|
@State private var pendingDelete: URL? = nil
|
|
|
|
var onSelectScript: ((URL?) -> Void)? = nil
|
|
|
|
private var isPickerMode: Bool { onSelectScript != nil }
|
|
|
|
private var filteredScripts: [URL] {
|
|
guard !searchText.isEmpty else { return scripts }
|
|
return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) }
|
|
}
|
|
|
|
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
|
@Environment(\.themeExpansionManager) private var themeExpansion
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
|
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ThemedBackground(style: backgroundStyle)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
headerCard
|
|
|
|
if filteredScripts.isEmpty {
|
|
emptyCard
|
|
} else {
|
|
ForEach(filteredScripts, id: \.self) { script in
|
|
scriptRow(script)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 30)
|
|
}
|
|
|
|
if isBusy {
|
|
Color.black.opacity(0.35).ignoresSafeArea()
|
|
ProgressView("Working…")
|
|
.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)
|
|
}
|
|
|
|
if alertVisible {
|
|
CustomErrorView(title: alertTitle,
|
|
message: alertMessage,
|
|
onDismiss: { alertVisible = false },
|
|
messageType: alertIsSuccess ? .success : .error)
|
|
}
|
|
|
|
if justCopied {
|
|
VStack {
|
|
Spacer()
|
|
Text("Copied")
|
|
.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: justCopied)
|
|
}
|
|
}
|
|
.navigationTitle(isPickerMode ? "Choose Script" : "Scripts")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
|
if !isPickerMode {
|
|
Button { showNewFileAlert = true } label: {
|
|
Label("New", systemImage: "doc.badge.plus")
|
|
}
|
|
Button { showImporter = true } label: {
|
|
Label("Import", systemImage: "tray.and.arrow.down")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear(perform: loadScripts)
|
|
.alert("New Script", isPresented: $showNewFileAlert) {
|
|
TextField("Filename", text: $newFileName)
|
|
Button("Create", action: createNewScript)
|
|
Button("Cancel", role: .cancel) { }
|
|
}
|
|
.alert("Delete Script?", isPresented: $showDeleteConfirmation, presenting: pendingDelete) { script in
|
|
Button("Delete", role: .destructive) { deleteScript(script) }
|
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
|
} message: { script in
|
|
Text("Are you sure you want to delete \(script.lastPathComponent)? This cannot be undone.")
|
|
}
|
|
.fileImporter(
|
|
isPresented: $showImporter,
|
|
allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText]
|
|
) { result in
|
|
switch result {
|
|
case .success(let fileURL): importScript(from: fileURL)
|
|
case .failure(let error): presentError(title: "Import Failed", message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(preferredScheme)
|
|
}
|
|
|
|
// MARK: - Cards
|
|
|
|
private var headerCard: some View {
|
|
VStack(spacing: 12) {
|
|
TextField("Search scripts…", text: $searchText)
|
|
.padding(12)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
|
)
|
|
|
|
HStack(spacing: 12) {
|
|
if isPickerMode {
|
|
WideGlassyButton(title: "None", systemImage: "nosign") {
|
|
onSelectScript?(nil)
|
|
}
|
|
WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") {
|
|
showImporter = true
|
|
}
|
|
} else {
|
|
WideGlassyButton(title: "New", systemImage: "doc.badge.plus") {
|
|
showNewFileAlert = true
|
|
}
|
|
WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") {
|
|
showImporter = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(glassyBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func scriptRow(_ script: URL) -> some View {
|
|
if isPickerMode {
|
|
Button {
|
|
onSelectScript?(script)
|
|
} label: {
|
|
scriptCard(script, showDefaultStar: true, showDelete: false)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
NavigationLink {
|
|
ScriptEditorView(scriptURL: script)
|
|
} label: {
|
|
scriptCard(script, showDefaultStar: true, showDelete: true)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private func scriptCard(_ script: URL, showDefaultStar: Bool, showDelete: Bool) -> some View {
|
|
let isDefault = defaultScriptName == script.lastPathComponent
|
|
|
|
return HStack(spacing: 12) {
|
|
Image(systemName: "doc.text.fill")
|
|
.foregroundColor(.blue)
|
|
.imageScale(.large)
|
|
|
|
Text(script.lastPathComponent)
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
if showDefaultStar, isDefault {
|
|
Image(systemName: "star.fill")
|
|
.foregroundColor(.yellow)
|
|
}
|
|
|
|
if showDelete {
|
|
Button(role: .destructive) {
|
|
pendingDelete = script
|
|
showDeleteConfirmation = true
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.foregroundColor(.red)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(glassyBackground)
|
|
.contextMenu {
|
|
Button { copyName(script) } label: {
|
|
Label("Copy Filename", systemImage: "doc.on.doc")
|
|
}
|
|
Button { copyPath(script) } label: {
|
|
Label("Copy Path", systemImage: "folder")
|
|
}
|
|
if !isPickerMode {
|
|
Button { saveDefaultScript(script) } label: {
|
|
Label("Set Default", systemImage: "star")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyCard: some View {
|
|
VStack(spacing: 6) {
|
|
Label(isPickerMode ? "No scripts available" : "No scripts found",
|
|
systemImage: "doc.text.magnifyingglass")
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(40)
|
|
.frame(maxWidth: .infinity)
|
|
.background(glassyBackground)
|
|
}
|
|
|
|
private var glassyBackground: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: overlayColors()),
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.opacity(0.32)
|
|
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 overlayColors() -> [Color] {
|
|
let colors: [Color]
|
|
switch backgroundStyle {
|
|
case .staticGradient(let palette):
|
|
colors = palette
|
|
case .animatedGradient(let palette, _):
|
|
colors = palette
|
|
case .blobs(_, let background):
|
|
colors = background
|
|
case .particles(let particle, let background):
|
|
colors = background.isEmpty ? [particle, particle.opacity(0.4)] : background
|
|
case .customGradient(let palette):
|
|
colors = palette
|
|
case .adaptiveGradient(let light, let dark):
|
|
colors = colorScheme == .dark ? dark : light
|
|
}
|
|
if colors.count >= 2 { return colors }
|
|
if let first = colors.first { return [first, first.opacity(0.6)] }
|
|
return [Color.blue, Color.purple]
|
|
}
|
|
|
|
// MARK: - File Ops
|
|
|
|
private func scriptsDirectory() -> URL {
|
|
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("scripts")
|
|
var isDir: ObjCBool = false
|
|
let exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir)
|
|
do {
|
|
if exists && !isDir.boolValue {
|
|
try FileManager.default.removeItem(at: dir)
|
|
}
|
|
if !exists || !isDir.boolValue {
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
}
|
|
try ensureDefaultScripts(in: dir)
|
|
} catch {
|
|
presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
private func ensureDefaultScripts(in directory: URL) throws {
|
|
let fm = FileManager.default
|
|
let bundledScripts: [(resource: String, filename: String)] = [
|
|
("attachDetach", "attachDetach.js"),
|
|
("maciOS", "maciOS.js"),
|
|
("Amethyst", "Amethyst.js"),
|
|
("Geode", "Geode.js"),
|
|
("MeloNX", "MeloNX.js"),
|
|
("manic", "manic.js"),
|
|
("UTM-Dolphin", "UTM-Dolphin.js")
|
|
]
|
|
|
|
for entry in bundledScripts {
|
|
if let bundleURL = Bundle.main.url(forResource: entry.resource, withExtension: "js") {
|
|
let destination = directory.appendingPathComponent(entry.filename)
|
|
if !fm.fileExists(atPath: destination.path) {
|
|
try fm.copyItem(at: bundleURL, to: destination)
|
|
}
|
|
}
|
|
}
|
|
let screenshotURL = directory.appendingPathComponent("screenshot-demo.js")
|
|
if !fm.fileExists(atPath: screenshotURL.path) {
|
|
try screenshotDemoScript.write(to: screenshotURL, atomically: true, encoding: .utf8)
|
|
}
|
|
let standaloneURL = directory.appendingPathComponent("screenshot-capture.js")
|
|
if !fm.fileExists(atPath: standaloneURL.path) {
|
|
try screenshotCaptureScript.write(to: standaloneURL, atomically: true, encoding: .utf8)
|
|
}
|
|
}
|
|
|
|
private func loadScripts() {
|
|
let dir = scriptsDirectory()
|
|
scripts = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
|
|
.filter { $0.pathExtension.lowercased() == "js" }
|
|
.sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } ?? []
|
|
}
|
|
|
|
private func saveDefaultScript(_ url: URL) {
|
|
defaultScriptName = url.lastPathComponent
|
|
presentSuccess(title: "Default Script Set", message: url.lastPathComponent)
|
|
}
|
|
|
|
private func createNewScript() {
|
|
guard !newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
|
var filename = newFileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !filename.hasSuffix(".js") { filename += ".js" }
|
|
let newURL = scriptsDirectory().appendingPathComponent(filename)
|
|
guard !FileManager.default.fileExists(atPath: newURL.path) else {
|
|
presentError(title: "Failed to Create New Script", message: "A script with the same name already exists.")
|
|
return
|
|
}
|
|
do {
|
|
try "".write(to: newURL, atomically: true, encoding: .utf8)
|
|
newFileName = ""
|
|
loadScripts()
|
|
presentSuccess(title: "Created", message: filename)
|
|
} catch {
|
|
presentError(title: "Error Creating File", message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func deleteScript(_ url: URL) {
|
|
do {
|
|
try FileManager.default.removeItem(at: url)
|
|
if url.lastPathComponent == defaultScriptName {
|
|
UserDefaults.standard.removeObject(forKey: "DefaultScriptName")
|
|
}
|
|
loadScripts()
|
|
} catch {
|
|
presentError(title: "Delete Failed", message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func importScript(from fileURL: URL) {
|
|
isBusy = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
defer { DispatchQueue.main.async { self.isBusy = false } }
|
|
do {
|
|
let dest = self.scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent)
|
|
if FileManager.default.fileExists(atPath: dest.path) {
|
|
try FileManager.default.removeItem(at: dest)
|
|
}
|
|
try FileManager.default.copyItem(at: fileURL, to: dest)
|
|
DispatchQueue.main.async {
|
|
self.loadScripts()
|
|
self.presentSuccess(title: "Imported", message: fileURL.lastPathComponent)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
self.presentError(title: "Import Failed", message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Feedback helpers
|
|
|
|
private func presentError(title: String, message: String) {
|
|
alertTitle = title; alertMessage = message
|
|
alertIsSuccess = false; alertVisible = true
|
|
}
|
|
|
|
private func presentSuccess(title: String, message: String) {
|
|
alertTitle = title; alertMessage = message
|
|
alertIsSuccess = true; alertVisible = true
|
|
}
|
|
|
|
private func copyName(_ url: URL) {
|
|
UIPasteboard.general.string = url.lastPathComponent
|
|
showCopiedToast()
|
|
}
|
|
|
|
private func copyPath(_ url: URL) {
|
|
UIPasteboard.general.string = url.path
|
|
showCopiedToast()
|
|
}
|
|
|
|
private func showCopiedToast() {
|
|
withAnimation { justCopied = true }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
withAnimation { justCopied = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Equal-width rounded-rectangle button (centered content)
|
|
struct WideGlassyButton: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
.imageScale(.medium)
|
|
.font(.body.weight(.semibold))
|
|
Text(title)
|
|
.font(.body.weight(.semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.horizontal, 14)
|
|
}
|
|
.frame(height: 44)
|
|
.frame(maxWidth: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
|
)
|
|
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private let screenshotDemoScript = """
|
|
// Screenshot Demo Script
|
|
// Attaches to the target, captures a PNG screenshot, and detaches.
|
|
|
|
function takeScreenshotDemo() {
|
|
log("[ScreenshotDemo] Starting demo");
|
|
|
|
const pid = get_pid();
|
|
log(`[ScreenshotDemo] Target PID: ${pid}`);
|
|
|
|
const attachResponse = send_command(`vAttach;${pid.toString(16)}`);
|
|
log(`[ScreenshotDemo] attach_response = ${attachResponse}`);
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const fileName = `screenshot-${timestamp}.png`;
|
|
const savedPath = take_screenshot(fileName);
|
|
|
|
if (savedPath && savedPath.length > 0) {
|
|
log(`[ScreenshotDemo] Screenshot saved to ${savedPath}`);
|
|
} else {
|
|
log("[ScreenshotDemo] Device did not report a saved path.");
|
|
}
|
|
|
|
const detachResponse = send_command("D");
|
|
log(`[ScreenshotDemo] detach_response = ${detachResponse}`);
|
|
log("[ScreenshotDemo] Demo complete.");
|
|
}
|
|
|
|
takeScreenshotDemo();
|
|
"""
|
|
|
|
private let screenshotCaptureScript = """
|
|
// Screenshot Capture Script
|
|
// Takes a screenshot without sending any debugserver commands.
|
|
|
|
function captureScreenshot() {
|
|
log("[ScreenshotCapture] Requesting screenshot without attaching…");
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const fileName = `standalone-${timestamp}.png`;
|
|
const savedPath = take_screenshot(fileName);
|
|
|
|
if (savedPath && savedPath.length > 0) {
|
|
log(`[ScreenshotCapture] Screenshot saved to ${savedPath}`);
|
|
} else {
|
|
log("[ScreenshotCapture] Device did not report a saved path.");
|
|
}
|
|
|
|
log("[ScreenshotCapture] Done.");
|
|
}
|
|
|
|
captureScreenshot();
|
|
"""
|